diff options
Diffstat (limited to 'toolkit/components/pictureinpicture')
67 files changed, 6904 insertions, 0 deletions
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 <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> 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 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" style="background-color:#fff" xmlns:xlink="http://www.w3.org/1999/xlink" width="1241" height="1169" viewBox="-0.5 -0.5 1241 1169"><path fill="#ffe6cc" stroke="#d79b00" pointer-events="all" d="M0 860h390v280H0z"/><path d="M290 1070v20h761l-.01 58.86" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M1050.99 1155.61l-4.5-9 4.5 2.25 4.5-2.25z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><path fill="none" stroke="#000" pointer-events="all" d="M80 960h210v110H80z"/><path d="M0 0h480v340H0z" fill="#ffe6cc" stroke="#d79b00" stroke-miterlimit="10" pointer-events="all"/><circle cx="415" cy="15" fill="transparent" stroke="#d79b00" pointer-events="all" r="10"/><circle cx="440" cy="15" fill="transparent" stroke="#d79b00" pointer-events="all" r="10"/><circle cx="465" cy="15" fill="transparent" stroke="#008cff" pointer-events="all" r="10"/><path d="M0 40h30V15c0-2.76 2.24-5 5-5h135c2.76 0 5 2.24 5 5v25h305M0 110h480M100 60c0-2.76 2.24-5 5-5h360c2.76 0 5 2.24 5 5v25c0 2.76-2.24 5-5 5H105c-2.76 0-5-2.24-5-5z" fill="none" stroke="#c4c4c4" stroke-miterlimit="10" pointer-events="all"/><path d="M37 17h11l4 4v14H37z" fill="none" stroke="#c4c4c4" stroke-miterlimit="10" pointer-events="all"/><path d="M48 17v4l4 1" fill="none" stroke="#c4c4c4" stroke-miterlimit="10" pointer-events="all"/><path d="M107 64h11l4 4v14h-15z" fill="none" stroke="#c4c4c4" stroke-width="2" stroke-miterlimit="10" pointer-events="all"/><path d="M118 64v4l4 1" fill="none" stroke="#c4c4c4" stroke-miterlimit="10" pointer-events="all"/><path d="M12 74l10-10v6h10v8H22v6zM62 74L52 64v6H42v8h10v6zM87.6 77.3a6 6 0 01-8.22 2.06c-2.84-1.69-3.77-5.36-2.09-8.21 1.69-2.84 5.36-3.79 8.21-2.11l-1.6 1.46 7.9 1.8-1.8-7.5-1.7 1.6a9.816 9.816 0 00-10.91-.53 9.799 9.799 0 00-4.62 9.89c.6 3.93 3.53 7.1 7.39 8.03 3.87.93 7.91-.57 10.24-3.79z" fill="#c4c4c4" stroke="#c4c4c4" stroke-width="2" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:25px;margin-left:62px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:17px;font-family:Helvetica;color:#666;line-height:1.2;pointer-events:all;white-space:nowrap"><div>Twitch</div></div></div></div></foreignObject><text x="62" y="30" fill="#666" font-family="Helvetica" font-size="17">Twitch</text></switch><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:73px;margin-left:132px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:17px;font-family:Helvetica;color:#666;line-height:1.2;pointer-events:all;white-space:nowrap"><div>https://www.twitch.tv</div></div></div></div></foreignObject><text x="132" y="78" fill="#666" font-family="Helvetica" font-size="17">https://www.twitch.tv</text></switch><path fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" d="M0 110h480v230H0z"/><path d="M78.08 315c-3.08 0-5.58-2.3-5.58-5.14V140.14c0-2.84 2.5-5.14 5.58-5.14h323.84c3.08 0 5.58 2.3 5.58 5.14v169.72c0 2.84-2.5 5.14-5.58 5.14z" fill="#f66" pointer-events="all"/><path d="M221.65 267.98l44.66-23.6-44.66-23.44zm-39.3 24.5c-4.07 0-7.3-3.53-7.3-6.75v-82.85c0-3.8 3.8-6.79 7.29-6.79h114.89c5.16 0 7.72 3.99 7.72 6.76v82.78c0 3.57-3.4 6.85-7.44 6.85zM75.29 176.14v133.72c0 1.42 1.25 2.57 2.79 2.57h323.84c1.54 0 2.79-1.15 2.79-2.57V176.14z" fill-opacity=".5" fill="#fff" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe center;width:1px;height:1px;padding-top:136px;margin-left:240px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#fff;line-height:1.2;pointer-events:all;white-space:nowrap">Video</div></div></div></foreignObject><text x="240" y="148" fill="#FFF" font-family="Helvetica" font-size="12" text-anchor="middle">Video</text></switch><path fill="#08f" stroke="#000" pointer-events="all" d="M387.5 225h20v20h-20z"/><path fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" d="M0 420h710v380H0z"/><path fill="none" pointer-events="all" d="M0 420h200v30H0z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:423px;margin-left:2px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap"><div>Content process hosting the video</div><div><br/></div></div></div></div></foreignObject><text x="2" y="435" font-family="Helvetica" font-size="12">Content process hosting the video</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M230 450h180v80H230z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:490px;margin-left:231px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">videocontrols.js</div></div></div></foreignObject><text x="320" y="494" font-family="Helvetica" font-size="12" text-anchor="middle">videocontrols.js</text></switch><path d="M407.5 235h10q10 0 10 10v92.5q0 10-10 10H220q-10 0-10 10V480q0 10 10 10h10M175 630v69.9" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M175 706.65l-4.5-9 4.5 2.25 4.5-2.25z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:670px;margin-left:175px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">MozTogglePictureInPicture chrome event</div></div></div></foreignObject><text x="175" y="673" font-family="Helvetica" font-size="11" text-anchor="middle">MozTogglePictureInPicture chrome event</text></switch><path d="M50 585H30v245h195v119.9" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M225 956.65l-4.5-9 4.5 2.25 4.5-2.25z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:831px;margin-left:91px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">JSWindowActor messaging</div></div></div></foreignObject><text x="91" y="834" font-family="Helvetica" font-size="12" text-anchor="middle">JSWindowActor messaging</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M50 540h250v90H50z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:248px;height:1px;padding-top:585px;margin-left:51px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureToggleChild</div></div></div></foreignObject><text x="175" y="589" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureToggleChild</text></switch><path d="M300 750h20v290h-39.9" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M273.35 1040l9-4.5-2.25 4.5 2.25 4.5z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:833px;margin-left:319px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">JSWindowActor messaging</div></div></div></foreignObject><text x="319" y="836" font-family="Helvetica" font-size="12" text-anchor="middle">JSWindowActor messaging</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M50 710h250v80H50z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:248px;height:1px;padding-top:750px;margin-left:51px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal"><div>PictureInPictureLauncherChild<br/></div></div></div></div></foreignObject><text x="175" y="754" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureLauncherChild</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M430 680h250v80H430z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:248px;height:1px;padding-top:720px;margin-left:431px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureChild for player <video></div></div></div></foreignObject><text x="555" y="724" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureChild for player <video></text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M90 960h180v40H90z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:980px;margin-left:91px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureToggleParent</div></div></div></foreignObject><text x="180" y="984" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureToggleParent</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M90 1020h180v40H90z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:1040px;margin-left:91px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureLauncherParent</div></div></div></foreignObject><text x="180" y="1044" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureLauncherParent</text></switch><path fill="none" pointer-events="all" d="M80 930h120v30H80z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:933px;margin-left:82px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap"><div>PictureInPicture.jsm</div><div><br/></div></div></div></div></foreignObject><text x="82" y="945" font-family="Helvetica" font-size="12">PictureInPicture.jsm</text></switch><path fill="#ffe6cc" stroke="#d79b00" pointer-events="all" d="M830 640h410v520H830z"/><path fill="none" pointer-events="all" d="M0 860h100v20H0z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:863px;margin-left:2px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap">Parent process</div></div></div></foreignObject><text x="2" y="875" font-family="Helvetica" font-size="12">Parent process</text></switch><path fill="none" pointer-events="all" d="M840 650h250v20H840z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:653px;margin-left:842px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap">player.xhtml / player.js - alwaysontop window</div></div></div></foreignObject><text x="842" y="665" font-family="Helvetica" font-size="12">player.xhtml / player.js - alwaysontop wi...</text></switch><path fill="#dae8fc" stroke="#6c8ebf" pointer-events="all" d="M840 920h390v230H840z"/><path fill="none" pointer-events="all" d="M850 920h340v30H850z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe flex-start;width:1px;height:1px;padding-top:923px;margin-left:852px"><div style="box-sizing:border-box;font-size:0;text-align:left"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:nowrap"><div>remote <xul:browser> running in the same content process as</div><div>original video</div></div></div></div></foreignObject><text x="852" y="935" font-family="Helvetica" font-size="12">remote <xul:browser> running in the same content process...</text></switch><path d="M865.58 1125c-1.48 0-2.9-.47-3.94-1.3-1.05-.83-1.64-1.95-1.64-3.13V974.43c0-1.18.59-2.3 1.64-3.13 1.04-.83 2.46-1.3 3.94-1.3h323.84c1.48 0 2.9.47 3.94 1.3 1.05.83 1.64 1.95 1.64 3.13v146.14c0 1.18-.59 2.3-1.64 3.13-1.04.83-2.46 1.3-3.94 1.3z" fill="#f66" pointer-events="all"/><path d="M1009.15 1084.51l44.66-20.32-44.66-20.19zm-39.3 21.1c-4.07 0-7.3-3.04-7.3-5.82v-71.34c0-3.27 3.8-5.84 7.29-5.84h114.89c5.16 0 7.72 3.43 7.72 5.82v71.28c0 3.08-3.4 5.9-7.44 5.9zm-107.06-100.18v115.14c0 .59.3 1.15.82 1.57.52.41 1.23.65 1.97.65h323.84c.74 0 1.45-.24 1.97-.65.52-.42.82-.98.82-1.57v-115.14z" fill-opacity=".5" fill="#fff" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe flex-start;justify-content:unsafe center;width:1px;height:1px;padding-top:971px;margin-left:1028px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#fff;line-height:1.2;pointer-events:all;white-space:nowrap">Player video</div></div></div></foreignObject><text x="1028" y="983" fill="#FFF" font-family="Helvetica" font-size="12" text-anchor="middle">Player video</text></switch><path d="M915 720H690.1" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M683.35 720l9-4.5-2.25 4.5 2.25 4.5z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:721px;margin-left:771px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap"><div>JSWindowActor</div><div>Messaging<br/></div></div></div></div></foreignObject><text x="771" y="724" font-family="Helvetica" font-size="12" text-anchor="middle">JSWindowActor...</text></switch><path fill="#fff" stroke="#000" pointer-events="all" d="M915 700h180v40H915z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:720px;margin-left:916px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">PictureInPictureParent</div></div></div></foreignObject><text x="1005" y="724" font-family="Helvetica" font-size="12" text-anchor="middle">PictureInPictureParent</text></switch><path d="M555 760h215v287.5h79.9" fill="none" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="stroke"/><path d="M856.65 1047.5l-9 4.5 2.25-4.5-2.25-4.5z" stroke="#000" stroke-width="3" stroke-miterlimit="10" pointer-events="all"/><path d="M185 1070h0" fill="none" stroke="#000" stroke-miterlimit="10" pointer-events="stroke"/><path d="M185 1070h0z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/><path fill="none" pointer-events="all" d="M570 1070h40v20h-40z"/><switch transform="translate(-.5 -.5)"><foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:38px;height:1px;padding-top:1080px;margin-left:571px"><div style="box-sizing:border-box;font-size:0;text-align:center"><div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;word-wrap:normal">Services.ww.openWindow</div></div></div></foreignObject><text x="590" y="1084" font-family="Helvetica" font-size="12" text-anchor="middle">Servic...</text></switch></svg>
\ 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 ``<video>`` element on a web page to be played within +an always-on-top video player. + +This documentation covers the architecture and inner workings of both the mechanism that +displays the ``<video>`` in the always-on-top video player, as well as the mechanism that +displays the Picture-in-Picture toggle that overlays ``<video>`` elements, which is the primary +method for launching the feature. + + +High-level overview +=================== + +The following diagram tries to illustrate the subcomponents, and how they interact with one another. + +.. image:: PiP-diagram.svg + +Let's suppose that the user has loaded a document with a ``<video>`` in it, and they decide to open +it in a Picture-in-Picture window. What happens? + +First the ``PictureInPictureToggleChild`` component notices when ``<video>`` elements are added to the +DOM, and monitors the mouse as it moves around the document. Once the mouse intersects a ``<video>``, +``PictureInPictureToggleChild`` causes the Picture-in-Picture toggle to appear on that element. + +If the user clicks on that toggle, then the ``PictureInPictureToggleChild`` dispatches a chrome-only +``MozTogglePictureInPicture`` event on the video, which is handled by the ``PictureInPictureLauncherChild`` actor +for that document. The reason for the indirection via the event is that the media context menu can also +trigger Picture-in-Picture by dispatching the same event on the video. Upon handling the event, the +``PictureInPictureLauncherChild`` actor then sends a ``PictureInPicture:Request`` message to the parent process. +The parent process opens up the always-on-top player window, with a remote ``<xul:browser>`` that runs in +the same content process as the original ``<video>``. The parent then sends a message to the player +window's remote ``<xul:browser>`` loaded in the player window. A ``PictureInPictureChild`` actor +is instantiated for the empty document loaded inside of the player window browser. This +``PictureInPictureChild`` actor constructs its own ``<video>`` element, and then tells Gecko to clone the +frames from the original ``<video>`` to the newly created ``<video>``. + +At this point, the video is displaying in the Picture-in-Picture player window. + +Next, we'll discuss the individual subcomponents, and how they operate at a more detailed level. + + +The Picture-in-Picture toggle +============================= + +One of the primary challenges faced when developing this feature was the fact that, in practice, mouse +events tend not to reach ``<video>`` elements. This is usually because the ``<video>`` element is +contained within a hierarchy of other DOM elements that are capturing and handling any events that +come down. This often occurs on sites that construct their own video controls. This is why we cannot +simply use a ``mouseover`` event handler on the ``<video>`` UAWidget - on sites that do the event +capturing, we'll never receive those events and the toggle will not be accessible. + +Other times, the problem is that the video is overlaid with a semi or fully transparent element +which captures any mouse events that would normally be dispatched to the underlying ``<video>``. +This can occur, for example, on sites that want to display an overlay when the video is paused. + +To work around this problem, the `PictureInPictureToggleChild` actor class samples the latest +``mousemove`` event every ``MOUSEMOVE_PROCESSING_DELAY_MS`` milliseconds, and then calls +``nsIDOMWindowUtils.nodesFromRect`` with the ``aOnlyVisible`` argument to get the full +list of visible nodes that exist underneath a 1x1 rect positioned at the mouse cursor. + +If a ``<video>`` is in that list, then we reach into its shadow root, and update some +attributes to tell it to maybe show the toggle. + +The underlying ``UAWidget`` for the video is defined in ``videocontrols.js``, and ultimately +chooses whether or not to display the toggle based on the following heuristics: + +1. Is the video less than 45 seconds? +2. Is either the width or the height of the video less than 160px? +3. Is the video silent? + +If any of the above is true, the underlying ``UAWidget`` will hide the toggle, since it's +unlikely that the user will want to pop the video out into an always-on-top player window. + + +Video registration +================== + +Sampling the latest ``mousemove`` event every ``MOUSEMOVE_PROCESSING_DELAY_MS`` is not free, +computationally speaking, so we only do this if there are one or more ``<video>`` elements +visible on the page. We use an ``IntersectionObserver`` to notice when there is a ``<video>`` +within the viewport, and if there are 1 or more ``<video>`` elements visible, then we start +sampling the ``mousemove`` event. + +Videos are added to the ``IntersectionObserver`` when they are added to the DOM by listening +for the ``UAWidgetSetupOrChange`` event. This is considered being "registered". + + +``docState`` +============ + +``PictureInPictureChild.jsm`` contains a ``WeakMap`` mapping ``document``'s to various information +that ``PictureInPictureToggleChild`` wants to retain for the lifetime of that ``document``. For +example, whether or not we're in the midst of handling the user clicking down on their pointer +device. Any state that needs to be remembered should be added to the ``docState`` ``WeakMap``. + + +Clicking on the toggle +====================== + +If the user clicks on the Picture-in-Picture toggle, we don't want the underlying webpage to +know that this happened, since this could result in unexpected behaviour, like a page +navigation (for example, if the ``<video>`` is a long-running advertisement that navigates +upon click). + +To accomplish this, we listen for all events fired on a mouse click on the root window during +the capturing phase. This allows us to handle the events before they are dispatched to content. + +The first event that is fired, ``pointerdown``, is captured, and we check the ``docState`` to see +whether or not we're showing a toggle on any videos. If so, we check the coordinates of that +toggle against the coordinates of the ``pointerdown`` event to determine if the user is clicking +on the toggle. If so, we set a flag in the ``docState`` so that any subsequent events from the +click (like ``mousedown``, ``mouseup``, ``pointerup``, ``click``) are captured and suppressed. +If the ``pointerdown`` event didn't occur within a toggle, we let the events pass through as +normal. + +If we determine that the click has occurred on the toggle, a ``MozTogglePictureInPicture`` event +is dispatched on the underlying ``<video>``. This event is handled by the separate +``PictureInPictureLauncherChild`` class. + +PictureInPictureLauncherChild +============================= + +A small actor class whose only responsibility is to tell the parent process to open an always-on-top-window by sending a ``PictureInPicture:Request`` message to its parent actor. + +Currently, this only occurs when a chrome-only ``MozTogglePictureInPicture`` event is dispatched by the ``PictureInPictureToggleChild`` when the user clicks the Picture-in-Picture toggle button +or uses the context-menu. + +PictureInPictureChild +===================== + +The ``PictureInPictureChild`` actor class will run in a content process containing a video, and is instantiated when the player window's `player.js` script runs its initialization. A ``PictureInPictureChild`` maps an individual ``<video>`` +to a player window instance. It creates an always-on-top window, and sets up a new ``<video>`` inside of this window to clone frames from another ``<video>`` +(which will be in the same process, and have its own ``PictureInPictureChild``). Creating this window also causes the new ``PictureInPictureChild`` to be created. +This instance will monitor the originating ``<video>`` for changes, and to receive commands from the player window if the user wants to control the ``<video>``. + +PictureInPicture.jsm +==================== + +This module runs in the parent process, and is also the scope where all ``PictureInPictureParent`` instances reside. ``PictureInPicture.jsm``'s job is to send and receive messages from ``PictureInPictureChild`` instances, and to react appropriately. + +Critically, ``PictureInPicture.jsm`` is responsible for opening up the always-on-top player window, and passing the relevant information about the ``<video>`` to be displayed to it. + + +The Picture-in-Picture player window +==================================== + +The Picture-in-Picture player window is a chrome-privileged window that loads an XHTML document. That document contains a remote ``<browser>`` element which is repurposed during window initialization to load in the same content process as the originating ``<video>``. + +The player window is where the player controls are defined, like "Play" and "Pause". When the user interacts with the player controls, a message is sent down to the appropriate ``PictureInPictureChild`` to call the appropriate method on the underlying ``<video>`` element in the originating tab. + + +Cloning the video frames +======================== + +While it appears as if the video is moving from the original ``<video>`` element to the player window, what's actually occurring is that the video frames are being *cloned* to the player window ``<video>`` element. This cloning is done at the platform level using a privileged method on the ``<video>`` element: ``cloneElementVisually``. + + +``cloneElementVisually`` +------------------------ + +.. code-block:: js + + Promise<void> video.cloneElementVisually(otherVideo); + +This will clone the frames being decoded for ``video`` and display them on the ``otherVideo`` element as well. The returned Promise resolves once the cloning has successfully started. + + +``stopCloningElementVisually`` +------------------------------ + +.. code-block:: js + + void video.stopCloningElementVisually(); + +If ``video`` is being cloned visually to another element, calling this method will stop the cloning. + + +``isCloningElementVisually`` +---------------------------- + +.. code-block:: js + + boolean video.isCloningElementVisually; + +A read-only value that returns ``true`` if ``video`` is being cloned visually. diff --git a/toolkit/components/pictureinpicture/jar.mn b/toolkit/components/pictureinpicture/jar.mn new file mode 100644 index 0000000000..19d9717138 --- /dev/null +++ b/toolkit/components/pictureinpicture/jar.mn @@ -0,0 +1,8 @@ +#filter substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +toolkit.jar: +* content/global/pictureinpicture/player.xhtml (content/player.xhtml) + content/global/pictureinpicture/player.js (content/player.js) diff --git a/toolkit/components/pictureinpicture/moz.build b/toolkit/components/pictureinpicture/moz.build new file mode 100644 index 0000000000..ebdf399e10 --- /dev/null +++ b/toolkit/components/pictureinpicture/moz.build @@ -0,0 +1,21 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Video/Audio Controls") + +SPHINX_TREES["pictureinpicture"] = "docs" + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "PictureInPicture.jsm", + "PictureInPictureControls.jsm", +] + +BROWSER_CHROME_MANIFESTS += [ + "tests/browser.ini", +] diff --git a/toolkit/components/pictureinpicture/tests/.eslintrc.js b/toolkit/components/pictureinpicture/tests/.eslintrc.js new file mode 100644 index 0000000000..1779fd7f1c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/.eslintrc.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = { + extends: ["plugin:mozilla/browser-test"], +}; diff --git a/toolkit/components/pictureinpicture/tests/browser.ini b/toolkit/components/pictureinpicture/tests/browser.ini new file mode 100644 index 0000000000..a11f4c7c5b --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser.ini @@ -0,0 +1,87 @@ +[DEFAULT] +support-files = + click-event-helper.js + head.js + test-button-overlay.html + test-opaque-overlay.html + test-page.html + test-page-with-iframe.html + test-page-with-sound.html + test-pointer-events-none.html + test-transparent-overlay-1.html + test-transparent-overlay-2.html + test-video-selection.html + test-reversed.html + test-media-stream.html + test-video.mp4 + test-video-cropped.mp4 + test-video-vertical.mp4 + test-video-long.mp4 + short.mp4 + ../../../../dom/media/test/gizmo.mp4 + ../../../../dom/media/test/owl.mp3 + +prefs = + media.videocontrols.picture-in-picture.enabled=true + media.videocontrols.picture-in-picture.allow-multiple=false + media.videocontrols.picture-in-picture.video-toggle.enabled=true + media.videocontrols.picture-in-picture.video-toggle.testing=true + media.videocontrols.picture-in-picture.video-toggle.always-show=true + media.videocontrols.picture-in-picture.video-toggle.has-used=true + +[browser_cannotTriggerFromContent.js] +[browser_closePipPause.js] +[browser_contextMenu.js] +skip-if = os == "linux" && bits == 64 && os_version == "18.04" # Bug 1569205 +[browser_cornerSnapping.js] +run-if = os == "mac" +[browser_closePlayer.js] +[browser_closeTab.js] +[browser_dblclickFullscreen.js] +[browser_flipIconWithRTL.js] +[browser_mediaStreamVideos.js] +[browser_durationChange.js] +[browser_fullscreen.js] +skip-if = (os == "mac" && debug) || os == "linux" #Bug 1566173, Bug 1664667 +[browser_keyboardShortcut.js] +[browser_mouseButtonVariation.js] +skip-if = debug +[browser_noToggleOnAudio.js] +[browser_playerControls.js] +[browser_multiPip.js] +[browser_removeVideoElement.js] +[browser_rerequestPiP.js] +[browser_resizeVideo.js] +skip-if = os == 'linux' # Bug 1594223 +[browser_reversePiP.js] +[browser_saveLastPiPLoc.js] +skip-if = + os == "linux" # Bug 1673465 + os == "win" && bits == 64 && debug # Bug 1683002 +[browser_shortcutsAfterFocus.js] +skip-if = os == "win" && bits == 64 && debug # Bug 1683002 +[browser_showMessage.js] +[browser_smallVideoLayout.js] +skip-if = os == "win" && bits == 64 && debug # Bug 1683002 +[browser_stripVideoStyles.js] +[browser_tabIconOverlayPiP.js] +[browser_thirdPartyIframe.js] +[browser_toggleAfterTabTearOutIn.js] +skip-if = (os == 'linux' && bits == 64) || (os == 'mac' && !asan && !debug) # Bug 1605546 +[browser_toggleButtonOverlay.js] +skip-if = true # Bug 1546455 +[browser_toggleMode_2.js] +skip-if = (os == 'linux') # Bug 1654971 +[browser_toggleOnInsertedVideo.js] +[browser_toggleOpaqueOverlay.js] +skip-if = true # Bug 1546455 +[browser_togglePointerEventsNone.js] +skip-if = true # Bug 1664920, Bug 1628777 +[browser_togglePolicies.js] +skip-if = os == "linux" && bits == 64 # Bug 1605565 +[browser_toggleSimple.js] +skip-if = os == 'linux' # Bug 1546455 +[browser_toggleTransparentOverlay-1.js] +[browser_toggleTransparentOverlay-2.js] +skip-if = os == 'linux' && (debug || asan) # Bug 1546930 +[browser_videoSelection.js] diff --git a/toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js b/toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js new file mode 100644 index 0000000000..4c3d32b474 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the MozTogglePictureInPicture event is ignored if + * fired by unprivileged web content. + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + // For now, the easiest way to ensure that this didn't happen is to fail + // if we receive the PictureInPicture:Request message. + const MESSAGE = "PictureInPicture:Request"; + let sawMessage = false; + let listener = msg => { + sawMessage = true; + }; + browser.messageManager.addMessageListener(MESSAGE, listener); + await SpecialPowers.spawn(browser, [], async () => { + content.wrappedJSObject.fireEvents(); + }); + browser.messageManager.removeMessageListener(MESSAGE, listener); + ok(!sawMessage, "Got PictureInPicture:Request message unexpectedly."); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_closePipPause.js b/toolkit/components/pictureinpicture/tests/browser_closePipPause.js new file mode 100644 index 0000000000..b87888e411 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_closePipPause.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test checks that MediaStream videos are not paused when closing + * the PiP window. + */ +add_task(async function test_close_mediaStreamVideos() { + await BrowserTestUtils.withNewTab( + { + url: TEST_ROOT + "test-media-stream.html", + gBrowser, + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + // Construct a new video element, and capture a stream from it + // to redirect to both testing videos + let newVideo = content.document.createElement("video"); + newVideo.src = "test-video.mp4"; + newVideo.id = "media-stream-video"; + content.document.body.appendChild(newVideo); + newVideo.loop = true; + }); + await ensureVideosReady(browser); + + // Modify both the "with-controls" and "no-controls" videos so that they mirror + // the new video that we just added via MediaStream. + await SpecialPowers.spawn(browser, [], async () => { + let newVideo = content.document.getElementById("media-stream-video"); + newVideo.play(); + + for (let videoID of ["with-controls", "no-controls"]) { + let testedVideo = content.document.createElement("video"); + testedVideo.id = videoID; + testedVideo.srcObject = newVideo.mozCaptureStream().clone(); + content.document.body.prepend(testedVideo); + if ( + testedVideo.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA + ) { + info(`Waiting for 'canplaythrough' for '${testedVideo.id}'`); + await ContentTaskUtils.waitForEvent(testedVideo, "canplaythrough"); + } + testedVideo.play(); + } + }); + + for (let videoID of ["with-controls", "no-controls"]) { + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + ok( + !(await isVideoPaused(browser, videoID)), + "The video is not paused in PiP window." + ); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("close"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + await pipClosed; + ok( + !(await isVideoPaused(browser, videoID)), + "The video is not paused after closing PiP window." + ); + } + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_closePlayer.js b/toolkit/components/pictureinpicture/tests/browser_closePlayer.js new file mode 100644 index 0000000000..9b1b0a0047 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_closePlayer.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that closing with unpip leaves the video playing but the close button + * will pause the video. + */ +add_task(async () => { + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + let playVideo = () => { + return SpecialPowers.spawn(browser, [videoID], async videoID => { + return content.document.getElementById(videoID).play(); + }); + }; + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE); + let browser = tab.linkedBrowser; + await playVideo(); + + // Try the unpip button. + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused"); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let unpipButton = pipWin.document.getElementById("unpip"); + EventUtils.synthesizeMouseAtCenter(unpipButton, {}, pipWin); + await pipClosed; + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused"); + + // Try the close button. + pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused"); + + pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("close"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + await pipClosed; + ok(await isVideoPaused(browser, videoID), "The video is paused"); + + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_closeTab.js b/toolkit/components/pictureinpicture/tests/browser_closeTab.js new file mode 100644 index 0000000000..e8c9f59d82 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_closeTab.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if the tab that's hosting a <video> that's opened in a + * Picture-in-Picture window is closed, that the Picture-in-Picture + * window is also closed. + */ +add_task(async () => { + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE); + let browser = tab.linkedBrowser; + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + BrowserTestUtils.removeTab(tab); + await pipClosed; + } +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_contextMenu.js b/toolkit/components/pictureinpicture/tests/browser_contextMenu.js new file mode 100644 index 0000000000..b762049844 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_contextMenu.js @@ -0,0 +1,238 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Opens up the content area context menu on a video loaded in a + * browser. + * + * @param {Element} browser The <xul:browser> hosting the <video> + * + * @param {String} videoID The ID of the video to open the context + * menu with. + * + * @returns Promise + * @resolves With the context menu DOM node once opened. + */ +async function openContextMenu(browser, videoID) { + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#" + videoID, + { type: "contextmenu", button: 2 }, + browser + ); + await popupShownPromise; + return contextMenu; +} + +/** + * Closes the content area context menu. + * + * @param {Element} contextMenu The content area context menu opened with + * openContextMenu. + * + * @returns Promise + * @resolves With undefined + */ +async function closeContextMenu(contextMenu) { + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; +} + +/** + * Tests that Picture-in-Picture can be opened and closed through the + * context menu + */ +add_task(async () => { + for (const videoId of ["with-controls", "no-controls"]) { + info(`Testing ${videoId} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await openContextMenu(browser, videoId); + + info("Context menu is open."); + + const pipMenuItemId = "context-video-pictureinpicture"; + let menuItem = document.getElementById(pipMenuItemId); + + Assert.ok( + !menuItem.hidden, + "Should show Picture-in-Picture menu item." + ); + Assert.equal( + menuItem.getAttribute("checked"), + "false", + "Picture-in-Picture should be unchecked." + ); + + await EventUtils.synthesizeMouseAtCenter(menuItem, {}); + + await SpecialPowers.spawn(browser, [videoId], async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video has started being cloned."); + }); + + info("PiP player is now open."); + + await openContextMenu(browser, videoId); + + info("Context menu is open again."); + + await EventUtils.synthesizeMouseAtCenter(menuItem, {}); + + await SpecialPowers.spawn(browser, [videoId], async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return !video.isCloningElementVisually; + }, "Video has stopped being cloned."); + }); + } + ); + } +}); + +/** + * Tests that the Picture-in-Picture context menu is correctly updated + * based on the Picture-in-Picture state of the video. + */ +add_task(async () => { + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let menuItem = document.getElementById( + "context-video-pictureinpicture" + ); + let menu = await openContextMenu(browser, videoID); + Assert.ok( + !menuItem.hidden, + "Should show Picture-in-Picture menu item." + ); + Assert.equal( + menuItem.getAttribute("checked"), + "false", + "Picture-in-Picture should be unchecked." + ); + await closeContextMenu(menu); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video has started being cloned."); + }); + + menu = await openContextMenu(browser, videoID); + Assert.ok( + !menuItem.hidden, + "Should show Picture-in-Picture menu item." + ); + Assert.equal( + menuItem.getAttribute("checked"), + "true", + "Picture-in-Picture should be checked." + ); + await closeContextMenu(menu); + + let videoNotCloning = SpecialPowers.spawn( + browser, + [videoID], + async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return !video.isCloningElementVisually; + }, "Video has stopped being cloned."); + } + ); + pipWin.close(); + await videoNotCloning; + + menu = await openContextMenu(browser, videoID); + Assert.ok( + !menuItem.hidden, + "Should show Picture-in-Picture menu item." + ); + Assert.equal( + menuItem.getAttribute("checked"), + "false", + "Picture-in-Picture should be unchecked." + ); + await closeContextMenu(menu); + + await SpecialPowers.spawn(browser, [videoID], async videoID => { + // Construct a new video element, and capture a stream from it + // to redirect to the video that we're testing. + let newVideo = content.document.createElement("video"); + content.document.body.appendChild(newVideo); + + let testedVideo = content.document.getElementById(videoID); + newVideo.src = testedVideo.src; + + testedVideo.srcObject = newVideo.mozCaptureStream(); + await newVideo.play(); + await testedVideo.play(); + + await newVideo.pause(); + await testedVideo.pause(); + }); + menu = await openContextMenu(browser, videoID); + Assert.ok( + !menuItem.hidden, + "Should be showing Picture-in-Picture menu item." + ); + Assert.equal( + menuItem.getAttribute("checked"), + "false", + "Picture-in-Picture should be unchecked." + ); + await closeContextMenu(menu); + + pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video has started being cloned."); + }); + + menu = await openContextMenu(browser, videoID); + Assert.ok( + !menuItem.hidden, + "Should show Picture-in-Picture menu item." + ); + Assert.equal( + menuItem.getAttribute("checked"), + "true", + "Picture-in-Picture should be checked." + ); + await closeContextMenu(menu); + } + ); + } +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js b/toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js new file mode 100644 index 0000000000..7ebfafc721 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js @@ -0,0 +1,271 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const FLOAT_OFFSET = 50; +const CHANGE_OFFSET = 30; +const DECREASE_OFFSET = FLOAT_OFFSET - CHANGE_OFFSET; +const INCREASE_OFFSET = FLOAT_OFFSET + CHANGE_OFFSET; +/** + * This function tests the PiP corner snapping feature. + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let pipWin = await triggerPictureInPicture(browser, "no-controls"); + let controls = pipWin.document.getElementById("controls"); + + /** + * pipWin floating in top left corner(quadrant 2), dragged left + * should snap into top left corner + */ + pipWin.moveTo( + pipWin.screen.availLeft + FLOAT_OFFSET, + pipWin.screen.availTop + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin); + pipWin.moveTo( + pipWin.screen.availLeft + DECREASE_OFFSET, + pipWin.screen.availTop + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent( + { + type: "mouseup", + metaKey: true, + }, + controls, + pipWin + ); + Assert.equal( + pipWin.screenX, + pipWin.screen.availLeft, + "Window should be on the left" + ); + Assert.equal( + pipWin.screenY, + pipWin.screen.availTop, + "Window should be on the top" + ); + + /** + * pipWin floating in top left corner(quadrant 2), dragged up + * should snap into top left corner + */ + pipWin.moveTo( + pipWin.screen.availLeft + FLOAT_OFFSET, + pipWin.screen.availTop + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin); + pipWin.moveTo( + pipWin.screen.availLeft + FLOAT_OFFSET, + pipWin.screen.availTop + DECREASE_OFFSET + ); + EventUtils.sendMouseEvent( + { + type: "mouseup", + metaKey: true, + }, + controls, + pipWin + ); + Assert.equal( + pipWin.screenX, + pipWin.screen.availLeft, + "Window should be on the left" + ); + Assert.equal( + pipWin.screenY, + pipWin.screen.availTop, + "Window should be on the top" + ); + + /** + * pipWin floating in top left corner(quadrant 2), dragged right + * should snap into top right corner + */ + pipWin.moveTo( + pipWin.screen.availLeft + FLOAT_OFFSET, + pipWin.screen.availTop + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin); + pipWin.moveTo( + pipWin.screen.availLeft + INCREASE_OFFSET, + pipWin.screen.availTop + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent( + { + type: "mouseup", + metaKey: true, + }, + controls, + pipWin + ); + Assert.equal( + pipWin.screenX, + pipWin.screen.availLeft + pipWin.screen.availWidth - pipWin.innerWidth, + "Window should be on the right" + ); + Assert.equal( + pipWin.screenY, + pipWin.screen.availTop, + "Window should be on the top" + ); + + /** + * pipWin floating in top left corner(quadrant 2), dragged down + * should snap into bottom left corner + */ + pipWin.moveTo( + pipWin.screen.availLeft + FLOAT_OFFSET, + pipWin.screen.availTop + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin); + pipWin.moveTo( + pipWin.screen.availLeft + FLOAT_OFFSET, + pipWin.screen.availTop + INCREASE_OFFSET + ); + EventUtils.sendMouseEvent( + { + type: "mouseup", + metaKey: true, + }, + controls, + pipWin + ); + Assert.equal( + pipWin.screenX, + pipWin.screen.availLeft, + "Window should be on the left" + ); + Assert.equal( + pipWin.screenY, + pipWin.screen.availTop + pipWin.screen.availHeight - pipWin.innerHeight, + "Window should be on the bottom" + ); + + /** + * pipWin floating in top right corner(quadrant 1), dragged down + * should snap into bottom right corner + */ + pipWin.moveTo( + pipWin.screen.availLeft + + pipWin.screen.availWidth - + pipWin.innerWidth - + FLOAT_OFFSET, + pipWin.screen.availTop + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin); + pipWin.moveTo( + pipWin.screen.availLeft + + pipWin.screen.availWidth - + pipWin.innerWidth - + FLOAT_OFFSET, + pipWin.screen.availTop + INCREASE_OFFSET + ); + EventUtils.sendMouseEvent( + { + type: "mouseup", + metaKey: true, + }, + controls, + pipWin + ); + Assert.equal( + pipWin.screenX, + pipWin.screen.availLeft + pipWin.screen.availWidth - pipWin.innerWidth, + "Window should be on the right" + ); + Assert.equal( + pipWin.screenY, + pipWin.screen.availTop + pipWin.screen.availHeight - pipWin.innerHeight, + "Window should be on the bottom" + ); + + /** + * pipWin floating in top left corner(quadrant 4), dragged left + * should snap into bottom left corner + */ + pipWin.moveTo( + pipWin.screen.availLeft + + pipWin.screen.availWidth - + pipWin.innerWidth - + FLOAT_OFFSET, + pipWin.screen.availTop + + pipWin.screen.availHeight - + pipWin.innerHeight - + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin); + pipWin.moveTo( + pipWin.screen.availLeft + + pipWin.screen.availWidth - + pipWin.innerWidth - + INCREASE_OFFSET, + pipWin.screen.availTop + + pipWin.screen.availHeight - + pipWin.innerHeight - + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent( + { + type: "mouseup", + metaKey: true, + }, + controls, + pipWin + ); + Assert.equal( + pipWin.screenX, + pipWin.screen.availLeft, + "Window should be on the left" + ); + Assert.equal( + pipWin.screenY, + pipWin.screen.availTop + pipWin.screen.availHeight - pipWin.innerHeight, + "Window should be on the bottom" + ); + + /** + * pipWin floating in top left corner(quadrant 3), dragged up + * should snap into top left corner + */ + pipWin.moveTo( + pipWin.screen.availLeft + FLOAT_OFFSET, + pipWin.screen.availTop + + pipWin.screen.availHeight - + pipWin.innerHeight - + FLOAT_OFFSET + ); + EventUtils.sendMouseEvent({ type: "mouseup" }, controls, pipWin); + pipWin.moveTo( + pipWin.screen.availLeft + FLOAT_OFFSET, + pipWin.screen.availTop + + pipWin.screen.availHeight - + pipWin.innerHeight - + INCREASE_OFFSET + ); + EventUtils.sendMouseEvent( + { + type: "mouseup", + metaKey: true, + }, + controls, + pipWin + ); + Assert.equal( + pipWin.screenX, + pipWin.screen.availLeft, + "Window should be on the left" + ); + Assert.equal( + pipWin.screenY, + pipWin.screen.availTop, + "Window should be on the top" + ); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js b/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js new file mode 100644 index 0000000000..c915a6f0e7 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that double-clicking on the Picture-in-Picture player window + * causes it to fullscreen, and that pressing Escape allows us to exit + * fullscreen. + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let pipWin = await triggerPictureInPicture(browser, "no-controls"); + let controls = pipWin.document.getElementById("controls"); + + await promiseFullscreenEntered(pipWin, async () => { + await EventUtils.sendMouseEvent( + { + type: "dblclick", + }, + controls, + pipWin + ); + }); + + Assert.equal( + pipWin.document.fullscreenElement, + pipWin.document.body, + "Double-click caused us to enter fullscreen." + ); + + // First, we'll test exiting fullscreen by double-clicking again + // on the document body. + + await promiseFullscreenExited(pipWin, async () => { + await EventUtils.sendMouseEvent( + { + type: "dblclick", + }, + controls, + pipWin + ); + }); + + Assert.ok( + !pipWin.document.fullscreenElement, + "Double-click caused us to exit fullscreen." + ); + + // Now we double-click to re-enter fullscreen. + + await promiseFullscreenEntered(pipWin, async () => { + await EventUtils.sendMouseEvent( + { + type: "dblclick", + }, + controls, + pipWin + ); + }); + + Assert.equal( + pipWin.document.fullscreenElement, + pipWin.document.body, + "Double-click caused us to re-enter fullscreen." + ); + + // Finally, we check that hitting Escape lets the user leave + // fullscreen. + + await promiseFullscreenExited(pipWin, async () => { + EventUtils.synthesizeKey("KEY_Escape", {}, pipWin); + }); + + Assert.ok( + !pipWin.document.fullscreenElement, + "Pressing Escape caused us to exit fullscreen." + ); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + pipWin.close(); + await pipClosed; + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_durationChange.js b/toolkit/components/pictureinpicture/tests/browser_durationChange.js new file mode 100644 index 0000000000..69397d8959 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_durationChange.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the visibility of the toggle will be + * recomputed after durationchange events fire. + */ +add_task(async function test_durationChange() { + // Most of the Picture-in-Picture tests run with the always-show + // preference set to true to avoid the toggle visibility heuristics. + // Since this test actually exercises those heuristics, we have + // to temporarily disable that pref. + // + // We also reduce the minimum video length for displaying the toggle + // to 5 seconds to avoid having to include or generate a 45 second long + // video (which is the default minimum length). + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.video-toggle.always-show", + false, + ], + ["media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 5], + ], + }); + + // First, ensure that the toggle doesn't show up for these + // short videos by default. + await testToggle(TEST_PAGE, { + "with-controls": { canToggle: false }, + "no-controls": { canToggle: false }, + }); + + // Now cause the video to change sources, which should fire a + // durationchange event. The longer video should qualify us for + // displaying the toggle. + await testToggle( + TEST_PAGE, + { + "with-controls": { canToggle: true }, + "no-controls": { canToggle: true }, + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + for (let videoID of ["with-controls", "no-controls"]) { + let video = content.document.getElementById(videoID); + video.src = "gizmo.mp4"; + let durationChangePromise = ContentTaskUtils.waitForEvent( + video, + "durationchange" + ); + + video.load(); + await durationChangePromise; + } + }); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js b/toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js new file mode 100644 index 0000000000..cfb91440f5 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * The goal of this test is to check that the icon on the PiP button mirrors + * and the explainer text that shows up before the first time PiP is used + * right aligns when the browser is set to a RtL mode + * + * The browser will create a tab and open a video using PiP + * then the tests check that the components change their appearance accordingly + * + */ + +/** + * This test ensures that the default ltr is working as intended + */ +add_task(async function test_ltr_toggle() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await ensureVideosReady(browser); + for (let videoId of ["with-controls", "no-controls"]) { + let localeDir = await SpecialPowers.spawn(browser, [videoId], id => { + let video = content.document.getElementById(id); + let videocontrols = video.openOrClosedShadowRoot.firstChild; + return videocontrols.getAttribute("localedir"); + }); + + Assert.equal(localeDir, "ltr", "Got the right localedir"); + } + } + ); +}); + +/** + * This test ensures that the components flip correctly when rtl is set + */ +add_task(async function test_rtl_toggle() { + await SpecialPowers.pushPrefEnv({ + set: [["intl.l10n.pseudo", "bidi"]], + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await ensureVideosReady(browser); + for (let videoId of ["with-controls", "no-controls"]) { + let localeDir = await SpecialPowers.spawn(browser, [videoId], id => { + let video = content.document.getElementById(id); + let videocontrols = video.openOrClosedShadowRoot.firstChild; + return videocontrols.getAttribute("localedir"); + }); + + Assert.equal(localeDir, "rtl", "Got the right localedir"); + } + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_fullscreen.js b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js new file mode 100644 index 0000000000..5f80c56307 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const VIDEOS = ["with-controls", "no-controls"]; + +/** + * Tests that the Picture-in-Picture toggle is hidden when + * a video with or without controls is made fullscreen. + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + for (let videoID of VIDEOS) { + await promiseFullscreenEntered(window, async () => { + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = this.content.document.getElementById(videoID); + video.requestFullscreen(); + }); + }); + + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mouseover", + }, + browser + ); + + let args = { videoID, toggleID: DEFAULT_TOGGLE_STYLES.rootID }; + + await promiseFullscreenExited(window, async () => { + await SpecialPowers.spawn(browser, [args], async args => { + let { videoID, toggleID } = args; + let video = this.content.document.getElementById(videoID); + let toggle = video.openOrClosedShadowRoot.getElementById(toggleID); + ok( + ContentTaskUtils.is_hidden(toggle), + "Toggle should be hidden in fullscreen mode." + ); + this.content.document.exitFullscreen(); + }); + }); + } + } + ); +}); + +/** + * Tests that the Picture-in-Picture toggle is hidden if an + * ancestor of a video (in this case, the document body) is made + * to be the fullscreen element. + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await promiseFullscreenEntered(window, async () => { + await SpecialPowers.spawn(browser, [], async () => { + this.content.document.body.requestFullscreen(); + }); + }); + + for (let videoID of VIDEOS) { + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mouseover", + }, + browser + ); + + let args = { videoID, toggleID: DEFAULT_TOGGLE_STYLES.rootID }; + + await SpecialPowers.spawn(browser, [args], async args => { + let { videoID, toggleID } = args; + let video = this.content.document.getElementById(videoID); + let toggle = video.openOrClosedShadowRoot.getElementById(toggleID); + ok( + ContentTaskUtils.is_hidden(toggle), + "Toggle should be hidden in fullscreen mode." + ); + }); + } + + await promiseFullscreenExited(window, async () => { + await SpecialPowers.spawn(browser, [], async () => { + this.content.document.exitFullscreen(); + }); + }); + } + ); +}); + +/** + * Tests that the Picture-In-Picture window is closed when something + * is fullscreened + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await ensureVideosReady(browser); + + for (let videoId of VIDEOS) { + let pipWin = await triggerPictureInPicture(browser, videoId); + ok(pipWin, "Got Picture-In-Picture window."); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + + // need to focus first, since fullscreen request will be blocked otherwise + await SimpleTest.promiseFocus(window); + + await promiseFullscreenEntered(window, async () => { + await SpecialPowers.spawn(browser, [], async () => { + this.content.document.body.requestFullscreen(); + }); + }); + + await pipClosed; + ok(pipWin.closed, "Picture-In-Picture successfully closed."); + + await promiseFullscreenExited(window, async () => { + await SpecialPowers.spawn(browser, [], async () => { + this.content.document.exitFullscreen(); + }); + }); + } + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js new file mode 100644 index 0000000000..b2f83205fa --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if the user keys in the keyboard shortcut for + * Picture-in-Picture, then the first video on the currently + * focused page will be opened in the player window. + */ +add_task(async function test_pip_keyboard_shortcut() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + // In test-page.html, the "with-controls" video is the first one that + // appears in the DOM, so this is what we expect to open via the keyboard + // shortcut. + const VIDEO_ID = "with-controls"; + + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + let videoReady = SpecialPowers.spawn( + browser, + [VIDEO_ID], + async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video is being cloned visually."); + } + ); + + if (AppConstants.platform == "macosx") { + EventUtils.synthesizeKey("]", { + accelKey: true, + shiftKey: true, + altKey: true, + }); + } else { + EventUtils.synthesizeKey("]", { accelKey: true, shiftKey: true }); + } + + let pipWin = await domWindowOpened; + await videoReady; + + ok(pipWin, "Got Picture-in-Picture window."); + + await ensureMessageAndClosePiP(browser, VIDEO_ID, pipWin, false); + + // Reopen PiP Window + pipWin = await triggerPictureInPicture(browser, VIDEO_ID); + await videoReady; + + ok(pipWin, "Got Picture-in-Picture window."); + + if (AppConstants.platform == "macosx") { + EventUtils.synthesizeKey( + "]", + { + accelKey: true, + shiftKey: true, + altKey: true, + }, + pipWin + ); + } else { + EventUtils.synthesizeKey( + "]", + { accelKey: true, shiftKey: true }, + pipWin + ); + } + + await BrowserTestUtils.windowClosed(pipWin); + + ok(pipWin.closed, "Picture-in-Picture window closed."); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js b/toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js new file mode 100644 index 0000000000..46b36a3a6e --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test checks that the media stream video format has functional + * support for PiP + */ +add_task(async function test_mediaStreamVideos() { + await testToggle( + TEST_ROOT + "test-media-stream.html", + { + "with-controls": { canToggle: true }, + "no-controls": { canToggle: true }, + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + // Construct a new video element, and capture a stream from it + // to redirect to both testing videos. Create the captureStreams after + // we have metadata so tracks are immediately available, but wait with + // playback until the setup is done. + + function logEvent(element, ev) { + element.addEventListener(ev, () => + info( + `${element.id} got event ${ev}. currentTime=${element.currentTime}` + ) + ); + } + + const newVideo = content.document.createElement("video"); + newVideo.id = "new-video"; + newVideo.src = "test-video.mp4"; + newVideo.preload = "auto"; + logEvent(newVideo, "timeupdate"); + logEvent(newVideo, "ended"); + content.document.body.appendChild(newVideo); + await ContentTaskUtils.waitForEvent(newVideo, "loadedmetadata"); + + const mediastreamPlayingPromises = []; + for (let videoID of ["with-controls", "no-controls"]) { + const testedVideo = content.document.createElement("video"); + testedVideo.id = videoID; + testedVideo.srcObject = newVideo.mozCaptureStream(); + testedVideo.play(); + mediastreamPlayingPromises.push( + new Promise(r => (testedVideo.onplaying = r)) + ); + content.document.body.prepend(testedVideo); + } + + await newVideo.play(); + await Promise.all(mediastreamPlayingPromises); + newVideo.pause(); + }); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js b/toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js new file mode 100644 index 0000000000..f424093215 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if the user mousedown's on a Picture-in-Picture toggle, + * but then mouseup's on something completely different, that we still + * open a Picture-in-Picture window, and that the mouse button events are + * all suppressed. Also ensures that a subsequent click on the document + * body results in all mouse button events firing normally. + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + let videoID = "no-controls"; + + await prepareForToggleClick(browser, videoID); + + // Hover the mouse over the video to reveal the toggle, which is necessary + // if we want to click on the toggle. + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mousemove", + }, + browser + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mouseover", + }, + browser + ); + + info("Waiting for toggle to become visible"); + await toggleOpacityReachesThreshold(browser, videoID, "hoverVideo"); + + let toggleClientRect = await getToggleClientRect(browser, videoID); + + // The toggle center, because of how it slides out, is actually outside + // of the bounds of a click event. For now, we move the mouse in by a + // hard-coded 15 pixels along the x and y axis to achieve the hover. + let toggleLeft = toggleClientRect.left + 15; + let toggleTop = toggleClientRect.top + 15; + + info( + "Clicking on toggle, and expecting a Picture-in-Picture window to open" + ); + // We need to wait for the window to have completed loading before we + // can close it as the document's type required by closeWindow may not + // be available. + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + + await BrowserTestUtils.synthesizeMouseAtPoint( + toggleLeft, + toggleTop, + { + type: "mousedown", + }, + browser + ); + + await BrowserTestUtils.synthesizeMouseAtPoint( + 1, + 1, + { + type: "mouseup", + }, + browser + ); + + let win = await domWindowOpened; + ok(win, "A Picture-in-Picture window opened."); + + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video is being cloned visually."); + }); + + await BrowserTestUtils.closeWindow(win); + await assertSawMouseEvents(browser, false); + + await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser); + await assertSawMouseEvents(browser, true); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_multiPip.js b/toolkit/components/pictureinpicture/tests/browser_multiPip.js new file mode 100644 index 0000000000..f4f48fc7a6 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_multiPip.js @@ -0,0 +1,232 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function createTab() { + return BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: TEST_PAGE, + waitForLoad: true, + }); +} + +function getTelemetryMaxPipCount(resetMax = false) { + const scalarData = Services.telemetry.getSnapshotForScalars("main", resetMax) + .parent; + return scalarData["pictureinpicture.most_concurrent_players"]; +} + +/** + * Set pref for multiple PiP support first + */ +add_task(async () => { + return SpecialPowers.pushPrefEnv({ + set: [["media.videocontrols.picture-in-picture.allow-multiple", true]], + }); +}); + +/** + * Tests that multiple PiPs can be opened and closed in a single tab + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let firstPip = await triggerPictureInPicture(browser, "with-controls"); + ok(firstPip, "Got first PiP window"); + + let secondPip = await triggerPictureInPicture(browser, "no-controls"); + ok(secondPip, "Got second PiP window"); + + await ensureMessageAndClosePiP(browser, "with-controls", firstPip, false); + info("First PiP was still open and is now closed"); + + await ensureMessageAndClosePiP(browser, "no-controls", secondPip, false); + info("Second PiP was still open and is now closed"); + } + ); +}); + +/** + * Tests that multiple PiPs can be opened and closed across different tabs + */ +add_task(async () => { + let firstTab = await createTab(); + let secondTab = await createTab(); + + gBrowser.selectedTab = firstTab; + + let firstPip = await triggerPictureInPicture( + firstTab.linkedBrowser, + "with-controls" + ); + ok(firstPip, "Got first PiP window"); + + gBrowser.selectedTab = secondTab; + + let secondPip = await triggerPictureInPicture( + secondTab.linkedBrowser, + "with-controls" + ); + ok(secondPip, "Got second PiP window"); + + await ensureMessageAndClosePiP( + firstTab.linkedBrowser, + "with-controls", + firstPip, + false + ); + info("First Picture-in-Picture window was open and is now closed."); + + await ensureMessageAndClosePiP( + secondTab.linkedBrowser, + "with-controls", + secondPip, + false + ); + info("Second Picture-in-Picture window was open and is now closed."); + + BrowserTestUtils.removeTab(firstTab); + BrowserTestUtils.removeTab(secondTab); +}); + +/** + * Tests that when a tab is closed; that only PiPs originating from this tab + * are closed as well + */ +add_task(async () => { + let firstTab = await createTab(); + let secondTab = await createTab(); + + let firstPip = await triggerPictureInPicture( + firstTab.linkedBrowser, + "with-controls" + ); + ok(firstPip, "Got first PiP window"); + + let secondPip = await triggerPictureInPicture( + secondTab.linkedBrowser, + "with-controls" + ); + ok(secondPip, "Got second PiP window"); + + let firstClosed = BrowserTestUtils.domWindowClosed(firstPip); + BrowserTestUtils.removeTab(firstTab); + await firstClosed; + info("First PiP closed after closing the first tab"); + + await assertVideoIsBeingCloned(secondTab.linkedBrowser, "with-controls"); + info("Second PiP is still open after first tab close"); + + let secondClosed = BrowserTestUtils.domWindowClosed(secondPip); + BrowserTestUtils.removeTab(secondTab); + await secondClosed; + info("Second PiP closed after closing the second tab"); +}); + +/** + * Check that correct number of pip players are recorded for Telemetry + * tracking + */ +add_task(async () => { + // run this to flush recorded values from previous tests + getTelemetryMaxPipCount(true); + + let firstTab = await createTab(); + let secondTab = await createTab(); + + gBrowser.selectedTab = firstTab; + + let firstPip = await triggerPictureInPicture( + firstTab.linkedBrowser, + "with-controls" + ); + ok(firstPip, "Got first PiP window"); + + Assert.equal( + getTelemetryMaxPipCount(true), + 1, + "There should only be 1 PiP window" + ); + + let secondPip = await triggerPictureInPicture( + firstTab.linkedBrowser, + "no-controls" + ); + ok(secondPip, "Got second PiP window"); + + Assert.equal( + getTelemetryMaxPipCount(true), + 2, + "There should be 2 PiP windows" + ); + + await ensureMessageAndClosePiP( + firstTab.linkedBrowser, + "no-controls", + secondPip, + false + ); + info("Second PiP was open and is now closed"); + + gBrowser.selectedTab = secondTab; + + let thirdPip = await triggerPictureInPicture( + secondTab.linkedBrowser, + "with-controls" + ); + ok(thirdPip, "Got third PiP window"); + + let fourthPip = await triggerPictureInPicture( + secondTab.linkedBrowser, + "no-controls" + ); + ok(fourthPip, "Got fourth PiP window"); + + Assert.equal( + getTelemetryMaxPipCount(false), + 3, + "There should now be 3 PiP windows" + ); + + gBrowser.selectedTab = firstTab; + + await ensureMessageAndClosePiP( + firstTab.linkedBrowser, + "with-controls", + firstPip, + false + ); + info("First PiP was open, it is now closed."); + + gBrowser.selectedTab = secondTab; + + await ensureMessageAndClosePiP( + secondTab.linkedBrowser, + "with-controls", + thirdPip, + false + ); + info("Third PiP was open, it is now closed."); + + await ensureMessageAndClosePiP( + secondTab.linkedBrowser, + "no-controls", + fourthPip, + false + ); + info("Fourth PiP was open, it is now closed."); + + Assert.equal( + getTelemetryMaxPipCount(false), + 3, + "Max PiP count should still be 3" + ); + + BrowserTestUtils.removeTab(firstTab); + BrowserTestUtils.removeTab(secondTab); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js new file mode 100644 index 0000000000..0d16bdc9e2 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if a <video> element only has audio, and no video + * frames, that we do not show the toggle. + */ +add_task(async function test_no_toggle_on_audio() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ROOT + "owl.mp3", + }, + async browser => { + await ensureVideosReady(browser); + await SimpleTest.promiseFocus(browser); + + // The media player document we create for owl.mp3 inserts a <video> + // element pointed at the .mp3 file, which is what we're trying to + // test for. The <video> element does not get an ID created for it + // though, so we sneak one in with SpecialPowers.spawn so that we + // can use testToggleHelper (which requires an ID). + // + // testToggleHelper also wants click-event-helper.js loaded in the + // document, so we insert that too. + const VIDEO_ID = "video-element"; + const SCRIPT_SRC = "click-event-helper.js"; + await SpecialPowers.spawn(browser, [VIDEO_ID, SCRIPT_SRC], async function( + videoID, + scriptSrc + ) { + let video = content.document.querySelector("video"); + video.id = videoID; + + let script = content.document.createElement("script"); + script.src = scriptSrc; + content.document.head.appendChild(script); + }); + + await testToggleHelper(browser, VIDEO_ID, false); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_playerControls.js b/toolkit/components/pictureinpicture/tests/browser_playerControls.js new file mode 100644 index 0000000000..a2684dd451 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_playerControls.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests functionality of the various controls for the Picture-in-Picture + * video window. + */ +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.videocontrols.picture-in-picture.audio-toggle.enabled", true], + ], + }); + let videoID = "with-controls"; + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let isVideoMuted = () => { + return SpecialPowers.spawn(browser, [videoID], async videoID => { + return content.document.getElementById(videoID).muted; + }); + }; + let waitForVideoEvent = eventType => { + return BrowserTestUtils.waitForContentEvent(browser, eventType, true); + }; + + await ensureVideosReady(browser); + await SpecialPowers.spawn(browser, [videoID], async videoID => { + await content.document.getElementById(videoID).play(); + }); + + // Open the video in PiP + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused."); + + let playPause = pipWin.document.getElementById("playpause"); + let audioButton = pipWin.document.getElementById("audio"); + + // Try the pause button + let pausedPromise = waitForVideoEvent("pause"); + EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin); + await pausedPromise; + ok(await isVideoPaused(browser, videoID), "The video is paused."); + + // Try the play button + let playPromise = waitForVideoEvent("play"); + EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin); + await playPromise; + ok(!(await isVideoPaused(browser, videoID)), "The video is playing."); + + // Try the mute button + let mutedPromise = waitForVideoEvent("volumechange"); + ok(!(await isVideoMuted()), "The audio is playing."); + EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin); + await mutedPromise; + ok(await isVideoMuted(), "The audio is muted."); + + // Try the unmute button + let unmutedPromise = waitForVideoEvent("volumechange"); + EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin); + await unmutedPromise; + ok(!(await isVideoMuted()), "The audio is playing."); + + // Try the unpip button. + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let unpipButton = pipWin.document.getElementById("unpip"); + EventUtils.synthesizeMouseAtCenter(unpipButton, {}, pipWin); + await pipClosed; + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused."); + + // Try the close button. + pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused."); + + pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("close"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + await pipClosed; + ok(await isVideoPaused(browser, videoID), "The video is paused."); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js b/toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js new file mode 100644 index 0000000000..d9622dc3f0 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js @@ -0,0 +1,84 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if a <video> element is being displayed in a + * Picture-in-Picture window, that the window closes if that + * original <video> is ever removed from the DOM. + */ +add_task(async () => { + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let pipWin = await triggerPictureInPicture(browser, videoID); + Assert.ok(pipWin, "Got PiP window."); + + // First, let's make sure that removing the _other_ video doesn't cause + // the special event to fire, nor the PiP window to close. + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let doc = content.document; + let otherVideo = doc.querySelector(`video:not([id="${videoID}"])`); + let eventFired = false; + + let listener = e => { + eventFired = true; + }; + + docShell.chromeEventHandler.addEventListener( + "MozStopPictureInPicture", + listener, + { + capture: true, + } + ); + otherVideo.remove(); + Assert.ok( + !eventFired, + "Should not have seen MozStopPictureInPicture for other video" + ); + docShell.chromeEventHandler.removeEventListener( + "MozStopPictureInPicture", + listener, + { + capture: true, + } + ); + }); + + Assert.ok(!pipWin.closed, "PiP window should still be open."); + + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let doc = content.document; + let video = doc.querySelector(`#${videoID}`); + + let promise = ContentTaskUtils.waitForEvent( + docShell.chromeEventHandler, + "MozStopPictureInPicture", + { capture: true } + ); + video.remove(); + await promise; + }); + + try { + await BrowserTestUtils.waitForCondition( + () => pipWin.closed, + "Player window closed." + ); + } finally { + if (!pipWin.closed) { + await BrowserTestUtils.closeWindow(pipWin); + } + } + } + ); + } +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js b/toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js new file mode 100644 index 0000000000..27d2861bca --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_rerequestPiP.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if a pre-existing Picture-in-Picture window exists, and a + * different video is requested to open in Picture-in-Picture, that the + * original Picture-in-Picture window closes and a new one is opened. + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let pipWin = await triggerPictureInPicture(browser, "with-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let pipWin2 = await triggerPictureInPicture(browser, "no-controls"); + await pipClosed; + ok(true, "Original Picture-in-Picture window closed."); + + pipClosed = BrowserTestUtils.domWindowClosed(pipWin2); + pipWin2.close(); + await pipClosed; + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js new file mode 100644 index 0000000000..917bceb9ac --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js @@ -0,0 +1,263 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Run the resize test on a player window. + * + * @param browser (xul:browser) + * The browser that has the source video. + * + * @param videoID (string) + * The id of the video in the browser to test. + * + * @param pipWin (player window) + * A player window to run the tests on. + * + * @param opts (object) + * The options for the test. + * + * pinX (boolean): + * If true, the video's X position shouldn't change when resized. + * + * pinY (boolean): + * If true, the video's Y position shouldn't change when resized. + */ +async function testVideo(browser, videoID, pipWin, { pinX, pinY } = {}) { + async function switchVideoSource(src) { + let videoResized = BrowserTestUtils.waitForEvent(pipWin, "resize"); + await ContentTask.spawn( + browser, + { src, videoID }, + async ({ src, videoID }) => { + let doc = content.document; + let video = doc.getElementById(videoID); + video.src = src; + } + ); + await videoResized; + } + + /** + * Check the new screen position against the previous one. When + * pinX or pinY is true then the top left corner is checked in that + * dimension. Otherwise, the bottom right corner is checked. + * + * The video position is determined by the screen edge it's closest + * to, so in the default bottom right its bottom right corner should + * match the previous video's bottom right corner. For the top left, + * the top left corners should match. + */ + function checkPosition( + previousScreenX, + previousScreenY, + previousWidth, + previousHeight, + newScreenX, + newScreenY, + newWidth, + newHeight + ) { + if (pinX || previousScreenX == 0) { + Assert.equal( + previousScreenX, + newScreenX, + "New video is still in the same X position" + ); + } else { + Assert.less( + Math.abs(previousScreenX + previousWidth - (newScreenX + newWidth)), + 2, + "New video ends at the same screen X position (within 1 pixel)" + ); + } + if (pinY) { + Assert.equal( + previousScreenY, + newScreenY, + "New video is still in the same Y position" + ); + } else { + Assert.equal( + previousScreenY + previousHeight, + newScreenY + newHeight, + "New video ends at the same screen Y position" + ); + } + } + + Assert.ok(pipWin, "Got PiP window."); + + let initialWidth = pipWin.innerWidth; + let initialHeight = pipWin.innerHeight; + let initialAspectRatio = initialWidth / initialHeight; + Assert.equal( + Math.floor(initialAspectRatio * 100), + 177, // 16 / 9 = 1.777777777 + "Original aspect ratio is 16:9" + ); + + // Store the window position for later. + let initialScreenX = pipWin.mozInnerScreenX; + let initialScreenY = pipWin.mozInnerScreenY; + + await switchVideoSource("test-video-cropped.mp4"); + + let resizedWidth = pipWin.innerWidth; + let resizedHeight = pipWin.innerHeight; + let resizedAspectRatio = resizedWidth / resizedHeight; + Assert.equal( + Math.floor(resizedAspectRatio * 100), + 133, // 4 / 3 = 1.333333333 + "Resized aspect ratio is 4:3" + ); + Assert.less(resizedWidth, initialWidth, "Resized video has smaller width"); + Assert.equal( + resizedHeight, + initialHeight, + "Resized video is the same vertically" + ); + + let resizedScreenX = pipWin.mozInnerScreenX; + let resizedScreenY = pipWin.mozInnerScreenY; + checkPosition( + initialScreenX, + initialScreenY, + initialWidth, + initialHeight, + resizedScreenX, + resizedScreenY, + resizedWidth, + resizedHeight + ); + + await switchVideoSource("test-video-vertical.mp4"); + + let verticalWidth = pipWin.innerWidth; + let verticalHeight = pipWin.innerHeight; + let verticalAspectRatio = verticalWidth / verticalHeight; + + if (verticalWidth == 136) { + // The video is minimun width allowed + Assert.equal( + Math.floor(verticalAspectRatio * 100), + 56, // 1 / 2 = 0.5 + "Vertical aspect ratio is 1:2" + ); + } else { + Assert.equal( + Math.floor(verticalAspectRatio * 100), + 50, // 1 / 2 = 0.5 + "Vertical aspect ratio is 1:2" + ); + } + + Assert.less(verticalWidth, resizedWidth, "Vertical video width shrunk"); + Assert.equal( + verticalHeight, + initialHeight, + "Vertical video height matches previous height" + ); + + let verticalScreenX = pipWin.mozInnerScreenX; + let verticalScreenY = pipWin.mozInnerScreenY; + checkPosition( + resizedScreenX, + resizedScreenY, + resizedWidth, + resizedHeight, + verticalScreenX, + verticalScreenY, + verticalWidth, + verticalHeight + ); + + await switchVideoSource("test-video.mp4"); + + let restoredWidth = pipWin.innerWidth; + let restoredHeight = pipWin.innerHeight; + let restoredAspectRatio = restoredWidth / restoredHeight; + Assert.equal( + Math.floor(restoredAspectRatio * 100), + 177, + "Restored aspect ratio is still 16:9" + ); + Assert.less( + Math.abs(initialWidth - pipWin.innerWidth), + 2, + "Restored video has its original width" + ); + Assert.equal( + initialHeight, + pipWin.innerHeight, + "Restored video has its original height" + ); + + let restoredScreenX = pipWin.mozInnerScreenX; + let restoredScreenY = pipWin.mozInnerScreenY; + checkPosition( + initialScreenX, + initialScreenY, + initialWidth, + initialHeight, + restoredScreenX, + restoredScreenY, + restoredWidth, + restoredHeight + ); +} + +/** + * Tests that if a <video> element is resized the Picture-in-Picture window + * will be resized to match the new dimensions. + */ +add_task(async () => { + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let pipWin = await triggerPictureInPicture(browser, videoID); + + await testVideo(browser, videoID, pipWin); + + pipWin.moveTo(0, 0); + + await testVideo(browser, videoID, pipWin, { pinX: true, pinY: true }); + + await BrowserTestUtils.closeWindow(pipWin); + } + ); + } +}); + +/** + * Tests that the RTL video starts on the left and is pinned in the X dimension. + */ +add_task(async () => { + await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] }); + + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let pipWin = await triggerPictureInPicture(browser, videoID); + + await testVideo(browser, videoID, pipWin, { pinX: true }); + + await BrowserTestUtils.closeWindow(pipWin); + } + ); + } + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_reversePiP.js b/toolkit/components/pictureinpicture/tests/browser_reversePiP.js new file mode 100644 index 0000000000..a8ae20166f --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_reversePiP.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the PiP toggle button is not flipped + * on certain websites (such as whereby.com). + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ROOT + "test-reversed.html", + }, + async browser => { + await ensureVideosReady(browser); + + let videoID = "reversed"; + + // Test the toggle button + await prepareForToggleClick(browser, videoID); + + // Hover the mouse over the video to reveal the toggle. + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mousemove", + }, + browser + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mouseover", + }, + browser + ); + + let toggleFlippedAttribute = await SpecialPowers.spawn( + browser, + [videoID], + async videoID => { + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + let controlsOverlay = shadowRoot.querySelector(".controlsOverlay"); + + await ContentTaskUtils.waitForCondition(() => { + return controlsOverlay.classList.contains("hovering"); + }, "Waiting for the hovering state to be set on the video."); + + return shadowRoot.firstChild.getAttribute("flipped"); + } + ); + + // The "flipped" attribute should be set on the toggle button (when applicable). + Assert.equal(toggleFlippedAttribute, "true"); + } + ); +}); + +/** + * Tests that the "This video is playing in Picture-in-Picture" message + * as well as the video playing in PiP are both not flipped on certain sites + * (such as whereby.com) + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_ROOT + "test-reversed.html", + }, + async browser => { + /** + * A helper function used to get the "flipped" attribute of the video's shadowRoot's first child. + * @param {Element} browser The <xul:browser> hosting the <video> + * @param {String} videoID The ID of the video being checked + */ + async function getFlippedAttribute(browser, videoID) { + let videoFlippedAttribute = await SpecialPowers.spawn( + browser, + [videoID], + async videoID => { + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + return shadowRoot.firstChild.getAttribute("flipped"); + } + ); + return videoFlippedAttribute; + } + + /** + * A helper function that returns the transform.a of the video being played in PiP. + * @param {Element} playerBrowser The <xul:browser> of the PiP window + */ + async function getPiPVideoTransform(playerBrowser) { + let pipVideoTransform = await SpecialPowers.spawn( + playerBrowser, + [], + async () => { + let video = content.document.querySelector("video"); + return video.getTransformToViewport().a; + } + ); + return pipVideoTransform; + } + + await ensureVideosReady(browser); + + let videoID = "reversed"; + + let videoFlippedAttribute = await getFlippedAttribute(browser, videoID); + Assert.equal(videoFlippedAttribute, null); // The "flipped" attribute should not be set initially. + + let pipWin = await triggerPictureInPicture(browser, videoID); + + videoFlippedAttribute = await getFlippedAttribute(browser, "reversed"); + Assert.equal(videoFlippedAttribute, "true"); // The "flipped" value should be set once the PiP window is opened (when applicable). + + let playerBrowser = pipWin.document.getElementById("browser"); + let pipVideoTransform = await getPiPVideoTransform(playerBrowser); + Assert.equal(pipVideoTransform, -1); + + await ensureMessageAndClosePiP(browser, videoID, pipWin, false); + + videoFlippedAttribute = await getFlippedAttribute(browser, "reversed"); + Assert.equal(videoFlippedAttribute, null); // The "flipped" attribute should be removed after closing PiP. + + // Now we want to test that regular (not-reversed) videos are unaffected + videoID = "not-reversed"; + videoFlippedAttribute = await getFlippedAttribute(browser, videoID); + Assert.equal(videoFlippedAttribute, null); + + pipWin = await triggerPictureInPicture(browser, videoID); + + videoFlippedAttribute = await getFlippedAttribute(browser, videoID); + Assert.equal(videoFlippedAttribute, null); + + playerBrowser = pipWin.document.getElementById("browser"); + pipVideoTransform = await getPiPVideoTransform(playerBrowser); + + await ensureMessageAndClosePiP(browser, videoID, pipWin, false); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js b/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js new file mode 100644 index 0000000000..fbdf7fb456 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js @@ -0,0 +1,362 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This function tests that the browser saves the last location of size of + * the PiP window and will open the next PiP window in the same location + * with the size. It adjusts for aspect ratio by keeping the same height and + * adjusting the width of the PiP window. + */ + +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + // Function to switch video source. + async browser => { + async function switchVideoSource(src) { + let videoResized = BrowserTestUtils.waitForEvent(pipWin, "resize"); + await ContentTask.spawn(browser, { src }, async ({ src }) => { + let doc = content.document; + let video = doc.getElementById("with-controls"); + video.src = src; + }); + await videoResized; + } + // This function is used because the rounding of the width can be off + // by about 1 pixel sometimes so this checks that val1 and val2 are + // within 1 pixel + function checkIfEqual(val1, val2, str) { + let equal = Math.abs(val1 - val2); + if (equal <= 1) { + is(equal <= 1, true, str); + } else { + is(val1, val2, str); + } + } + + // Used for clearing the size and location of the PiP window + const PLAYER_URI = + "chrome://global/content/pictureinpicture/player.xhtml"; + + // The PiP window now stores information between tests and needs to be + // cleared before the test begins + function clearSaved() { + let xulStore = Services.xulStore; + xulStore.setValue(PLAYER_URI, "picture-in-picture", "left", NaN); + xulStore.setValue(PLAYER_URI, "picture-in-picture", "top", NaN); + xulStore.setValue(PLAYER_URI, "picture-in-picture", "width", NaN); + xulStore.setValue(PLAYER_URI, "picture-in-picture", "height", NaN); + } + + function 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, + ]; + } + + let screen = Cc["@mozilla.org/gfx/screenmanager;1"] + .getService(Ci.nsIScreenManager) + .screenForRect(1, 1, 1, 1); + + let [ + defaultX, + defaultY, + defaultWidth, + defaultHeight, + ] = getAvailScreenSize(screen); + + // Default size of PiP window + let rightEdge = defaultX + defaultWidth; + let bottomEdge = defaultY + defaultHeight; + + // tab height + // Used only for Linux as the PiP window has a tab + let tabHeight = 35; + + // clear already saved information + clearSaved(); + + // Open PiP + let pipWin = await triggerPictureInPicture(browser, "with-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + let defaultPiPWidth = pipWin.innerWidth; + let defaultPiPHeight = pipWin.innerHeight; + + // Check that it is opened at default location + checkIfEqual( + pipWin.screenX, + rightEdge - defaultPiPWidth, + "Default PiP X location" + ); + if (AppConstants.platform == "linux") { + checkIfEqual( + pipWin.screenY, + bottomEdge - defaultPiPHeight - tabHeight, + "Default PiP Y location" + ); + } else { + checkIfEqual( + pipWin.screenY, + bottomEdge - defaultPiPHeight, + "Default PiP Y location" + ); + } + checkIfEqual(pipWin.innerHeight, defaultPiPHeight, "Default PiP height"); + checkIfEqual(pipWin.innerWidth, defaultPiPWidth, "Default PiP width"); + + let top = defaultY; + let left = defaultX; + pipWin.moveTo(left, top); + let height = pipWin.innerHeight / 2; + let width = pipWin.innerWidth / 2; + pipWin.resizeTo(width, height); + + // CLose first PiP window and open another + await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true); + pipWin = await triggerPictureInPicture(browser, "with-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + // PiP is opened at 0, 0 with size 1/4 default width and 1/4 default height + checkIfEqual(pipWin.screenX, left, "Opened at last X location"); + checkIfEqual(pipWin.screenY, top, "Opened at last Y location"); + checkIfEqual( + pipWin.innerHeight, + height, + "Opened with 1/2 default height" + ); + checkIfEqual(pipWin.innerWidth, width, "Opened with 1/2 default width"); + + // Mac and Linux did not allow moving to coordinates offscreen so this + // test is skipped on those platforms + if (AppConstants.platform == "win") { + // Move to -1111, -1111 and adjust size to 1/4 width and 1/4 height + left = -11111; + top = -11111; + pipWin.moveTo(left, top); + pipWin.resizeTo(pipWin.innerWidth / 4, pipWin.innerHeight / 4); + + await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true); + pipWin = await triggerPictureInPicture(browser, "with-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + // because the coordinates are off screen, the default size and location will be used + checkIfEqual( + pipWin.screenX, + rightEdge - defaultPiPWidth, + "Opened at default X location" + ); + checkIfEqual( + pipWin.screenY, + bottomEdge - defaultPiPHeight, + "Opened at default Y location" + ); + checkIfEqual( + pipWin.innerWidth, + defaultPiPWidth, + "Opened at default PiP width" + ); + checkIfEqual( + pipWin.innerHeight, + defaultPiPHeight, + "Opened at default PiP height" + ); + } + + // Linux doesn't handle switching the video source well and it will + // cause the tests to failed in unexpected ways. Possibly caused by + // bug 1594223 https://bugzilla.mozilla.org/show_bug.cgi?id=1594223 + if (AppConstants.platform != "linux") { + // Save width and height for when aspect ratio is changed + height = pipWin.innerHeight; + width = pipWin.innerWidth; + + left = 200; + top = 100; + pipWin.moveTo(left, top); + + // Now switch the video so the video ratio is different + await switchVideoSource("test-video-cropped.mp4"); + await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true); + pipWin = await triggerPictureInPicture(browser, "with-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + checkIfEqual(pipWin.screenX, left, "Opened at last X location"); + checkIfEqual(pipWin.screenY, top, "Opened at last Y location"); + checkIfEqual( + pipWin.innerHeight, + height, + "Opened height with previous width" + ); + checkIfEqual( + pipWin.innerWidth, + height * (pipWin.innerWidth / pipWin.innerHeight), + "Width is changed to adjust for aspect ration" + ); + + left = 300; + top = 300; + pipWin.moveTo(left, top); + pipWin.resizeTo(defaultPiPWidth / 2, defaultPiPHeight / 2); + + // Save height for when aspect ratio is changed + height = pipWin.innerHeight; + + // Now switch the video so the video ratio is different + await switchVideoSource("test-video.mp4"); + await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true); + pipWin = await triggerPictureInPicture(browser, "with-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + checkIfEqual(pipWin.screenX, left, "Opened at last X location"); + checkIfEqual(pipWin.screenY, top, "Opened at last Y location"); + checkIfEqual(pipWin.innerHeight, height, "Opened with previous height"); + checkIfEqual( + pipWin.innerWidth, + height * (pipWin.innerWidth / pipWin.innerHeight), + "Width is changed to adjust for aspect ration" + ); + } + + // Move so that part of PiP is off screen (bottom right) + + left = rightEdge - Math.round((3 * pipWin.innerWidth) / 4); + top = bottomEdge - Math.round((3 * pipWin.innerHeight) / 4); + + let movePromise = BrowserTestUtils.waitForEvent( + pipWin.windowRoot, + "MozUpdateWindowPos" + ); + pipWin.moveTo(left, top); + await movePromise; + + await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true); + pipWin = await triggerPictureInPicture(browser, "with-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + // Redefine top and left to where the PiP windop will open + left = rightEdge - pipWin.innerWidth; + top = bottomEdge - pipWin.innerHeight; + + // await new Promise(r => setTimeout(r, 5000)); + // PiP is opened bottom right but not off screen + checkIfEqual( + pipWin.screenX, + left, + "Opened at last X location but shifted back on screen" + ); + if (AppConstants.platform == "linux") { + checkIfEqual( + pipWin.screenY, + top - tabHeight, + "Opened at last Y location but shifted back on screen" + ); + } else { + checkIfEqual( + pipWin.screenY, + top, + "Opened at last Y location but shifted back on screen" + ); + } + + // Move so that part of PiP is off screen (top left) + left = defaultX - Math.round(pipWin.innerWidth / 4); + top = defaultY - Math.round(pipWin.innerHeight / 4); + + movePromise = BrowserTestUtils.waitForEvent( + pipWin.windowRoot, + "MozUpdateWindowPos" + ); + pipWin.moveTo(left, top); + await movePromise; + + await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true); + pipWin = await triggerPictureInPicture(browser, "with-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + // PiP is opened top left on screen + checkIfEqual( + pipWin.screenX, + defaultX, + "Opened at last X location but shifted back on screen" + ); + checkIfEqual( + pipWin.screenY, + defaultY, + "Opened at last Y location but shifted back on screen" + ); + + if (AppConstants.platform != "linux") { + // test that if video is on right edge and new video with smaller width + // is opened next, it is still on the right edge + left = rightEdge - pipWin.innerWidth; + top = Math.round(bottomEdge / 4); + + pipWin.moveTo(left, top); + + // Used to ensure that video width decreases for next PiP window + width = pipWin.innerWidth; + checkIfEqual( + pipWin.innerWidth + pipWin.screenX, + rightEdge, + "Video is on right edge before video is changed" + ); + + // Now switch the video so the video width is smaller + await switchVideoSource("test-video-cropped.mp4"); + await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true); + pipWin = await triggerPictureInPicture(browser, "with-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + checkIfEqual( + pipWin.innerWidth < width, + true, + "New video width is smaller" + ); + checkIfEqual( + pipWin.innerWidth + pipWin.screenX, + rightEdge, + "Video is on right edge after video is changed" + ); + } + + await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js b/toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js new file mode 100644 index 0000000000..7d40664df4 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests functionality of arrow keys in Picture-in-Picture window + * for seeking and volume adjustment + */ +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.keyboard-controls.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let waitForVideoEvent = eventType => { + return BrowserTestUtils.waitForContentEvent(browser, eventType, true); + }; + + await ensureVideosReady(browser); + + // Open the video in PiP + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + // run the next tests 4 times to ensure that they work for each PiP button, including none + for (var i = 0; i < 4; i++) { + // Try seek forward + let seekedForwardPromise = waitForVideoEvent("seeked"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin); + ok(await seekedForwardPromise, "The time seeked forward"); + + // Try seek backward + let seekedBackwardPromise = waitForVideoEvent("seeked"); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin); + ok(await seekedBackwardPromise, "The time seeked backward"); + + // Try volume down + let volumeDownPromise = waitForVideoEvent("volumechange"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, pipWin); + ok(await volumeDownPromise, "The volume went down"); + + // Try volume up + let volumeUpPromise = waitForVideoEvent("volumechange"); + EventUtils.synthesizeKey("KEY_ArrowUp", {}, pipWin); + ok(await volumeUpPromise, "The volume went up"); + + // Tab to get to the next button + EventUtils.synthesizeKey("KEY_Tab", {}, pipWin); + } + + await BrowserTestUtils.closeWindow(pipWin); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_showMessage.js b/toolkit/components/pictureinpicture/tests/browser_showMessage.js new file mode 100644 index 0000000000..24d8347a7f --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_showMessage.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that triggering Picture-in-Picture causes the Picture-in-Picture + * window to be opened, and a message to be displayed in the original video + * player area. Also ensures that once the Picture-in-Picture window is closed, + * the video goes back to the original state. + */ +add_task(async () => { + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + await ensureMessageAndClosePiP(browser, videoID, pipWin, false); + } + ); + } +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js b/toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js new file mode 100644 index 0000000000..0c2a392d9f --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js @@ -0,0 +1,210 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the Picture-in-Picture toggle is hidden when videos + * are laid out with dimensions smaller than MIN_VIDEO_DIMENSION (a + * constant that is also defined in videocontrols.js). + */ +add_task(async () => { + // Most of the Picture-in-Picture tests run with the always-show + // preference set to true to avoid the toggle visibility heuristics. + // Since this test actually exercises those heuristics, we have + // to temporarily disable that pref. + // + // We also reduce the minimum video length for displaying the toggle + // to 5 seconds to avoid having to include or generate a 45 second long + // video (which is the default minimum length). + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.video-toggle.always-show", + false, + ], + ["media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 5], + ], + }); + + // This is the minimum size of the video in either width or height for + // which we will show the toggle. See videocontrols.js. + const MIN_VIDEO_DIMENSION = 140; // pixels + + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_SOUND, + gBrowser, + }, + async browser => { + // Shrink the video down to less than MIN_VIDEO_DIMENSION. + let targetWidth = MIN_VIDEO_DIMENSION - 1; + await SpecialPowers.spawn( + browser, + [videoID, targetWidth], + async (videoID, targetWidth) => { + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + let resizePromise = ContentTaskUtils.waitForEvent( + shadowRoot.firstChild, + "resizevideocontrols" + ); + video.style.width = targetWidth + "px"; + await resizePromise; + } + ); + + // The toggle should be hidden. + await testToggleHelper(browser, videoID, false); + + // Now re-expand the video. + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + let resizePromise = ContentTaskUtils.waitForEvent( + shadowRoot.firstChild, + "resizevideocontrols" + ); + video.style.width = ""; + await resizePromise; + }); + + // The toggle should be visible. + await testToggleHelper(browser, videoID, true); + } + ); + } +}); + +/** + * Tests that when using the experimental toggle variations, videos + * under 320px width are given the "small-video" attribute. + */ +add_task(async () => { + const TOGGLE_SMALL = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-wrapper": 0.8, + }, + hidden: [], + }, + + hoverToggle: { + opacities: { + ".pip-wrapper": 1.0, + }, + hidden: [".pip-expanded"], + }, + }, + }; + + const TOGGLE_LARGE = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-small": 0.0, + ".pip-wrapper": 0.8, + ".pip-expanded": 0.0, + }, + hidden: [".pip-explainer", ".pip-icon-label > .pip-icon"], + }, + hoverToggle: { + opacities: { + ".pip-small": 0.0, + ".pip-wrapper": 1.0, + }, + hidden: [ + ".pip-explainer", + ".pip-icon-label > .pip-icon", + ".pip-expanded", + ], + }, + }, + }; + + // Most of the Picture-in-Picture tests run with the always-show + // preference set to true to avoid the toggle visibility heuristics. + // Since this test actually exercises those heuristics, we have + // to temporarily disable that pref. + // + // We also reduce the minimum video length for displaying the toggle + // to 5 seconds to avoid having to include or generate a 45 second long + // video (which is the default minimum length). + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.video-toggle.always-show", + false, + ], + ["media.videocontrols.picture-in-picture.video-toggle.min-video-secs", 5], + ["media.videocontrols.picture-in-picture.video-toggle.mode", 1], + ], + }); + + // Videos that are thinner than MIN_VIDEO_WIDTH should have the small-video + // attribute set on the experimental toggle. + const MIN_VIDEO_WIDTH = 320; // pixels + + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_SOUND, + gBrowser, + }, + async browser => { + // Shrink the video down to less than MIN_VIDEO_WIDTH. + let targetWidth = MIN_VIDEO_WIDTH - 1; + let isSmallVideo = await SpecialPowers.spawn( + browser, + [videoID, targetWidth], + async (videoID, targetWidth) => { + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + let resizePromise = ContentTaskUtils.waitForEvent( + shadowRoot.firstChild, + "resizevideocontrols" + ); + video.style.width = targetWidth + "px"; + await resizePromise; + let toggle = shadowRoot.getElementById("pictureInPictureToggle"); + return toggle.hasAttribute("small-video"); + } + ); + + Assert.ok(isSmallVideo, "Video should have small-video attribute"); + + await testToggleHelper(browser, videoID, true, undefined, TOGGLE_SMALL); + + // Now re-expand the video. + isSmallVideo = await SpecialPowers.spawn( + browser, + [videoID], + async videoID => { + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + let resizePromise = ContentTaskUtils.waitForEvent( + shadowRoot.firstChild, + "resizevideocontrols" + ); + video.style.width = ""; + await resizePromise; + let toggle = shadowRoot.getElementById("pictureInPictureToggle"); + return toggle.hasAttribute("small-video"); + } + ); + + Assert.ok(!isSmallVideo, "Video should not have small-video attribute"); + + await testToggleHelper(browser, videoID, true, undefined, TOGGLE_LARGE); + } + ); + } +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js b/toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js new file mode 100644 index 0000000000..10eb8f43ea --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that <video>'s with styles on the element don't have those + * styles cloned over into the <video> that's inserted into the + * player window. + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let styles = { + padding: "15px", + border: "5px solid red", + margin: "3px", + position: "absolute", + }; + + await SpecialPowers.spawn(browser, [styles], async videoStyles => { + let video = content.document.getElementById("no-controls"); + for (let styleProperty in videoStyles) { + video.style[styleProperty] = videoStyles[styleProperty]; + } + }); + + let pipWin = await triggerPictureInPicture(browser, "no-controls"); + ok(pipWin, "Got Picture-in-Picture window."); + + let playerBrowser = pipWin.document.getElementById("browser"); + await SpecialPowers.spawn(playerBrowser, [styles], async videoStyles => { + let video = content.document.querySelector("video"); + for (let styleProperty in videoStyles) { + Assert.equal( + video.style[styleProperty], + "", + `Style ${styleProperty} should not be set` + ); + } + }); + await BrowserTestUtils.closeWindow(pipWin); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js b/toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js new file mode 100644 index 0000000000..3ca55f2c73 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * The goal of this test is check the that "tab-icon-overlay" image is + * showing when the tab is using PiP. + * + * The browser will create a tab and open a video using PiP + * then the tests check that the tab icon overlay image is showing* + * + * + */ +add_task(async () => { + let videoID = "with-controls"; + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_SOUND, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + let audioPromise = BrowserTestUtils.waitForEvent( + browser, + "DOMAudioPlaybackStarted" + ); + + await SpecialPowers.spawn(browser, [videoID], async videoID => { + await content.document.getElementById(videoID).play(); + }); + + // Check that video is playing + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused."); + await audioPromise; + + // Need tab to access the tab-icon-overlay element + let tab = gBrowser.getTabForBrowser(browser); + + // Use tab to get the tab-icon-overlay element + let tabIconOverlay = tab.getElementsByClassName("tab-icon-overlay")[0]; + + // Not in PiP yet so the tab-icon-overlay does not have "pictureinpicture" attribute + ok(!tabIconOverlay.hasAttribute("pictureinpicture"), "Not using PiP"); + + // Sound is playing so tab should have "soundplaying" attribute + ok(tabIconOverlay.hasAttribute("soundplaying"), "Sound is playing"); + + // Start the PiP + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + // Check that video is still playing + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused."); + + // Video is still playing so the tab-icon-overlay should have "soundplaying" as an attribute + ok( + tabIconOverlay.hasAttribute("soundplaying"), + "Tab knows sound is playing" + ); + + // Now in PiP. "pictureinpicture" is an attribute + ok( + tabIconOverlay.hasAttribute("pictureinpicture"), + "Tab knows were using PiP" + ); + + // We know the tab has sound playing and it is using PiP so we can check the + // tab-icon-overlay image is showing + let style = window.getComputedStyle(tabIconOverlay); + Assert.equal( + style.listStyleImage, + 'url("chrome://browser/skin/tabbrowser/tab-audio-playing-small.svg")', + "Got the tab-icon-overlay image" + ); + + // Check tab is not muted + ok(!tabIconOverlay.hasAttribute("muted"), "Tab is not muted"); + + // Click on tab icon overlay to mute tab and check it is muted + tabIconOverlay.click(); + ok(tabIconOverlay.hasAttribute("muted"), "Tab is muted"); + + // Click on tab icon overlay to unmute tab and check it is not muted + tabIconOverlay.click(); + ok(!tabIconOverlay.hasAttribute("muted"), "Tab is not muted"); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js new file mode 100644 index 0000000000..cf63768d85 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that videos hosted inside of a third-party <iframe> can be opened + * in a Picture-in-Picture window. + */ +add_task(async () => { + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_IFRAME, + gBrowser, + }, + async browser => { + // TEST_PAGE_WITH_IFRAME is hosted at a different domain from TEST_PAGE, + // so loading TEST_PAGE within the iframe will act as our third-party + // iframe. + await SpecialPowers.spawn(browser, [TEST_PAGE], async TEST_PAGE => { + let iframe = content.document.getElementById("iframe"); + let loadPromise = ContentTaskUtils.waitForEvent(iframe, "load"); + iframe.src = TEST_PAGE; + await loadPromise; + }); + + let iframeBc = browser.browsingContext.children[0]; + + if (gFissionBrowser) { + Assert.notEqual( + browser.browsingContext.currentWindowGlobal.osPid, + iframeBc.currentWindowGlobal.osPid, + "The iframe should be running in a different process." + ); + } + + let pipWin = await triggerPictureInPicture(iframeBc, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + await ensureMessageAndClosePiP(iframeBc, videoID, pipWin, true); + } + ); + } +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js b/toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js new file mode 100644 index 0000000000..5588b3a775 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Helper function that tries to use the mouse to open the Picture-in-Picture + * player window for a video with and without the built-in controls. + * + * @param {Element} tab The tab to be tested. + * @return Promise + * @resolves When the toggles for both the video-with-controls and + * video-without-controls have been tested. + */ +async function testToggleForTab(tab) { + for (let videoID of ["with-controls", "no-controls"]) { + let browser = tab.linkedBrowser; + info(`Testing ${videoID} case.`); + + await testToggleHelper(browser, videoID, true); + } +} + +/** + * Tests that the Picture-in-Picture toggle still works after tearing out the + * tab into a new window, or tearing in a tab from one window to another. + */ +add_task(async () => { + // The startingTab will be torn out and placed in the new window. + let startingTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE + ); + + // Tear out the starting tab into its own window... + let newWinLoaded = BrowserTestUtils.waitForNewWindow(); + let win2 = gBrowser.replaceTabWithWindow(startingTab); + await newWinLoaded; + + // Let's maximize the newly opened window so we don't have to worry about + // the videos being visible. + if (win2.windowState != win2.STATE_MAXIMIZED) { + let resizePromise = BrowserTestUtils.waitForEvent(win2, "resize"); + win2.maximize(); + await resizePromise; + } + + await SimpleTest.promiseFocus(win2); + await testToggleForTab(win2.gBrowser.selectedTab); + + // Now bring the tab back to the original window. + let dragInTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gBrowser.swapBrowsersAndCloseOther(dragInTab, win2.gBrowser.selectedTab); + await SimpleTest.promiseFocus(window); + await testToggleForTab(dragInTab); + + BrowserTestUtils.removeTab(dragInTab); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js new file mode 100644 index 0000000000..6f81075770 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js @@ -0,0 +1,17 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the Picture-in-Picture toggle can be clicked when overlaid + * with a transparent button, but not clicked when overlaid with an + * opaque button. + */ +add_task(async () => { + const PAGE = TEST_ROOT + "test-button-overlay.html"; + await testToggle(PAGE, { + "video-partial-transparent-button": { canToggle: true }, + "video-opaque-button": { canToggle: false }, + }); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js b/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js new file mode 100644 index 0000000000..e7a62f6278 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * See the documentation for the DEFAULT_TOGGLE_STYLES object in head.js + * for a description of what these toggle style objects are representing. + */ +const TOGGLE_STYLES_LEFT_EXPLAINER = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-small": 0.0, + ".pip-wrapper": 0.8, + ".pip-expanded": 1.0, + }, + hidden: [".pip-icon-label > .pip-icon"], + }, + + hoverToggle: { + opacities: { + ".pip-small": 0.0, + ".pip-wrapper": 1.0, + ".pip-expanded": 1.0, + }, + hidden: [".pip-icon-label > .pip-icon"], + }, + }, +}; + +const TOGGLE_STYLES_RIGHT_EXPLAINER = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-small": 0.0, + ".pip-wrapper": 0.8, + ".pip-expanded": 1.0, + }, + hidden: [".pip-wrapper > .pip-icon"], + }, + + hoverToggle: { + opacities: { + ".pip-small": 0.0, + ".pip-wrapper": 1.0, + ".pip-expanded": 1.0, + }, + hidden: [".pip-wrapper > .pip-icon"], + }, + }, +}; + +const TOGGLE_STYLES_LEFT_SMALL = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-wrapper": 0.8, + }, + hidden: [".pip-expanded"], + }, + + hoverToggle: { + opacities: { + ".pip-wrapper": 1.0, + }, + hidden: [".pip-expanded"], + }, + }, +}; + +const TOGGLE_STYLES_RIGHT_SMALL = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-wrapper": 0.8, + }, + hidden: [".pip-expanded"], + }, + + hoverToggle: { + opacities: { + ".pip-wrapper": 1.0, + }, + hidden: [".pip-expanded"], + }, + }, +}; + +/** + * Tests the Mode 2 variation of the Picture-in-Picture toggle in both the + * left and right positions, when the user is in the state where they've never + * clicked on the Picture-in-Picture toggle before (since we show a more detailed + * toggle in that state). + */ +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.videocontrols.picture-in-picture.video-toggle.position", "left"], + [HAS_USED_PREF, false], + ], + }); + + await testToggle(TEST_PAGE, { + "with-controls": { + canToggle: true, + toggleStyles: TOGGLE_STYLES_LEFT_EXPLAINER, + }, + }); + + Assert.ok( + Services.prefs.getBoolPref(HAS_USED_PREF, false), + "Entered has-used mode." + ); + Services.prefs.clearUserPref(HAS_USED_PREF); + + await testToggle(TEST_PAGE, { + "no-controls": { + canToggle: true, + toggleStyles: TOGGLE_STYLES_LEFT_EXPLAINER, + }, + }); + + Assert.ok( + Services.prefs.getBoolPref(HAS_USED_PREF, false), + "Entered has-used mode." + ); + Services.prefs.clearUserPref(HAS_USED_PREF); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.videocontrols.picture-in-picture.video-toggle.position", "right"], + ], + }); + + await testToggle(TEST_PAGE, { + "with-controls": { + canToggle: true, + toggleStyles: TOGGLE_STYLES_RIGHT_EXPLAINER, + }, + }); + + Assert.ok( + Services.prefs.getBoolPref(HAS_USED_PREF, false), + "Entered has-used mode." + ); + Services.prefs.clearUserPref(HAS_USED_PREF); + + await testToggle(TEST_PAGE, { + "no-controls": { + canToggle: true, + toggleStyles: TOGGLE_STYLES_RIGHT_EXPLAINER, + }, + }); + + Assert.ok( + Services.prefs.getBoolPref(HAS_USED_PREF, false), + "Entered has-used mode." + ); + Services.prefs.clearUserPref(HAS_USED_PREF); +}); + +/** + * Tests the Mode 2 variation of the Picture-in-Picture toggle in both the + * left and right positions, when the user is in the state where they've + * used the Picture-in-Picture feature before. + */ +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.videocontrols.picture-in-picture.video-toggle.mode", 2], + ["media.videocontrols.picture-in-picture.video-toggle.position", "left"], + [HAS_USED_PREF, true], + ], + }); + + await testToggle(TEST_PAGE, { + "with-controls": { + canToggle: true, + toggleStyles: TOGGLE_STYLES_LEFT_SMALL, + }, + "no-controls": { canToggle: true, toggleStyles: TOGGLE_STYLES_LEFT_SMALL }, + }); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.videocontrols.picture-in-picture.video-toggle.position", "right"], + ], + }); + + await testToggle(TEST_PAGE, { + "with-controls": { + canToggle: true, + toggleStyles: TOGGLE_STYLES_LEFT_SMALL, + }, + "no-controls": { canToggle: true, toggleStyles: TOGGLE_STYLES_LEFT_SMALL }, + }); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js b/toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js new file mode 100644 index 0000000000..7163966e64 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the Picture-in-Picture toggle correctly attaches itself when the + * video element has been inserted into the DOM after the video is ready to + * play. + */ +add_task(async () => { + const PAGE = TEST_ROOT + "test-page.html"; + + await testToggle( + PAGE, + { + inserted: { canToggle: true }, + }, + async browser => { + await SpecialPowers.spawn(browser, [], async () => { + let doc = content.document; + + // To avoid issues with the video not being scrolled into view, get + // rid of the other videos on the page. + let preExistingVideos = doc.querySelectorAll("video"); + for (let video of preExistingVideos) { + video.remove(); + } + + let newVideo = doc.createElement("video"); + const { ContentTaskUtils } = ChromeUtils.import( + "resource://testing-common/ContentTaskUtils.jsm" + ); + let ready = ContentTaskUtils.waitForEvent(newVideo, "canplay"); + newVideo.src = "test-video.mp4"; + newVideo.id = "inserted"; + await ready; + doc.body.appendChild(newVideo); + }); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js b/toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js new file mode 100644 index 0000000000..18c906bf20 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the Picture-in-Picture toggle is not clickable when + * overlaid with opaque elements. + */ +add_task(async () => { + const PAGE = TEST_ROOT + "test-opaque-overlay.html"; + await testToggle(PAGE, { + "video-full-opacity": { canToggle: false }, + "video-full-opacity-over-toggle": { canToggle: false }, + }); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js b/toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js new file mode 100644 index 0000000000..23cc393960 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the Picture-in-Picture toggle is clickable even if the + * video element has pointer-events: none. + */ +add_task(async () => { + const PAGE = TEST_ROOT + "test-pointer-events-none.html"; + await testToggle(PAGE, { + "with-controls": { canToggle: true }, + "no-controls": { canToggle: true }, + }); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js b/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js new file mode 100644 index 0000000000..165fa1c72b --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js @@ -0,0 +1,126 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that by setting a Picture-in-Picture toggle position policy + * in the sharedData structure, that the toggle position can be + * change for a particular URI. + */ +add_task(async () => { + let positionPolicies = [ + TOGGLE_POLICIES.TOP, + TOGGLE_POLICIES.ONE_QUARTER, + TOGGLE_POLICIES.THREE_QUARTERS, + TOGGLE_POLICIES.BOTTOM, + ]; + + for (let policy of positionPolicies) { + Services.ppmm.sharedData.set(SHARED_DATA_KEY, { + "*://example.com/*": { policy }, + }); + Services.ppmm.sharedData.flush(); + + let expectations = { + "with-controls": { canToggle: true, policy }, + "no-controls": { canToggle: true, policy }, + }; + + // For <video> elements with controls, the video controls overlap the + // toggle when its on the bottom and can't be clicked, so we'll ignore + // that case. + if (policy == TOGGLE_POLICIES.BOTTOM) { + expectations["with-controls"] = { canToggle: true }; + } + + await testToggle(TEST_PAGE, expectations); + + // And ensure that other pages aren't affected by this override. + await testToggle(TEST_PAGE_2, { + "with-controls": { canToggle: true }, + "no-controls": { canToggle: true }, + }); + } + + Services.ppmm.sharedData.set(SHARED_DATA_KEY, {}); + Services.ppmm.sharedData.flush(); +}); + +/** + * Tests that by setting a Picture-in-Picture toggle hidden policy + * in the sharedData structure, that the toggle can be suppressed. + */ +add_task(async () => { + Services.ppmm.sharedData.set(SHARED_DATA_KEY, { + "*://example.com/*": { policy: TOGGLE_POLICIES.HIDDEN }, + }); + Services.ppmm.sharedData.flush(); + + await testToggle(TEST_PAGE, { + "with-controls": { canToggle: false, policy: TOGGLE_POLICIES.HIDDEN }, + "no-controls": { canToggle: false, policy: TOGGLE_POLICIES.HIDDEN }, + }); + + // And ensure that other pages aren't affected by this override. + await testToggle(TEST_PAGE_2, { + "with-controls": { canToggle: true }, + "no-controls": { canToggle: true }, + }); + + Services.ppmm.sharedData.set(SHARED_DATA_KEY, {}); + Services.ppmm.sharedData.flush(); +}); + +/** + * Tests that policies are re-evaluated if the page URI is transitioned + * via the history API. + */ +add_task(async () => { + Services.ppmm.sharedData.set(SHARED_DATA_KEY, { + "*://example.com/*/test-page.html": { policy: TOGGLE_POLICIES.HIDDEN }, + }); + Services.ppmm.sharedData.flush(); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await ensureVideosReady(browser); + await SimpleTest.promiseFocus(browser); + + await testToggleHelper( + browser, + "no-controls", + false, + TOGGLE_POLICIES.HIDDEN + ); + + await SpecialPowers.spawn(browser, [], async function() { + content.history.pushState({}, "2", "otherpage.html"); + }); + + // Since we no longer match the policy URI, we should be able + // to use the Picture-in-Picture toggle. + await testToggleHelper(browser, "no-controls", true); + + // Now use the history API to put us back at the original location, + // which should have the HIDDEN policy re-applied. + await SpecialPowers.spawn(browser, [], async function() { + content.history.pushState({}, "Return", "test-page.html"); + }); + + await testToggleHelper( + browser, + "no-controls", + false, + TOGGLE_POLICIES.HIDDEN + ); + } + ); + + Services.ppmm.sharedData.set(SHARED_DATA_KEY, {}); + Services.ppmm.sharedData.flush(); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleSimple.js b/toolkit/components/pictureinpicture/tests/browser_toggleSimple.js new file mode 100644 index 0000000000..a9351816fa --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleSimple.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that we show the Picture-in-Picture toggle on video + * elements when hovering them with the mouse cursor, and that + * clicking on them causes the Picture-in-Picture window to + * open if the toggle isn't being occluded. This test tests videos + * both with and without controls. + */ +add_task(async () => { + await testToggle(TEST_PAGE, { + "with-controls": { canToggle: true }, + "no-controls": { canToggle: true }, + }); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js new file mode 100644 index 0000000000..658bb0f362 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the Picture-in-Picture toggle can appear and be clicked + * when the video is overlaid with transparent elements. Also tests the + * site-specific toggle visibility threshold to ensure that we can + * configure opacities that can't be clicked through. + */ +add_task(async () => { + const PAGE = TEST_ROOT + "test-transparent-overlay-1.html"; + await testToggle(PAGE, { + "video-transparent-background": { canToggle: true }, + "video-alpha-background": { canToggle: true }, + }); + + // Now set a toggle visibility threshold to 0.4 and ensure that the + // partially obscured toggle can't be clicked. + Services.ppmm.sharedData.set(SHARED_DATA_KEY, { + "*://example.com/*": { visibilityThreshold: 0.4 }, + }); + Services.ppmm.sharedData.flush(); + + await testToggle(PAGE, { + "video-transparent-background": { canToggle: true }, + "video-alpha-background": { canToggle: false }, + }); + + Services.ppmm.sharedData.set(SHARED_DATA_KEY, {}); + Services.ppmm.sharedData.flush(); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js new file mode 100644 index 0000000000..b425b50d1c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the Picture-in-Picture toggle can appear and be clicked + * when the video is overlaid with elements that have zero and partial + * opacity. Also tests the site-specific toggle visibility threshold to + * ensure that we can configure opacities that can't be clicked through. + */ +add_task(async () => { + const PAGE = TEST_ROOT + "test-transparent-overlay-2.html"; + await testToggle(PAGE, { + "video-zero-opacity": { canToggle: true }, + "video-partial-opacity": { canToggle: true }, + }); + + // Now set a toggle visibility threshold to 0.4 and ensure that the + // partially obscured toggle can't be clicked. + Services.ppmm.sharedData.set(SHARED_DATA_KEY, { + "*://example.com/*": { visibilityThreshold: 0.4 }, + }); + Services.ppmm.sharedData.flush(); + + await testToggle(PAGE, { + "video-zero-opacity": { canToggle: true }, + "video-partial-opacity": { canToggle: false }, + }); + + Services.ppmm.sharedData.set(SHARED_DATA_KEY, {}); + Services.ppmm.sharedData.flush(); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_videoSelection.js b/toolkit/components/pictureinpicture/tests/browser_videoSelection.js new file mode 100644 index 0000000000..f887f158c1 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_videoSelection.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the correct video is opened in the + * Picture-in-Picture player when opened via keyboard shortcut. + * The shortcut will open the first unpaused video + * or the longest video on the page. + */ +add_task(async function test_video_selection() { + await BrowserTestUtils.withNewTab( + { + url: TEST_ROOT + "test-video-selection.html", + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + let pipVideoID = await SpecialPowers.spawn(browser, [], () => { + let videoList = content.document.querySelectorAll("video"); + let longestDuration = -1; + let pipVideoID = null; + + for (let video of videoList) { + if (!video.paused) { + pipVideoID = video.id; + break; + } + if (video.duration > longestDuration) { + pipVideoID = video.id; + longestDuration = video.duration; + } + } + return pipVideoID; + }); + + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + let videoReady = SpecialPowers.spawn( + browser, + [pipVideoID], + async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video is being cloned visually."); + } + ); + + let eventObj = { accelKey: true, shiftKey: true }; + if (AppConstants.platform == "macosx") { + eventObj.altKey = true; + } + EventUtils.synthesizeKey("]", eventObj, window); + + let pipWin = await domWindowOpened; + await videoReady; + + ok(pipWin, "Got Picture-in-Picture window."); + + await ensureMessageAndClosePiP(browser, pipVideoID, pipWin, false); + + pipVideoID = await SpecialPowers.spawn(browser, [], () => { + let videoList = content.document.querySelectorAll("video"); + videoList[1].play(); + videoList[2].play(); + let longestDuration = -1; + let pipVideoID = null; + + for (let video of videoList) { + if (!video.paused) { + pipVideoID = video.id; + break; + } + if (video.duration > longestDuration) { + pipVideoID = video.id; + longestDuration = video.duration; + } + } + + return pipVideoID; + }); + + // Next time we want to use a keyboard shortcut with the main window in focus again. + await SimpleTest.promiseFocus(browser); + + domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + videoReady = SpecialPowers.spawn(browser, [pipVideoID], async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video is being cloned visually."); + }); + + EventUtils.synthesizeKey("]", eventObj, window); + + pipWin = await domWindowOpened; + await videoReady; + + ok(pipWin, "Got Picture-in-Picture window."); + + await ensureMessageAndClosePiP(browser, pipVideoID, pipWin, false); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/click-event-helper.js b/toolkit/components/pictureinpicture/tests/click-event-helper.js new file mode 100644 index 0000000000..6b3ba42994 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/click-event-helper.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This helper script is used to record mouse button events for + * Picture-in-Picture toggle click tests. Anytime the toggle is + * clicked, we expect none of the events to be fired. Otherwise, + * all events should be fired when clicking. + */ + +let eventTypes = ["pointerdown", "mousedown", "pointerup", "mouseup", "click"]; + +for (let event of eventTypes) { + addEventListener(event, recordEvent, { capture: true }); +} + +let recordedEvents = []; +function recordEvent(event) { + recordedEvents.push(event.type); +} + +function getRecordedEvents() { + let result = recordedEvents.concat(); + recordedEvents = []; + return result; +} diff --git a/toolkit/components/pictureinpicture/tests/head.js b/toolkit/components/pictureinpicture/tests/head.js new file mode 100644 index 0000000000..8df9062263 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/head.js @@ -0,0 +1,835 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TOGGLE_POLICIES } = ChromeUtils.import( + "resource://gre/modules/PictureInPictureControls.jsm" +); + +const TEST_ROOT = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" +); +const TEST_ROOT_2 = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.org" +); +const TEST_PAGE = TEST_ROOT + "test-page.html"; +const TEST_PAGE_2 = TEST_ROOT_2 + "test-page.html"; +const TEST_PAGE_WITH_IFRAME = TEST_ROOT_2 + "test-page-with-iframe.html"; +const TEST_PAGE_WITH_SOUND = TEST_ROOT + "test-page-with-sound.html"; +const WINDOW_TYPE = "Toolkit:PictureInPicture"; +const TOGGLE_POSITION_PREF = + "media.videocontrols.picture-in-picture.video-toggle.position"; +const HAS_USED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; +const SHARED_DATA_KEY = "PictureInPicture:SiteOverrides"; + +/** + * We currently ship with a few different variations of the + * Picture-in-Picture toggle. The tests for Picture-in-Picture include tests + * that check the style rules of various parts of the toggle. Since each toggle + * variation has different style rules, we introduce a structure here to + * describe the appearance of the toggle at different stages for the tests. + * + * The top-level structure looks like this: + * + * { + * rootID (String): The ID of the root element of the toggle. + * stages (Object): An Object representing the styles of the toggle at + * different stages of its use. Each property represents a different + * stage that can be tested. Right now, those stages are: + * + * hoverVideo: + * When the mouse is hovering the video but not the toggle. + * + * hoverToggle: + * When the mouse is hovering both the video and the toggle. + * + * Both stages must be assigned an Object with the following properties: + * + * opacities: + * This should be set to an Object where the key is a CSS selector for + * an element, and the value is a double for what the eventual opacity + * of that element should be set to. + * + * hidden: + * This should be set to an Array of CSS selector strings for elements + * that should be hidden during a particular stage. + * } + * + * DEFAULT_TOGGLE_STYLES is the set of styles for the default variation of the + * toggle. + */ +const DEFAULT_TOGGLE_STYLES = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-wrapper": 0.8, + }, + hidden: [".pip-expanded"], + }, + + hoverToggle: { + opacities: { + ".pip-wrapper": 1.0, + }, + hidden: [".pip-expanded"], + }, + }, +}; + +/** + * Given a browser and the ID for a <video> element, triggers + * Picture-in-Picture for that <video>, and resolves with the + * Picture-in-Picture window once it is ready to be used. + * + * @param {Element,BrowsingContext} browser The <xul:browser> or + * BrowsingContext hosting the <video> + * + * @param {String} videoID The ID of the video to trigger + * Picture-in-Picture on. + * + * @return Promise + * @resolves With the Picture-in-Picture window when ready. + */ +async function triggerPictureInPicture(browser, videoID) { + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + let videoReady = SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + let event = new content.CustomEvent("MozTogglePictureInPicture", { + bubbles: true, + }); + video.dispatchEvent(event); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video is being cloned visually."); + }); + let win = await domWindowOpened; + await Promise.all([ + SimpleTest.promiseFocus(win), + win.promiseDocumentFlushed(() => {}), + videoReady, + ]); + return win; +} + +/** + * Given a browser and the ID for a <video> element, checks that the + * video is showing the "This video is playing in Picture-in-Picture mode." + * status message overlay. + * + * @param {Element,BrowsingContext} browser The <xul:browser> or + * BrowsingContext hosting the <video> + * + * @param {String} videoID The ID of the video to trigger + * Picture-in-Picture on. + * + * @param {bool} expected True if we expect the message to be showing. + * + * @return Promise + * @resolves When the checks have completed. + */ +async function assertShowingMessage(browser, videoID, expected) { + let showing = await SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + let pipOverlay = shadowRoot.querySelector(".pictureInPictureOverlay"); + Assert.ok(pipOverlay, "Should be able to find Picture-in-Picture overlay."); + + let rect = pipOverlay.getBoundingClientRect(); + return rect.height > 0 && rect.width > 0; + }); + Assert.equal( + showing, + expected, + "Video should be showing the expected state." + ); +} + +/** + * Tests if a video is currently being cloned for a given content browser. Provides a + * good indicator for answering if this video is currently open in PiP. + * + * @param {Browser} browser + * The content browser that the video lives in + * @param {string} videoId + * The id associated with the video + * + * @returns {bool} + * Whether the video is currently being cloned (And is most likely open in PiP) + */ +function assertVideoIsBeingCloned(browser, videoId) { + return SpecialPowers.spawn(browser, [videoId], async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video is being cloned visually."); + }); +} + +/** + * Ensures that each of the videos loaded inside of a document in a + * <browser> have reached the HAVE_ENOUGH_DATA readyState. + * + * @param {Element} browser The <xul:browser> hosting the <video>(s) + * + * @return Promise + * @resolves When each <video> is in the HAVE_ENOUGH_DATA readyState. + */ +async function ensureVideosReady(browser) { + // PictureInPictureToggleChild waits for videos to fire their "canplay" + // event before considering them for the toggle, so we start by making + // sure each <video> has done this. + info(`Waiting for videos to be ready`); + await SpecialPowers.spawn(browser, [], async () => { + let videos = this.content.document.querySelectorAll("video"); + for (let video of videos) { + if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) { + info(`Waiting for 'canplaythrough' for '${video.id}'`); + await ContentTaskUtils.waitForEvent(video, "canplaythrough"); + } + } + }); +} + +/** + * Tests that the toggle opacity reaches or exceeds a certain threshold within + * a reasonable time. + * + * @param {Element} browser The <xul:browser> that has the <video> in it. + * @param {String} videoID The ID of the video element that we expect the toggle + * to appear on. + * @param {String} stage The stage for which the opacity is going to change. This + * should be one of "hoverVideo" or "hoverToggle". + * @param {Object} toggleStyles Optional argument. See the documentation for the + * DEFAULT_TOGGLE_STYLES object for a sense of what styleRules is expected to be. + * + * @return Promise + * @resolves When the check has completed. + */ +async function toggleOpacityReachesThreshold( + browser, + videoID, + stage, + toggleStyles = DEFAULT_TOGGLE_STYLES +) { + let togglePosition = Services.prefs.getStringPref( + TOGGLE_POSITION_PREF, + "right" + ); + let hasUsed = Services.prefs.getBoolPref(HAS_USED_PREF, false); + let toggleStylesForStage = toggleStyles.stages[stage]; + info( + `Testing toggle for stage ${stage} ` + + `in position ${togglePosition}, has used: ${hasUsed}` + ); + + let args = { videoID, toggleStylesForStage, togglePosition, hasUsed }; + await SpecialPowers.spawn(browser, [args], async args => { + let { videoID, toggleStylesForStage } = args; + + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + + for (let hiddenElement of toggleStylesForStage.hidden) { + let el = shadowRoot.querySelector(hiddenElement); + ok( + ContentTaskUtils.is_hidden(el), + `Expected ${hiddenElement} to be hidden.` + ); + } + + for (let opacityElement in toggleStylesForStage.opacities) { + let opacityThreshold = toggleStylesForStage.opacities[opacityElement]; + let el = shadowRoot.querySelector(opacityElement); + + await ContentTaskUtils.waitForCondition( + () => { + let opacity = parseFloat(this.content.getComputedStyle(el).opacity); + return opacity >= opacityThreshold; + }, + `Toggle element ${opacityElement} should have eventually reached ` + + `target opacity ${opacityThreshold}`, + 100, + 100 + ); + } + + ok(true, "Toggle reached target opacity."); + }); +} + +/** + * Tests that the toggle has the correct policy attribute set. This should be called + * either when the toggle is visible, or events have been queued such that the toggle + * will soon be visible. + * + * @param {Element} browser The <xul:browser> that has the <video> in it. + * @param {String} videoID The ID of the video element that we expect the toggle + * to appear on. + * @param {Number} policy Optional argument. If policy is defined, then it should + * be one of the values in the TOGGLE_POLICIES from PictureInPictureControls.jsm. + * If undefined, this function will ensure no policy attribute is set. + * + * @return Promise + * @resolves When the check has completed. + */ +async function assertTogglePolicy( + browser, + videoID, + policy, + toggleStyles = DEFAULT_TOGGLE_STYLES +) { + let toggleID = toggleStyles.rootID; + let args = { videoID, toggleID, policy }; + await SpecialPowers.spawn(browser, [args], async args => { + let { videoID, toggleID, policy } = args; + + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + let controlsOverlay = shadowRoot.querySelector(".controlsOverlay"); + let toggle = shadowRoot.getElementById(toggleID); + + await ContentTaskUtils.waitForCondition(() => { + return controlsOverlay.classList.contains("hovering"); + }, "Waiting for the hovering state to be set on the video."); + + if (policy) { + const { TOGGLE_POLICY_STRINGS } = ChromeUtils.import( + "resource://gre/modules/PictureInPictureControls.jsm" + ); + let policyAttr = toggle.getAttribute("policy"); + Assert.equal( + policyAttr, + TOGGLE_POLICY_STRINGS[policy], + "The correct toggle policy is set." + ); + } else { + Assert.ok( + !toggle.hasAttribute("policy"), + "No toggle policy should be set." + ); + } + }); +} + +/** + * Tests that either all or none of the expected mousebutton events + * fire in web content when clicking on the page. + * + * Note: This function will only work on pages that load the + * click-event-helper.js script. + * + * @param {Element} browser The <xul:browser> that will receive the mouse + * events. + * @param {bool} isExpectingEvents True if we expect all of the normal + * mouse button events to fire. False if we expect none of them to fire. + * @param {bool} isExpectingClick True if the mouse events should include the + * "click" event, which is only included when the primary mouse button is pressed. + * @return Promise + * @resolves When the check has completed. + */ +async function assertSawMouseEvents( + browser, + isExpectingEvents, + isExpectingClick = true +) { + const MOUSE_BUTTON_EVENTS = [ + "pointerdown", + "mousedown", + "pointerup", + "mouseup", + ]; + + if (isExpectingClick) { + MOUSE_BUTTON_EVENTS.push("click"); + } + + let mouseEvents = await SpecialPowers.spawn(browser, [], async () => { + return this.content.wrappedJSObject.getRecordedEvents(); + }); + + let expectedEvents = isExpectingEvents ? MOUSE_BUTTON_EVENTS : []; + Assert.deepEqual( + mouseEvents, + expectedEvents, + "Expected to get the right mouse events." + ); +} + +/** + * Ensures that a <video> inside of a <browser> is scrolled into view, + * and then returns the coordinates of its Picture-in-Picture toggle as well + * as whether or not the <video> element is showing the built-in controls. + * + * @param {Element} browser The <xul:browser> that has the <video> loaded in it. + * @param {String} videoID The ID of the video that has the toggle. + * + * @return Promise + * @resolves With the following Object structure: + * { + * controls: <Boolean>, + * } + * + * Where controls represents whether or not the video has the default control set + * displayed. + */ +async function prepareForToggleClick(browser, videoID) { + // Synthesize a mouse move just outside of the video to ensure that + // the video is in a non-hovering state. We'll go 5 pixels to the + // left and above the top-left corner. + await BrowserTestUtils.synthesizeMouse( + `#${videoID}`, + -5, + -5, + { + type: "mousemove", + }, + browser, + false + ); + + // For each video, make sure it's scrolled into view, and get the rect for + // the toggle while we're at it. + let args = { videoID }; + return SpecialPowers.spawn(browser, [args], async args => { + let { videoID } = args; + + let video = content.document.getElementById(videoID); + video.scrollIntoView({ behaviour: "instant" }); + + if (!video.controls) { + // For no-controls <video> elements, an IntersectionObserver is used + // to know when we the PictureInPictureChild should begin tracking + // mousemove events. We don't exactly know when that IntersectionObserver + // will fire, so we poll a special testing function that will tell us when + // the video that we care about is being tracked. + let { PictureInPictureToggleChild } = ChromeUtils.import( + "resource://gre/actors/PictureInPictureChild.jsm" + ); + await ContentTaskUtils.waitForCondition( + () => { + return PictureInPictureToggleChild.isTracking(video); + }, + "Waiting for PictureInPictureToggleChild to be tracking the video.", + 100, + 100 + ); + } + + let shadowRoot = video.openOrClosedShadowRoot; + let controlsOverlay = shadowRoot.querySelector(".controlsOverlay"); + await ContentTaskUtils.waitForCondition( + () => { + return !controlsOverlay.classList.contains("hovering"); + }, + "Waiting for the video to not be hovered.", + 100, + 100 + ); + + return { + controls: video.controls, + }; + }); +} + +/** + * Returns client rect info for the toggle if it's supposed to be visible + * on hover. Otherwise, returns client rect info for the video with the + * associated ID. + * + * @param {Element} browser The <xul:browser> that has the <video> loaded in it. + * @param {String} videoID The ID of the video that has the toggle. + * + * @return Promise + * @resolves With the following Object structure: + * { + * top: <Number>, + * left: <Number>, + * width: <Number>, + * height: <Number>, + * } + */ +async function getToggleClientRect( + browser, + videoID, + toggleStyles = DEFAULT_TOGGLE_STYLES +) { + let args = { videoID, toggleID: toggleStyles.rootID }; + return ContentTask.spawn(browser, args, async args => { + const { Rect } = ChromeUtils.import("resource://gre/modules/Geometry.jsm"); + + let { videoID, toggleID } = args; + let video = content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + let toggle = shadowRoot.getElementById(toggleID); + let rect = Rect.fromRect(toggle.getBoundingClientRect()); + + let clickableChildren = toggle.querySelectorAll(".clickable"); + for (let child of clickableChildren) { + let childRect = Rect.fromRect(child.getBoundingClientRect()); + rect.expandToContain(childRect); + } + + if (!rect.width && !rect.height) { + rect = video.getBoundingClientRect(); + } + + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + }); +} + +/** + * Test helper for the Picture-in-Picture toggle. Loads a page, and then + * tests the provided video elements for the toggle both appearing and + * opening the Picture-in-Picture window in the expected cases. + * + * @param {String} testURL The URL of the page with the <video> elements. + * @param {Object} expectations An object with the following schema: + * <video-element-id>: { + * canToggle: {Boolean} + * policy: {Number} (optional) + * styleRules: {Object} (optional) + * } + * If canToggle is true, then it's expected that moving the mouse over the + * video and then clicking in the toggle region should open a + * Picture-in-Picture window. If canToggle is false, we expect that a click + * in this region will not result in the window opening. + * + * If policy is defined, then it should be one of the values in the + * TOGGLE_POLICIES from PictureInPictureControls.jsm. + * + * See the documentation for the DEFAULT_TOGGLE_STYLES object for a sense + * of what styleRules is expected to be. If left undefined, styleRules will + * default to DEFAULT_TOGGLE_STYLES. + * + * @param {async Function} prepFn An optional asynchronous function to run + * before running the toggle test. The function is passed the opened + * <xul:browser> as its only argument once the testURL has finished loading. + * + * @return Promise + * @resolves When the test is complete and the tab with the loaded page is + * removed. + */ +async function testToggle(testURL, expectations, prepFn = async () => {}) { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: testURL, + }, + async browser => { + await prepFn(browser); + await ensureVideosReady(browser); + + for (let [videoID, { canToggle, policy, toggleStyles }] of Object.entries( + expectations + )) { + await SimpleTest.promiseFocus(browser); + info(`Testing video with id: ${videoID}`); + + await testToggleHelper( + browser, + videoID, + canToggle, + policy, + toggleStyles + ); + } + } + ); +} + +/** + * Test helper for the Picture-in-Picture toggle. Given a loaded page with some + * videos on it, tests that the toggle behaves as expected when interacted + * with by the mouse. + * + * @param {Element} browser The <xul:browser> that has the <video> loaded in it. + * @param {String} videoID The ID of the video that has the toggle. + * @param {Boolean} canToggle True if we expect the toggle to be visible and + * clickable by the mouse for the associated video. + * @param {Number} policy Optional argument. If policy is defined, then it should + * be one of the values in the TOGGLE_POLICIES from PictureInPictureControls.jsm. + * @param {Object} toggleStyles Optional argument. See the documentation for the + * DEFAULT_TOGGLE_STYLES object for a sense of what styleRules is expected to be. + * + * @return Promise + * @resolves When the check for the toggle is complete. + */ +async function testToggleHelper( + browser, + videoID, + canToggle, + policy, + toggleStyles +) { + let { controls } = await prepareForToggleClick(browser, videoID); + + // Hover the mouse over the video to reveal the toggle. + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mousemove", + }, + browser + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mouseover", + }, + browser + ); + + info("Checking toggle policy"); + await assertTogglePolicy(browser, videoID, policy, toggleStyles); + + if (canToggle) { + info("Waiting for toggle to become visible"); + await toggleOpacityReachesThreshold( + browser, + videoID, + "hoverVideo", + toggleStyles + ); + } + + let toggleClientRect = await getToggleClientRect( + browser, + videoID, + toggleStyles + ); + + info("Hovering the toggle rect now."); + let toggleCenterX = toggleClientRect.left + toggleClientRect.width / 2; + let toggleCenterY = toggleClientRect.top + toggleClientRect.height / 2; + + await BrowserTestUtils.synthesizeMouseAtPoint( + toggleCenterX, + toggleCenterY, + { + type: "mousemove", + }, + browser + ); + await BrowserTestUtils.synthesizeMouseAtPoint( + toggleCenterX, + toggleCenterY, + { + type: "mouseover", + }, + browser + ); + + if (canToggle) { + info("Waiting for toggle to reach full opacity"); + await toggleOpacityReachesThreshold( + browser, + videoID, + "hoverToggle", + toggleStyles + ); + } + + // First, ensure that a non-primary mouse click is ignored. + info("Right-clicking on toggle."); + + await BrowserTestUtils.synthesizeMouseAtPoint( + toggleCenterX, + toggleCenterY, + { button: 2 }, + browser + ); + + // For videos without the built-in controls, we expect that all mouse events + // should have fired - otherwise, the events are all suppressed. For videos + // with controls, none of the events should be fired, as the controls overlay + // absorbs them all. + // + // Note that the right-click does not result in a "click" event firing. + await assertSawMouseEvents(browser, !controls, false); + + // The message to open the Picture-in-Picture window would normally be sent + // immediately before this Promise resolved, so the window should have opened + // by now if it was going to happen. + for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) { + if (!win.closed) { + ok(false, "Found a Picture-in-Picture window unexpectedly."); + return; + } + } + + ok(true, "No Picture-in-Picture window found."); + + // Okay, now test with the primary mouse button. + + if (canToggle) { + info( + "Clicking on toggle, and expecting a Picture-in-Picture window to open" + ); + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + await BrowserTestUtils.synthesizeMouseAtPoint( + toggleCenterX, + toggleCenterY, + {}, + browser + ); + let win = await domWindowOpened; + ok(win, "A Picture-in-Picture window opened."); + + await assertVideoIsBeingCloned(browser, videoID); + + await BrowserTestUtils.closeWindow(win); + + // Make sure that clicking on the toggle resulted in no mouse button events + // being fired in content. + await assertSawMouseEvents(browser, false); + } else { + info( + "Clicking on toggle, and expecting no Picture-in-Picture window opens" + ); + await BrowserTestUtils.synthesizeMouseAtPoint( + toggleCenterX, + toggleCenterY, + {}, + browser + ); + + // If we aren't showing the toggle, we expect all mouse events to be seen. + await assertSawMouseEvents(browser, !controls); + + // The message to open the Picture-in-Picture window would normally be sent + // immediately before this Promise resolved, so the window should have opened + // by now if it was going to happen. + for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) { + if (!win.closed) { + ok(false, "Found a Picture-in-Picture window unexpectedly."); + return; + } + } + + ok(true, "No Picture-in-Picture window found."); + } + + // Click on the very top-left pixel of the document and ensure that we + // see all of the mouse events for it. + await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser); + await assertSawMouseEvents(browser, true); +} + +/** + * Helper function that ensures that a provided async function + * causes a window to fully enter fullscreen mode. + * + * @param window (DOM Window) + * The window that is expected to enter fullscreen mode. + * @param asyncFn (Async Function) + * The async function to run to trigger the fullscreen switch. + * @return Promise + * @resolves When the fullscreen entering transition completes. + */ +async function promiseFullscreenEntered(window, asyncFn) { + let entered = BrowserTestUtils.waitForEvent( + window, + "MozDOMFullscreen:Entered" + ); + + await asyncFn(); + + await entered; + + await BrowserTestUtils.waitForCondition(() => { + return !TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS"); + }); +} + +/** + * Helper function that ensures that a provided async function + * causes a window to fully exit fullscreen mode. + * + * @param window (DOM Window) + * The window that is expected to exit fullscreen mode. + * @param asyncFn (Async Function) + * The async function to run to trigger the fullscreen switch. + * @return Promise + * @resolves When the fullscreen exiting transition completes. + */ +async function promiseFullscreenExited(window, asyncFn) { + let exited = BrowserTestUtils.waitForEvent(window, "MozDOMFullscreen:Exited"); + + await asyncFn(); + + await exited; + + await BrowserTestUtils.waitForCondition(() => { + return !TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS"); + }); + + if (AppConstants.platform == "macosx") { + // On macOS, the fullscreen transition takes some extra time + // to complete, and we don't receive events for it. We need to + // wait for it to complete or else input events in the next test + // might get eaten up. This is the best we can currently do. + // + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 2000)); + } +} + +/** + * Helper function that ensures that the "This video is + * playing in Picture-in-Picture mode" message works, + * then closes the player window + * + * @param {Element} browser The <xul:browser> that has the <video> loaded in it. + * @param {String} videoID The ID of the video that has the toggle. + * @param {Element} pipWin The Picture-in-Picture window that was opened + * @param {Boolean} iframe True if the test is on an Iframe, which modifies + * the test behavior + */ +async function ensureMessageAndClosePiP(browser, videoID, pipWin, isIframe) { + try { + await assertShowingMessage(browser, videoID, true); + } finally { + let uaWidgetUpdate = null; + if (isIframe) { + uaWidgetUpdate = SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForEvent( + content.windowRoot, + "UAWidgetSetupOrChange", + true /* capture */ + ); + }); + } else { + uaWidgetUpdate = BrowserTestUtils.waitForContentEvent( + browser, + "UAWidgetSetupOrChange", + true /* capture */ + ); + } + await BrowserTestUtils.closeWindow(pipWin); + await uaWidgetUpdate; + } +} + +/** + * Helper function that returns True if the specified video is paused + * and False if the specified video is not paused. + * + * @param {Element} browser The <xul:browser> that has the <video> loaded in it. + * @param {String} videoID The ID of the video to check. + */ +async function isVideoPaused(browser, videoID) { + return SpecialPowers.spawn(browser, [videoID], async videoID => { + return content.document.getElementById(videoID).paused; + }); +} diff --git a/toolkit/components/pictureinpicture/tests/short.mp4 b/toolkit/components/pictureinpicture/tests/short.mp4 Binary files differnew file mode 100644 index 0000000000..abe37b9f9d --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/short.mp4 diff --git a/toolkit/components/pictureinpicture/tests/test-button-overlay.html b/toolkit/components/pictureinpicture/tests/test-button-overlay.html new file mode 100644 index 0000000000..9917fba973 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-button-overlay.html @@ -0,0 +1,81 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture test - transparent overlays - 1</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + video { + display: block; + } + + .container { + position: relative; + display: inline-block; + } + + .overlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + color: white; + } + + .toggle-overlay { + position: absolute; + min-width: 50px; + right: 0px; + height: 100%; + top: calc(50% - 25px); + } + + button { + height: 100px; + } + + .transparent-background { + background-color: transparent; + } + + .partial-opacity { + opacity: 0.5; + } + + .full-opacity { + opacity: 1.0; + background-color: green; + } + + .no-pointer-events { + pointer-events: none; + } + + .pointer-events { + pointer-events: auto; + } +</style> +<body> + <div class="container"> + <div class="overlay transparent-background no-pointer-events"> + This is a fully transparent overlay using a transparent background. + <div class="toggle-overlay partial-opacity pointer-events"> + <button>I'm a button overlapping the toggle</button> + </div> + </div> + <video id="video-partial-transparent-button" src="test-video.mp4" loop="true"></video> + </div> + + <div class="container"> + <div class="overlay transparent-background no-pointer-events"> + This is a fully transparent overlay using a transparent background. + <div class="toggle-overlay full-opacity pointer-events"> + <button>I'm a button overlapping the toggle</button> + </div> + </div> + <video id="video-opaque-button" src="test-video.mp4" loop="true"></video> + </div> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-media-stream.html b/toolkit/components/pictureinpicture/tests/test-media-stream.html new file mode 100644 index 0000000000..ad5f91dd25 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-media-stream.html @@ -0,0 +1,25 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture tests</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + video { + display: block; + border: 1px solid black; + } +</style> +<body> + <script> + function fireEvents() { + for (let videoID of ["with-controls", "no-controls"]) { + let video = document.getElementById(videoID); + let event = new CustomEvent("MozTogglePictureInPicture", { bubbles: true }); + video.dispatchEvent(event); + } + } + </script> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-opaque-overlay.html b/toolkit/components/pictureinpicture/tests/test-opaque-overlay.html new file mode 100644 index 0000000000..425c0e74c2 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-opaque-overlay.html @@ -0,0 +1,51 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture test - transparent overlays - 1</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + video { + display: block; + } + + .container { + position: relative; + display: inline-block; + } + + .overlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + color: white; + } + + .toggle-overlay { + position: absolute; + min-width: 50px; + right: 0px; + height: 100%; + top: calc(50% - 25px); + } + + .full-opacity { + opacity: 1.0; + background-color: green; + } +</style> +<body> + <div class="container"> + <div class="overlay full-opacity">This is a fully opaque overlay using opacity: 1.0</div> + <video id="video-full-opacity" src="test-video.mp4" loop="true"></video> + </div> + + <div class="container"> + <div class="toggle-overlay full-opacity">This is a fully opaque overlay over a region covering the toggle at opacity: 1.0</div> + <video id="video-full-opacity-over-toggle" src="test-video.mp4" loop="true"></video> + </div> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-page-with-iframe.html b/toolkit/components/pictureinpicture/tests/test-page-with-iframe.html new file mode 100644 index 0000000000..02205a028b --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-page-with-iframe.html @@ -0,0 +1,27 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture tests</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + html, body { + height: 100vh; + width: 100vw; + margin: 0; + padding: 0; + overflow: hidden; + } + #iframe { + height: 100vh; + width: 100vw; + padding: 0; + margin: 0; + border: 0; + } +</style> +<body> + <iframe id="iframe"></iframe> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-page-with-sound.html b/toolkit/components/pictureinpicture/tests/test-page-with-sound.html new file mode 100644 index 0000000000..6b6a860bc2 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-page-with-sound.html @@ -0,0 +1,20 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture tests (longer video with sound)</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + video { + display: block; + border: 1px solid black; + } +</style> +<body> + <h1>Video with controls</h1> + <video id="with-controls" src="gizmo.mp4" controls loop="true"></video> + <h1>Video without controls</h1> + <video id="no-controls" src="gizmo.mp4" loop="true"></video> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-page.html b/toolkit/components/pictureinpicture/tests/test-page.html new file mode 100644 index 0000000000..a62ff1ac4a --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-page.html @@ -0,0 +1,30 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture tests</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + video { + display: block; + border: 1px solid black; + } +</style> +<body> + <h1>Video with controls</h1> + <video id="with-controls" src="test-video.mp4" controls loop="true" width="400" height="225"></video> + <h1>Video without controls</h1> + <video id="no-controls" src="test-video.mp4" loop="true" width="400" height="225"></video> + + <script> + function fireEvents() { + for (let videoID of ["with-controls", "no-controls"]) { + let video = document.getElementById(videoID); + let event = new CustomEvent("MozTogglePictureInPicture", { bubbles: true }); + video.dispatchEvent(event); + } + } + </script> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-pointer-events-none.html b/toolkit/components/pictureinpicture/tests/test-pointer-events-none.html new file mode 100644 index 0000000000..63254b329c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-pointer-events-none.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture test - pointer-events: none</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + video { + display: block; + pointer-events: none; + } + +</style> +<body> + <h1>Video with controls</h1> + <video id="with-controls" src="test-video.mp4" controls loop="true"></video> + <h1>Video without controls</h1> + <video id="no-controls" src="test-video.mp4" loop="true"></video> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-reversed.html b/toolkit/components/pictureinpicture/tests/test-reversed.html new file mode 100644 index 0000000000..4f6d516698 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-reversed.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture tests</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + #reversed { + transform: scaleX(-1); + } +</style> +<body> + <h1>Reversed video</h1> + <video id="reversed" src="test-video.mp4" controls loop="true" width="400" height="225"></video> + <h1>Not Reversed Video</h1> + <video id="not-reversed" src="test-video.mp4" loop="true" width="400" height="225"></video> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html b/toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html new file mode 100644 index 0000000000..d7efcc1e92 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html @@ -0,0 +1,51 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture test - transparent iframe</title> +</head> + +<style> + video { + display: block; + } + + .root { + position: relative; + display: inline-block; + } + + .controls { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + color: white; + } + + .container, + iframe { + width: 100%; + height: 100%; + } + + iframe { + border: 0; + } +</style> + +<body> + <div class="root"> + <div class="controls"> + <div class="container"> + <iframe src="about:blank"></iframe> + </div> + </div> + + <div class="video-container"> + <video id="video-transparent-background" src="test-video.mp4" loop="true"></video> + </div> + </div> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html new file mode 100644 index 0000000000..8f0f76311b --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html @@ -0,0 +1,46 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture test - transparent overlays - 1</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + video { + display: block; + } + + .container { + position: relative; + display: inline-block; + } + + .overlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + color: white; + } + + .transparent-background { + background-color: transparent; + } + + .alpha-background { + background-color: rgba(255, 0, 0, 0.5); + } +</style> +<body> + <div class="container"> + <div class="overlay transparent-background">This is a fully transparent overlay</div> + <video id="video-transparent-background" src="test-video.mp4" loop="true"></video> + </div> + + <div class="container"> + <div class="overlay alpha-background">This is a partially transparent overlay using alpha</div> + <video id="video-alpha-background" src="test-video.mp4" loop="true"></video> + </div> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html new file mode 100644 index 0000000000..86dab15690 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html @@ -0,0 +1,46 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture test - transparent overlays - 1</title> + <script type="text/javascript" src="click-event-helper.js"></script> +</head> +<style> + video { + display: block; + } + + .container { + position: relative; + display: inline-block; + } + + .overlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + color: white; + } + + .zero-opacity { + opacity: 0; + } + + .partial-opacity { + opacity: 0.5; + } +</style> +<body> + <div class="container"> + <div class="overlay zero-opacity">This is a transparent overlay using opacity: 0</div> + <video id="video-zero-opacity" src="test-video.mp4" loop="true"></video> + </div> + + <div class="container"> + <div class="overlay partial-opacity">This is a partially transparent overlay using opacity: 0.5</div> + <video id="video-partial-opacity" src="test-video.mp4" loop="true"></video> + </div> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-video-cropped.mp4 b/toolkit/components/pictureinpicture/tests/test-video-cropped.mp4 Binary files differnew file mode 100644 index 0000000000..6ea66eb1fc --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-video-cropped.mp4 diff --git a/toolkit/components/pictureinpicture/tests/test-video-long.mp4 b/toolkit/components/pictureinpicture/tests/test-video-long.mp4 Binary files differnew file mode 100644 index 0000000000..714c17ca12 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-video-long.mp4 diff --git a/toolkit/components/pictureinpicture/tests/test-video-selection.html b/toolkit/components/pictureinpicture/tests/test-video-selection.html new file mode 100644 index 0000000000..4ce65d6309 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-video-selection.html @@ -0,0 +1,22 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture tests</title> + <script type="text/javascript"></script> +</head> +<style> + video { + display: block; + border: 1px solid black; + } +</style> +<body> + <h1>Shortest Video</h1> + <video id="shortest" src="test-video.mp4" controls loop="true"></video> + <h1>Shorter Video</h1> + <video id="short" src="short.mp4" controls loop="true"></video> + <h1>Longer Video</h1> + <video id="long" src="test-video-long.mp4" controls loop="true"></video> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-video-vertical.mp4 b/toolkit/components/pictureinpicture/tests/test-video-vertical.mp4 Binary files differnew file mode 100644 index 0000000000..404eec6fd0 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-video-vertical.mp4 diff --git a/toolkit/components/pictureinpicture/tests/test-video.mp4 b/toolkit/components/pictureinpicture/tests/test-video.mp4 Binary files differnew file mode 100644 index 0000000000..90bbe6bc26 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-video.mp4 |