diff options
Diffstat (limited to 'toolkit/components/pictureinpicture')
117 files changed, 15556 insertions, 0 deletions
diff --git a/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs new file mode 100644 index 0000000000..2356548bf8 --- /dev/null +++ b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs @@ -0,0 +1,1647 @@ +/* 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/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { ShortcutUtils } from "resource://gre/modules/ShortcutUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyServiceGetters(lazy, { + WindowsUIUtils: ["@mozilla.org/windows-ui-utils;1", "nsIWindowsUIUtils"], +}); + +ChromeUtils.defineESModuleGetters(lazy, { + ASRouter: + // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit + "resource:///modules/asrouter/ASRouter.sys.mjs", + PageActions: "resource:///modules/PageActions.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +import { Rect, Point } from "resource://gre/modules/Geometry.sys.mjs"; + +const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml"; +// Currently, we need titlebar="yes" on macOS in order for the player window +// to be resizable. See bug 1824171. +const TITLEBAR = AppConstants.platform == "macosx" ? "yes" : "no"; +const PLAYER_FEATURES = `chrome,alwaysontop,lockaspectratio,resizable,dialog,titlebar=${TITLEBAR}`; + +const WINDOW_TYPE = "Toolkit:PictureInPicture"; +const TOGGLE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.enabled"; +const TOGGLE_FIRST_SEEN_PREF = + "media.videocontrols.picture-in-picture.video-toggle.first-seen-secs"; +const TOGGLE_HAS_USED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; +const TOGGLE_POSITION_PREF = + "media.videocontrols.picture-in-picture.video-toggle.position"; +const TOGGLE_POSITION_RIGHT = "right"; +const TOGGLE_POSITION_LEFT = "left"; +const RESIZE_MARGIN_PX = 16; +const BACKGROUND_DURATION_HISTOGRAM_ID = + "FX_PICTURE_IN_PICTURE_BACKGROUND_TAB_PLAYING_DURATION"; +const FOREGROUND_DURATION_HISTOGRAM_ID = + "FX_PICTURE_IN_PICTURE_FOREGROUND_TAB_PLAYING_DURATION"; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "PIP_ENABLED", + "media.videocontrols.picture-in-picture.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "PIP_URLBAR_BUTTON", + "media.videocontrols.picture-in-picture.urlbar-button.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "RESPECT_PIP_DISABLED", + "media.videocontrols.picture-in-picture.respect-disablePictureInPicture", + true +); + +/** + * 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; + +export class PictureInPictureLauncherParent extends JSWindowActorParent { + receiveMessage(aMessage) { + switch (aMessage.name) { + case "PictureInPicture:Request": { + let videoData = aMessage.data; + PictureInPicture.handlePictureInPictureRequest(this.manager, videoData); + break; + } + } + } +} + +export 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; + } + case "PictureInPicture:UpdateEligiblePipVideoCount": { + let { pipCount, pipDisabledCount } = aMessage.data; + PictureInPicture.updateEligiblePipVideoCount(browsingContext, { + pipCount, + pipDisabledCount, + }); + PictureInPicture.updateUrlbarToggle(browser); + break; + } + case "PictureInPicture:SetFirstSeen": { + let { dateSeconds } = aMessage.data; + PictureInPicture.setFirstSeen(dateSeconds); + break; + } + case "PictureInPicture:SetHasUsed": { + let { hasUsed } = aMessage.data; + PictureInPicture.setHasUsed(hasUsed); + break; + } + } + } +} + +/** + * This module is responsible for creating a Picture in Picture window to host + * a clone of a video element running in web content. + */ +export 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; + PictureInPicture.closeSinglePipWindow({ reason, actorRef: this }); + 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; + } + case "PictureInPicture:EnableSubtitlesButton": { + let player = PictureInPicture.getWeakPipPlayer(this); + if (player) { + player.enableSubtitlesButton(); + } + break; + } + case "PictureInPicture:DisableSubtitlesButton": { + let player = PictureInPicture.getWeakPipPlayer(this); + if (player) { + player.disableSubtitlesButton(); + } + break; + } + case "PictureInPicture:SetTimestampAndScrubberPosition": { + let { timestamp, scrubberPosition } = aMessage.data; + let player = PictureInPicture.getWeakPipPlayer(this); + player.setTimestamp(timestamp); + player.setScrubberPosition(scrubberPosition); + break; + } + case "PictureInPicture:VolumeChange": { + let { volume } = aMessage.data; + let player = PictureInPicture.getWeakPipPlayer(this); + player.setVolume(volume); + break; + } + } + } +} + +/** + * This module is responsible for creating a Picture in Picture window to host + * a clone of a video element running in web content. + */ +export 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(), + + // Maps a browser to the number of PiP windows it has + browserWeakMap: new WeakMap(), + + // Maps an AppWindow to the number of PiP windows it has + originatingWinWeakMap: new WeakMap(), + + // Maps a WindowGlobal to count of eligible PiP videos + weakGlobalToEligiblePipCount: new WeakMap(), + + /** + * Returns the player window if one exists and if it hasn't yet been closed. + * + * @param {PictureInPictureParent} pipActorRef + * Reference to the calling PictureInPictureParent actor + * + * @returns {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; + }, + + /** + * Get the PiP panel for a browser. Create the panel if needed. + * @param {Browser} browser The current browser + * @returns panel The panel element + */ + getPanelForBrowser(browser) { + let panel = browser.ownerDocument.querySelector("#PictureInPicturePanel"); + + if (!panel) { + browser.ownerGlobal.ensureCustomElements("moz-toggle"); + browser.ownerGlobal.ensureCustomElements("moz-support-link"); + let template = browser.ownerDocument.querySelector( + "#PictureInPicturePanelTemplate" + ); + let clone = template.content.cloneNode(true); + template.replaceWith(clone); + + panel = this.getPanelForBrowser(browser); + } + return panel; + }, + + handleEvent(event) { + switch (event.type) { + case "TabSwapPictureInPicture": { + this.onPipSwappedBrowsers(event); + break; + } + case "TabSelect": { + this.updatePlayingDurationHistograms(); + break; + } + } + }, + + /** + * Increase the count of PiP windows for a given browser + * @param browser The browser to increase PiP count in browserWeakMap + */ + addPiPBrowserToWeakMap(browser) { + let count = this.browserWeakMap.has(browser) + ? this.browserWeakMap.get(browser) + : 0; + this.browserWeakMap.set(browser, count + 1); + + // If a browser is being added to the browserWeakMap, that means its + // probably a good time to make sure the playing duration histograms + // are up-to-date, as it means that we've either opened a new PiP + // player window, or moved the originating tab to another window. + this.updatePlayingDurationHistograms(); + }, + + /** + * Increase the count of PiP windows for a given AppWindow. + * + * @param {Browser} browser + * The content browser that the originating video lives in and from which + * we'll read its parent window to increase PiP window count in originatingWinWeakMap. + */ + addOriginatingWinToWeakMap(browser) { + let parentWin = browser.ownerGlobal; + let count = this.originatingWinWeakMap.get(parentWin); + if (!count || count == 0) { + this.setOriginatingWindowActive(parentWin.browsingContext, true); + this.originatingWinWeakMap.set(parentWin, 1); + + let gBrowser = browser.getTabBrowser(); + if (gBrowser) { + gBrowser.tabContainer.addEventListener("TabSelect", this); + } + } else { + this.originatingWinWeakMap.set(parentWin, count + 1); + } + }, + + /** + * Decrease the count of PiP windows for a given browser. + * If the count becomes 0, we will remove the browser from the WeakMap + * @param browser The browser to decrease PiP count in browserWeakMap + */ + removePiPBrowserFromWeakMap(browser) { + let count = this.browserWeakMap.get(browser); + if (count <= 1) { + this.browserWeakMap.delete(browser); + let tabbrowser = browser.getTabBrowser(); + if (tabbrowser && !tabbrowser.shouldActivateDocShell(browser)) { + browser.docShellIsActive = false; + } + } else { + this.browserWeakMap.set(browser, count - 1); + } + }, + + /** + * Decrease the count of PiP windows for a given AppWindow. + * If the count becomes 0, we will remove the AppWindow from the WeakMap. + * + * @param {Browser} browser + * The content browser that the originating video lives in and from which + * we'll read its parent window to decrease PiP window count in originatingWinWeakMap. + */ + removeOriginatingWinFromWeakMap(browser) { + let parentWin = browser?.ownerGlobal; + + if (!parentWin) { + return; + } + + let count = this.originatingWinWeakMap.get(parentWin); + if (!count || count <= 1) { + this.originatingWinWeakMap.delete(parentWin, 0); + this.setOriginatingWindowActive(parentWin.browsingContext, false); + + let gBrowser = browser.getTabBrowser(); + if (gBrowser) { + gBrowser.tabContainer.removeEventListener("TabSelect", this); + } + } else { + this.originatingWinWeakMap.set(parentWin, count - 1); + } + }, + + onPipSwappedBrowsers(event) { + let otherTab = event.detail; + if (otherTab) { + for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) { + if (this.weakWinToBrowser.get(win) === event.target.linkedBrowser) { + this.weakWinToBrowser.set(win, otherTab.linkedBrowser); + this.removePiPBrowserFromWeakMap(event.target.linkedBrowser); + this.removeOriginatingWinFromWeakMap(event.target.linkedBrowser); + this.addPiPBrowserToWeakMap(otherTab.linkedBrowser); + this.addOriginatingWinToWeakMap(otherTab.linkedBrowser); + } + } + otherTab.addEventListener("TabSwapPictureInPicture", this); + } + }, + + updatePlayingDurationHistograms() { + // A tab switch occurred in a browser window with one more tabs that have + // PiP player windows associated with them. + for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) { + let browser = this.weakWinToBrowser.get(win); + let gBrowser = browser.getTabBrowser(); + if (gBrowser?.selectedBrowser == browser) { + // If there are any background stopwatches running for this window, finish + // them and switch to foreground. + if (TelemetryStopwatch.running(BACKGROUND_DURATION_HISTOGRAM_ID, win)) { + TelemetryStopwatch.finish(BACKGROUND_DURATION_HISTOGRAM_ID, win); + } + if ( + !TelemetryStopwatch.running(FOREGROUND_DURATION_HISTOGRAM_ID, win) + ) { + TelemetryStopwatch.start(FOREGROUND_DURATION_HISTOGRAM_ID, win, { + inSeconds: true, + }); + } + } else { + // If there are any foreground stopwatches running for this window, finish + // them and switch to background. + if (TelemetryStopwatch.running(FOREGROUND_DURATION_HISTOGRAM_ID, win)) { + TelemetryStopwatch.finish(FOREGROUND_DURATION_HISTOGRAM_ID, win); + } + + if ( + !TelemetryStopwatch.running(BACKGROUND_DURATION_HISTOGRAM_ID, win) + ) { + TelemetryStopwatch.start(BACKGROUND_DURATION_HISTOGRAM_ID, win, { + inSeconds: true, + }); + } + } + } + }, + + /** + * Called when the browser UI handles the View:PictureInPicture command via + * the keyboard. + * + * @param {Event} event + */ + onCommand(event) { + if (!lazy.PIP_ENABLED) { + return; + } + + let win = event.target.ownerGlobal; + let bc = Services.focus.focusedContentBrowsingContext; + if (bc.top == win.gBrowser.selectedBrowser.browsingContext) { + let actor = bc.currentWindowGlobal.getActor("PictureInPictureLauncher"); + actor.sendAsyncMessage("PictureInPicture:KeyToggle"); + } + }, + + async focusTabAndClosePip(window, pipActor) { + let browser = this.weakWinToBrowser.get(window); + if (!browser) { + return; + } + + let gBrowser = browser.getTabBrowser(); + let tab = gBrowser.getTabForBrowser(browser); + + // focus the tab's window + tab.ownerGlobal.focus(); + + gBrowser.selectedTab = tab; + await this.closeSinglePipWindow({ reason: "unpip", actorRef: pipActor }); + }, + + /** + * Update the respect PiPDisabled pref value when the toggle is clicked. + * @param {Event} event The event from toggling the respect + * PiPDisabled in the PiP panel + */ + toggleRespectDisablePip(event) { + let toggle = event.target; + let respectPipDisabled = !toggle.pressed; + + Services.prefs.setBoolPref( + "media.videocontrols.picture-in-picture.respect-disablePictureInPicture", + respectPipDisabled + ); + + Services.telemetry.recordEvent( + "pictureinpicture", + "disrespect_disable", + "urlBar" + ); + }, + + /** + * Updates the PiP count and PiPDisabled count of eligible PiP videos for a + * respective WindowGlobal. + * @param {BrowsingContext} browsingContext The BrowsingContext with eligible videos + * @param {Object} object + * pipCount: The number of eligible videos for the respective WindowGlobal + * pipDisabledCount: The number of disablePiP videos for the respective WindowGlobal + */ + updateEligiblePipVideoCount(browsingContext, object) { + let windowGlobal = browsingContext.currentWindowGlobal; + + if (windowGlobal) { + this.weakGlobalToEligiblePipCount.set(windowGlobal, object); + } + }, + + /** + * A generator function that yeilds a WindowGlobal, it's respective PiP + * count, and if any of the videos have PiPDisabled set. + * @param {Browser} browser The selected browser + */ + *windowGlobalPipCountGenerator(browser) { + let contextsToVisit = [browser.browsingContext]; + while (contextsToVisit.length) { + let currentBC = contextsToVisit.pop(); + let windowGlobal = currentBC.currentWindowGlobal; + + if (!windowGlobal) { + continue; + } + + let { pipCount, pipDisabledCount } = + this.weakGlobalToEligiblePipCount.get(windowGlobal) || { + pipCount: 0, + pipDisabledCount: 0, + }; + + contextsToVisit.push(...currentBC.children); + + yield { windowGlobal, pipCount, pipDisabledCount }; + } + }, + + /** + * Gets the total eligible video count and total PiPDisabled count for a + * given browser. + * @param {Browser} browser The selected browser + * @returns Total count of eligible PiP videos for the selected broser + */ + getEligiblePipVideoCount(browser) { + let totalPipCount = 0; + let totalPipDisabled = 0; + + for (let { + pipCount, + pipDisabledCount, + } of this.windowGlobalPipCountGenerator(browser)) { + totalPipCount += pipCount; + totalPipDisabled += pipDisabledCount; + } + + return { totalPipCount, totalPipDisabled }; + }, + + /** + * This function updates the hover text on the urlbar PiP button when we enter or exit PiP + * @param {Document} document The window document + * @param {Element} pipToggle The urlbar PiP button + * @param {String} dataL10nId The data l10n id of the string we want to show + */ + updateUrlbarHoverText(document, pipToggle, dataL10nId) { + let shortcut = document.getElementById("key_togglePictureInPicture"); + + document.l10n.setAttributes(pipToggle, dataL10nId, { + shortcut: ShortcutUtils.prettifyShortcut(shortcut), + }); + }, + + /** + * Toggles the visibility of the PiP urlbar button. If the total video count + * is 1, then we will show the button. If any eligible video has PiPDisabled, + * then the button will show. Otherwise the button is hidden. + * @param {Browser} browser The selected browser + */ + updateUrlbarToggle(browser) { + if (!lazy.PIP_ENABLED || !lazy.PIP_URLBAR_BUTTON) { + return; + } + + let win = browser.ownerGlobal; + if (win.closed || win.gBrowser?.selectedBrowser !== browser) { + return; + } + + let { totalPipCount, totalPipDisabled } = + this.getEligiblePipVideoCount(browser); + + let pipToggle = win.document.getElementById("picture-in-picture-button"); + if ( + totalPipCount === 1 || + (totalPipDisabled > 0 && lazy.RESPECT_PIP_DISABLED) + ) { + pipToggle.hidden = false; + lazy.PageActions.sendPlacedInUrlbarTrigger(pipToggle); + } else { + pipToggle.hidden = true; + } + + let browserHasPip = !!this.browserWeakMap.get(browser); + if (browserHasPip) { + this.setUrlbarPipIconActive(browser.ownerGlobal); + } else { + this.setUrlbarPipIconInactive(browser.ownerGlobal); + } + }, + + /** + * Open the PiP panel if any video has PiPDisabled, otherwise finds the + * correct WindowGlobal to open the eligible PiP video. + * @param {Event} event Event from clicking the PiP urlbar button + */ + toggleUrlbar(event) { + if (event.button !== 0) { + return; + } + + let win = event.target.ownerGlobal; + let browser = win.gBrowser.selectedBrowser; + + let pipPanel = this.getPanelForBrowser(browser); + + for (let { + windowGlobal, + pipCount, + pipDisabledCount, + } of this.windowGlobalPipCountGenerator(browser)) { + if ( + (pipDisabledCount > 0 && lazy.RESPECT_PIP_DISABLED) || + (pipPanel && pipPanel.state !== "closed") + ) { + this.togglePipPanel(browser); + return; + } else if (pipCount === 1) { + let eventExtraKeys = {}; + if ( + !Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF) && + lazy.ASRouter.initialized + ) { + let { messages, messageImpressions } = lazy.ASRouter.state; + let pipCallouts = messages.filter( + message => + message.template === "feature_callout" && + message.content.screens.some(screen => + screen.anchors.some(anchor => + anchor.selector.includes("picture-in-picture-button") + ) + ) + ); + if (pipCallouts.length) { + // Has one of the callouts been seen in the last 48 hours? + let now = Date.now(); + let callout = pipCallouts.some(message => + messageImpressions[message.id]?.some( + impression => now - impression < 48 * 60 * 60 * 1000 + ) + ); + if (callout) { + eventExtraKeys.callout = "true"; + } + } + } + let actor = windowGlobal.getActor("PictureInPictureToggle"); + actor.sendAsyncMessage("PictureInPicture:UrlbarToggle", eventExtraKeys); + return; + } + } + }, + + /** + * Set the toggle for PiPDisabled when the panel is shown. + * If the pref is set from about:config, we need to update + * the toggle switch in the panel to match the pref. + * @param {Event} event The panel shown event + */ + onPipPanelShown(event) { + let toggle = event.target.querySelector("#respect-pipDisabled-switch"); + toggle.pressed = !lazy.RESPECT_PIP_DISABLED; + }, + + /** + * Update the visibility of the urlbar PiP button when the panel is hidden. + * The button will show when there is more than 1 video and at least 1 video + * has PiPDisabled. If we no longer want to respect PiPDisabled then we + * need to check if the urlbar button should still be visible. + * @param {Event} event The panel hidden event + */ + onPipPanelHidden(event) { + this.updateUrlbarToggle(event.view.gBrowser.selectedBrowser); + }, + + /** + * Create the PiP panel if needed and toggle the display of the panel + * @param {Browser} browser The current browser + */ + togglePipPanel(browser) { + let pipPanel = this.getPanelForBrowser(browser); + + if (pipPanel.state === "closed") { + let anchor = browser.ownerDocument.querySelector( + "#picture-in-picture-button" + ); + + pipPanel.openPopup(anchor, "bottomright topright"); + Services.telemetry.recordEvent( + "pictureinpicture", + "opened_method", + "urlBar", + null, + { disableDialog: "true" } + ); + } else { + pipPanel.hidePopup(); + } + }, + + /** + * Sets the PiP urlbar to an active state. This changes the icon in the + * urlbar button to the unpip icon. + * @param {Window} win The current Window + */ + setUrlbarPipIconActive(win) { + let pipToggle = win.document.getElementById("picture-in-picture-button"); + pipToggle.toggleAttribute("pipactive", true); + + this.updateUrlbarHoverText( + win.document, + pipToggle, + "picture-in-picture-urlbar-button-close" + ); + }, + + /** + * Sets the PiP urlbar to an inactive state. This changes the icon in the + * urlbar button to the open pip icon. + * @param {Window} win The current window + */ + setUrlbarPipIconInactive(win) { + if (!win) { + return; + } + let pipToggle = win.document.getElementById("picture-in-picture-button"); + pipToggle.toggleAttribute("pipactive", false); + + this.updateUrlbarHoverText( + win.document, + pipToggle, + "picture-in-picture-urlbar-button-open" + ); + }, + + /** + * 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.getTabBrowser(); + let tab = gBrowser?.getTabForBrowser(browser); + if (tab) { + tab.removeAttribute("pictureinpicture"); + } + }, + + /** + * Closes and waits for passed PiP player window to finish closing. + * + * @param {Window} pipWin + * 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; + } + this.removePiPBrowserFromWeakMap(this.weakWinToBrowser.get(win)); + + Services.telemetry.recordEvent( + "pictureinpicture", + "closed_method", + reason, + null + ); + await this.closePipWindow(win); + }, + + /** + * A request has come up from content to open a Picture in Picture + * window. + * + * @param {WindowGlobalParent} wgps + * The WindowGlobalParent that is requesting the Picture in Picture + * window. + * + * @param {object} videoData + * 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) { + 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); + + this.setUrlbarPipIconActive(parentWin); + + tab.addEventListener("TabSwapPictureInPicture", this); + + let pipId = gNextWindowID.toString(); + win.setupPlayer(pipId, wgp, videoData.videoRef); + gNextWindowID++; + + this.weakWinToBrowser.set(win, browser); + this.addPiPBrowserToWeakMap(browser); + this.addOriginatingWinToWeakMap(browser); + + win.setScrubberPosition(videoData.scrubberPosition); + win.setTimestamp(videoData.timestamp); + win.setVolume(videoData.volume); + + Services.prefs.setBoolPref(TOGGLE_HAS_USED_PREF, true); + + let args = { + width: win.innerWidth.toString(), + height: win.innerHeight.toString(), + screenX: win.screenX.toString(), + screenY: win.screenY.toString(), + ccEnabled: videoData.ccEnabled.toString(), + webVTTSubtitles: videoData.webVTTSubtitles.toString(), + }; + + Services.telemetry.recordEvent( + "pictureinpicture", + "create", + "player", + pipId, + args + ); + }, + + /** + * Calls the browsingContext's `forceAppWindowActive` flag to determine if the + * the top level chrome browsingContext should be forcefully set as active or not. + * When the originating window's browsing context is set to active, captions on the + * PiP window are properly updated. Forcing active while a PiP window is open ensures + * that captions are still updated when the originating window is occluded. + * + * @param {BrowsingContext} browsingContext + * The browsing context of the originating window + * @param {boolean} isActive + * True to force originating window as active, or false to not enforce it + * @see CanonicalBrowsingContext + */ + setOriginatingWindowActive(browsingContext, isActive) { + browsingContext.forceAppWindowActive = isActive; + }, + + /** + * unload event has been called in player.js, cleanup our preserved + * browser object. + * + * @param {Window} window + */ + unload(window) { + TelemetryStopwatch.finish( + "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION", + window + ); + + if (TelemetryStopwatch.running(BACKGROUND_DURATION_HISTOGRAM_ID, window)) { + TelemetryStopwatch.finish(BACKGROUND_DURATION_HISTOGRAM_ID, window); + } else if ( + TelemetryStopwatch.running(FOREGROUND_DURATION_HISTOGRAM_ID, window) + ) { + TelemetryStopwatch.finish(FOREGROUND_DURATION_HISTOGRAM_ID, window); + } + + let browser = this.weakWinToBrowser.get(window); + this.removeOriginatingWinFromWeakMap(browser); + + gCurrentPlayerCount -= 1; + // Saves the location of the Picture in Picture window + this.savePosition(window); + this.clearPipTabIcon(window); + this.setUrlbarPipIconInactive(browser?.ownerGlobal); + }, + + /** + * Open a Picture in Picture window on the same screen as parentWin, + * sized based on the information in videoData. + * + * @param {ChromeWindow} parentWin + * The window hosting the browser that requested the Picture in + * Picture window. + * + * @param {object} videoData + * An object containing the following properties: + * + * videoHeight (int): + * The preferred height of the video. + * + * videoWidth (int): + * The preferred width of the video. + * + * @param {PictureInPictureParent} actorReference + * 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 { left: resolvedLeft, top: resolvedTop } = this.resolveOverlapConflicts( + left, + top, + width, + height + ); + + top = Math.round(resolvedTop); + left = Math.round(resolvedLeft); + width = Math.round(width); + height = Math.round(height); + + let features = + `${PLAYER_FEATURES},top=${top},left=${left},outerWidth=${width},` + + `outerHeight=${height}`; + let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(parentWin); + + if (isPrivate) { + features += ",private"; + } + + let pipWindow = Services.ww.openWindow( + parentWin, + PLAYER_URI, + null, + features, + null + ); + + TelemetryStopwatch.start( + "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION", + pipWindow, + { + inSeconds: true, + } + ); + + pipWindow.windowUtils.setResizeMargin(RESIZE_MARGIN_PX); + + // If the window is Private the icon will have already been set when + // it was opened. + if (Services.appinfo.OS == "WINNT" && !isPrivate) { + lazy.WindowsUIUtils.setWindowIconNoData(pipWindow); + } + + 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 {ChromeWindow|PlayerWindow} requestingWin + * 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 {object} videoData + * 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, in CSS pixels relative to + * requestingWin. + * + * 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 requestingCssToDesktopScale = + requestingWin.devicePixelRatio / requestingWin.desktopToDeviceScale; + + let top, left, width, height; + if (!isPlayer) { + // requestingWin is a content window, load last PiP's dimensions + ({ top, left, width, height } = this.loadPosition()); + } else if (requestingWin.windowState === requestingWin.STATE_FULLSCREEN) { + // `requestingWin` is a PiP window and in fullscreen. We stored the size + // and position before entering fullscreen and we will use that to + // calculate the new position + ({ top, left, width, height } = requestingWin.getDeferredResize()); + left *= requestingCssToDesktopScale; + top *= requestingCssToDesktopScale; + } else { + // requestingWin is a PiP player, conserve its dimensions in this case + left = requestingWin.screenX * requestingCssToDesktopScale; + top = requestingWin.screenY * requestingCssToDesktopScale; + width = requestingWin.outerWidth; + height = requestingWin.outerHeight; + } + + // Check that previous location and size were loaded. + // Note that at this point left and top are in desktop pixels, while width + // and height are in CSS pixels. + if (!isNaN(top) && !isNaN(left) && !isNaN(width) && !isNaN(height)) { + // Get the screen of the last PiP window. PiP screen will be the default + // screen if the point was not on a screen. + let PiPScreen = this.getWorkingScreen(left, top); + + // Center position of PiP window. + let PipScreenCssToDesktopScale = + PiPScreen.defaultCSSScaleFactor / PiPScreen.contentsScaleFactor; + let centerX = left + (width * PipScreenCssToDesktopScale) / 2; + let centerY = top + (height * PipScreenCssToDesktopScale) / 2; + + // 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 oldWidthDesktopPix = width * PipScreenCssToDesktopScale; + + // 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; + } + + let widthDesktopPix = width * PipScreenCssToDesktopScale; + let heightDesktopPix = height * PipScreenCssToDesktopScale; + + // 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 + widthDesktopPix); + if ( + 0 < distFromRight && + distFromRight <= WIGGLE_ROOM + (oldWidthDesktopPix - widthDesktopPix) + ) { + 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; + } + if (top < PiPScreenTop) { + // off the top of the screen + // slide down + top = PiPScreenTop; + } + if (left + widthDesktopPix > PiPScreenLeft + PiPScreenWidth) { + // off the right of the screen + // slide left + left = PiPScreenLeft + PiPScreenWidth - widthDesktopPix; + } + if (top + heightDesktopPix > PiPScreenTop + PiPScreenHeight) { + // off the bottom of the screen + // slide up + top = PiPScreenTop + PiPScreenHeight - heightDesktopPix; + } + // Convert top / left from desktop to requestingWin-relative CSS pixels. + top /= requestingCssToDesktopScale; + left /= requestingCssToDesktopScale; + 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 * requestingCssToDesktopScale, + requestingWin.screenY * requestingCssToDesktopScale, + requestingWin.outerWidth * requestingCssToDesktopScale, + requestingWin.outerHeight * requestingCssToDesktopScale + ); + let [screenLeft, screenTop, screenWidth, screenHeight] = + this.getAvailScreenSize(screen); + + let screenCssToDesktopScale = + screen.defaultCSSScaleFactor / screen.contentsScaleFactor; + + // 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 * screenCssToDesktopScale; + height = videoHeight * screenCssToDesktopScale; + 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; + + // Convert top/left from desktop pixels to requestingWin-relative CSS + // pixels, and width / height to the target screen's CSS pixels, which is + // what we've made the size calculation against. + top /= requestingCssToDesktopScale; + left /= requestingCssToDesktopScale; + width /= screenCssToDesktopScale; + height /= screenCssToDesktopScale; + + return { top, left, width, height }; + }, + + /** + * This function will take the size and potential location of a new + * Picture-in-Picture player window, and try to return the location + * coordinates that will best ensure that the player window will not overlap + * with other pre-existing player windows. + * + * @param {int} left + * x position of left edge for Picture-in-Picture window that is being + * opened + * @param {int} top + * y position of top edge for Picture-in-Picture window that is being + * opened + * @param {int} width + * Width of Picture-in-Picture window that is being opened + * @param {int} height + * Height of Picture-in-Picture window that is being opened + * + * @returns {object} + * An object with the following properties: + * + * top (int): + * The recommended top position for the player window. + * + * left (int): + * The recommended left position for the player window. + */ + resolveOverlapConflicts(left, top, width, height) { + // This algorithm works by first identifying the possible candidate + // locations that the new player window could be placed without overlapping + // other player windows (assuming that a confict is discovered at all of + // course). The optimal candidate is then selected by its distance to the + // original conflict, shorter distances are better. + // + // Candidates are discovered by iterating over each of the sides of every + // pre-existing player window. One candidate is collected for each side. + // This is done to ensure that the new player window will be opened to + // tightly fit along the edge of another player window. + // + // These candidates are then pruned for candidates that will introduce + // further conflicts. Finally the ideal candidate is selected from this + // pool of remaining candidates, optimized for minimizing distance to + // the original conflict. + let playerRects = []; + + for (let playerWin of Services.wm.getEnumerator(WINDOW_TYPE)) { + playerRects.push( + new Rect( + playerWin.screenX, + playerWin.screenY, + playerWin.outerWidth, + playerWin.outerHeight + ) + ); + } + + const newPlayerRect = new Rect(left, top, width, height); + let conflictingPipRect = playerRects.find(rect => + rect.intersects(newPlayerRect) + ); + + if (!conflictingPipRect) { + // no conflicts found + return { left, top }; + } + + const conflictLoc = conflictingPipRect.center(); + + // Will try to resolve a better placement only on the screen where + // the conflict occurred + const conflictScreen = this.getWorkingScreen(conflictLoc.x, conflictLoc.y); + + const [screenTop, screenLeft, screenWidth, screenHeight] = + this.getAvailScreenSize(conflictScreen); + + const screenRect = new Rect( + screenTop, + screenLeft, + screenWidth, + screenHeight + ); + + const getEdgeCandidates = rect => { + return [ + // left edge's candidate + new Point(rect.left - newPlayerRect.width, rect.top), + // top edge's candidate + new Point(rect.left, rect.top - newPlayerRect.height), + // right edge's candidate + new Point(rect.right + newPlayerRect.width, rect.top), + // bottom edge's candidate + new Point(rect.left, rect.bottom), + ]; + }; + + let candidateLocations = []; + for (const playerRect of playerRects) { + for (let candidateLoc of getEdgeCandidates(playerRect)) { + const candidateRect = new Rect( + candidateLoc.x, + candidateLoc.y, + width, + height + ); + + if (!screenRect.contains(candidateRect)) { + continue; + } + + // test that no PiPs conflict with this candidate box + if (playerRects.some(rect => rect.intersects(candidateRect))) { + continue; + } + + const candidateCenter = candidateRect.center(); + const candidateDistanceToConflict = + Math.abs(conflictLoc.x - candidateCenter.x) + + Math.abs(conflictLoc.y - candidateCenter.y); + + candidateLocations.push({ + distanceToConflict: candidateDistanceToConflict, + location: candidateLoc, + }); + } + } + + if (!candidateLocations.length) { + // if no suitable candidates can be found, return the original location + return { left, top }; + } + + // sort candidates by distance to the conflict, select the closest + const closestCandidate = candidateLocations.sort( + (firstCand, secondCand) => + firstCand.distanceToConflict - secondCand.distanceToConflict + )[0]; + + if (!closestCandidate) { + // can occur if there were no valid candidates, return original location + return { left, top }; + } + + const resolvedX = closestCandidate.location.x; + const resolvedY = closestCandidate.location.y; + + return { left: resolvedX, top: resolvedY }; + }, + + /** + * Resizes the the PictureInPicture player window. + * + * @param {object} videoData + * The source video's data. + * @param {PictureInPictureParent} actorRef + * Reference to the PictureInPicture parent actor. + */ + resizePictureInPictureWindow(videoData, actorRef) { + let win = this.getWeakPipPlayer(actorRef); + + if (!win) { + return; + } + + win.resizeToVideo(this.fitToScreen(win, videoData)); + }, + + /** + * Opens the context menu for toggling PictureInPicture. + * + * @param {Window} window + * @param {object} data + * Message data from the PictureInPictureToggleParent + */ + openToggleContextMenu(window, data) { + let document = window.document; + let popup = document.getElementById("pictureInPictureToggleContextMenu"); + let contextMoveToggle = document.getElementById( + "context_MovePictureInPictureToggle" + ); + + // Set directional string for toggle position + let position = Services.prefs.getStringPref( + TOGGLE_POSITION_PREF, + TOGGLE_POSITION_RIGHT + ); + switch (position) { + case TOGGLE_POSITION_RIGHT: + document.l10n.setAttributes( + contextMoveToggle, + "picture-in-picture-move-toggle-left" + ); + break; + case TOGGLE_POSITION_LEFT: + document.l10n.setAttributes( + contextMoveToggle, + "picture-in-picture-move-toggle-right" + ); + break; + } + + // We synthesize a new MouseEvent to propagate the inputSource to the + // subsequently triggered popupshowing event. + let newEvent = document.createEvent("MouseEvent"); + let screenX = data.screenXDevPx / window.devicePixelRatio; + let screenY = data.screenYDevPx / window.devicePixelRatio; + newEvent.initNSMouseEvent( + "contextmenu", + true, + true, + null, + 0, + screenX, + screenY, + 0, + 0, + false, + false, + false, + false, + 0, + null, + 0, + data.inputSource + ); + popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent); + }, + + hideToggle() { + Services.prefs.setBoolPref(TOGGLE_ENABLED_PREF, false); + Services.telemetry.recordEvent( + "pictureinpicture.settings", + "disable", + "player" + ); + }, + + /** + * This is used in AsyncTabSwitcher.sys.mjs and tabbrowser.js to check if the browser + * currently has a PiP window. + * If the browser has a PiP window we want to keep the browser in an active state because + * the browser is still partially visible. + * @param browser The browser to check if it has a PiP window + * @returns true if browser has PiP window else false + */ + isOriginatingBrowser(browser) { + return this.browserWeakMap.has(browser); + }, + + moveToggle() { + // Get the current position + let position = Services.prefs.getStringPref( + TOGGLE_POSITION_PREF, + TOGGLE_POSITION_RIGHT + ); + let newPosition = ""; + // Determine what the opposite position would be for that preference + switch (position) { + case TOGGLE_POSITION_RIGHT: + newPosition = TOGGLE_POSITION_LEFT; + break; + case TOGGLE_POSITION_LEFT: + newPosition = TOGGLE_POSITION_RIGHT; + break; + } + if (newPosition) { + Services.prefs.setStringPref(TOGGLE_POSITION_PREF, newPosition); + } + }, + + /** + * This function takes a screen and will return the left, top, width and + * height of the screen + * @param {Screen} screen + * The screen we need to get the size and coordinates of + * + * @returns {array} + * Size and location of screen in desktop pixels. + * + * 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 + ); + return [ + screenLeft.value, + screenTop.value, + screenWidth.value, + screenHeight.value, + ]; + }, + + /** + * This function takes in a rect in desktop pixels, and returns the screen it + * is located on. + * + * If the left and top are not on any screen, it will return the default + * screen. + * + * @param {int} left + * left or x coordinate + * + * @param {int} top + * top or y coordinate + * + * @param {int} width + * top or y coordinate + * + * @param {int} height + * top or y coordinate + * + * @returns {Screen} 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 + return screenManager.screenForRect(left, top, width, height); + }, + + /** + * Saves position and size of Picture-in-Picture window + * @param {Window} win The Picture-in-Picture window + */ + savePosition(win) { + let xulStore = Services.xulStore; + + // We store left / top position in desktop pixels, like SessionStore does, + // so that we can restore them properly (as CSS pixels need to be relative + // to a screen, and we won't have a target screen to restore). + let cssToDesktopScale = win.devicePixelRatio / win.desktopToDeviceScale; + + let left = win.screenX * cssToDesktopScale; + let top = win.screenY * cssToDesktopScale; + let width = win.outerWidth; + let height = win.outerHeight; + + 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 }; + }, + + setFirstSeen(dateSeconds) { + if (!dateSeconds) { + return; + } + + Services.prefs.setIntPref(TOGGLE_FIRST_SEEN_PREF, dateSeconds); + }, + + setHasUsed(hasUsed) { + Services.prefs.setBoolPref(TOGGLE_HAS_USED_PREF, !!hasUsed); + }, +}; diff --git a/toolkit/components/pictureinpicture/PictureInPictureControls.sys.mjs b/toolkit/components/pictureinpicture/PictureInPictureControls.sys.mjs new file mode 100644 index 0000000000..1f898a9078 --- /dev/null +++ b/toolkit/components/pictureinpicture/PictureInPictureControls.sys.mjs @@ -0,0 +1,40 @@ +/* -*- 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/. */ + +// These denote which keyboard controls to disable for a qualified video element. +export const KEYBOARD_CONTROLS = { + ALL: 0, + PLAY_PAUSE: 1 << 0, + MUTE_UNMUTE: 1 << 1, + VOLUME: 1 << 2, + SEEK: 1 << 3, + CLOSE: 1 << 4, + LIVE_SEEK: 1 << 5, +}; + +// These are the possible toggle positions along the right side of +// a qualified video element. +export const TOGGLE_POLICIES = { + DEFAULT: 1, + HIDDEN: 2, + TOP: 3, + ONE_QUARTER: 4, + MIDDLE: 5, + THREE_QUARTERS: 6, + BOTTOM: 7, +}; + +// These strings are used in the videocontrols.css stylesheet as +// toggle policy attribute values for setting rules on the position +// of the toggle. +export const TOGGLE_POLICY_STRINGS = { + [TOGGLE_POLICIES.DEFAULT]: "default", + [TOGGLE_POLICIES.HIDDEN]: "hidden", + [TOGGLE_POLICIES.TOP]: "top", + [TOGGLE_POLICIES.ONE_QUARTER]: "one-quarter", + [TOGGLE_POLICIES.MIDDLE]: "middle", + [TOGGLE_POLICIES.THREE_QUARTERS]: "three-quarters", + [TOGGLE_POLICIES.BOTTOM]: "bottom", +}; diff --git a/toolkit/components/pictureinpicture/content/pictureInPicturePanel.inc.xhtml b/toolkit/components/pictureinpicture/content/pictureInPicturePanel.inc.xhtml new file mode 100644 index 0000000000..fb4b32a1d2 --- /dev/null +++ b/toolkit/components/pictureinpicture/content/pictureInPicturePanel.inc.xhtml @@ -0,0 +1,46 @@ +<!-- 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:template id="PictureInPicturePanelTemplate"> + <panel + id="PictureInPicturePanel" + class="panel-no-padding" + type="arrow" + orient="vertical" + level="parent" + tabspecific="true" + onpopupshown="PictureInPicture.onPipPanelShown(event);" + onpopuphidden="PictureInPicture.onPipPanelHidden(event)" + > + <box class="panel-header"> + <html:h1> + <html:span data-l10n-id="picture-in-picture-panel-header" /> + </html:h1> + </box> + <toolbarseparator /> + <vbox id="PictureInPicturePanelBody"> + <html:div data-l10n-id="picture-in-picture-panel-headline" /> + <html:div> + <html:span + class="deemphasized" + data-l10n-id="picture-in-picture-panel-body" + /> + <html:a + is="moz-support-link" + id="pip-learn-more-link" + support-page="about-picture-picture-firefox" + /> + </html:div> + </vbox> + <toolbarseparator /> + <vbox id="PictureInPicturePanelFooter"> + <html:moz-toggle + id="respect-pipDisabled-switch" + data-l10n-id="picture-in-picture-enable-toggle" + data-l10n-attrs="label" + onclick="PictureInPicture.toggleRespectDisablePip(event);" + /> + </vbox> + </panel> +</html:template> diff --git a/toolkit/components/pictureinpicture/content/player.js b/toolkit/components/pictureinpicture/content/player.js new file mode 100644 index 0000000000..c60c2ca44e --- /dev/null +++ b/toolkit/components/pictureinpicture/content/player.js @@ -0,0 +1,1407 @@ +/* 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.importESModule( + "resource://gre/modules/PictureInPicture.sys.mjs" +); +const { ShortcutUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ShortcutUtils.sys.mjs" +); +const { DeferredTask } = ChromeUtils.importESModule( + "resource://gre/modules/DeferredTask.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +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"; +const CAPTIONS_ENABLED_PREF = + "media.videocontrols.picture-in-picture.display-text-tracks.enabled"; +const CAPTIONS_TOGGLE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.display-text-tracks.toggle.enabled"; +const TEXT_TRACK_FONT_SIZE_PREF = + "media.videocontrols.picture-in-picture.display-text-tracks.size"; +const IMPROVED_CONTROLS_ENABLED_PREF = + "media.videocontrols.picture-in-picture.improved-video-controls.enabled"; +const SEETHROUGH_MODE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.seethrough-mode.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 {Number} id + * A unique numeric ID for the window, used for Telemetry Events. + * @param {WindowGlobalParent} wgp + * The WindowGlobalParent that is hosting the originating video. + * @param {ContentDOMReference} videoRef + * 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 {Boolean} isPlaying + * 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 {Boolean} isMuted + * True if the Picture-in-Picture video is muted. + */ +function setIsMutedState(isMuted) { + Player.isMuted = isMuted; +} + +/** + * Function to resize and reposition the PiP window + * @param {Object} rect + * An object containing `left`, `top`, `width`, and `height` for the PiP + * window + */ +function resizeToVideo(rect) { + Player.resizeToVideo(rect); +} + +/** + * Returns an object containing `left`, `top`, `width`, and `height` of the + * PiP window before entering fullscreen. Will be null if the PiP window is + * not in fullscreen. + */ +function getDeferredResize() { + return Player.deferredResize; +} + +function enableSubtitlesButton() { + Player.enableSubtitlesButton(); +} + +function disableSubtitlesButton() { + Player.disableSubtitlesButton(); +} + +function setScrubberPosition(position) { + Player.setScrubberPosition(position); +} + +function setTimestamp(timeString) { + Player.setTimestamp(timeString); +} + +function setVolume(volume) { + Player.setVolume(volume); +} + +/** + * The Player object handles initializing the player, holds state, and handles + * events for updating state. + */ +let Player = { + _isInitialized: false, + WINDOW_EVENTS: [ + "click", + "contextmenu", + "dblclick", + "keydown", + "mouseup", + "mousemove", + "MozDOMFullscreen:Entered", + "MozDOMFullscreen:Exited", + "resize", + "unload", + "draggableregionleftmousedown", + ], + 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 Telemetry to identify the window. + */ + 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, + + /** + * Used to determine if hovering the mouse cursor over the pip window or not. + * Gets updated whenever a new hover state is detected. + */ + isCurrentHover: false, + + /** + * Store the size and position of the window before entering fullscreen and + * use this to correctly position the window when exiting fullscreen + */ + deferredResize: null, + + /** + * Initializes the player browser, and sets up the initial state. + * + * @param {Number} id + * A unique numeric ID for the window, used for Telemetry Events. + * @param {WindowGlobalParent} wgp + * The WindowGlobalParent that is hosting the originating video. + * @param {ContentDOMReference} videoRef + * A reference to the video element that a Picture-in-Picture window + * is being created for + */ + init(id, wgp, videoRef) { + this.id = id; + + // State for whether or not we are adjusting the time via the scrubber + this.scrubbing = false; + + let holder = document.querySelector(".player-holder"); + let browser = document.getElementById("browser"); + browser.remove(); + + browser.setAttribute("nodefaultsrc", "true"); + + this.setupTooltip("close", "pictureinpicture-close-btn", "closeShortcut"); + let strId = this.isFullscreen + ? `pictureinpicture-exit-fullscreen-btn2` + : `pictureinpicture-fullscreen-btn2`; + this.setupTooltip("fullscreen", strId, "fullscreenToggleShortcut"); + + // 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); + } + + this.controls.addEventListener("mouseleave", () => { + this.onMouseLeave(); + }); + this.controls.addEventListener("mouseenter", () => { + this.onMouseEnter(); + }); + + this.scrubber.addEventListener("input", event => { + this.handleScrubbing(event); + }); + this.scrubber.addEventListener("change", event => { + this.handleScrubbingDone(event); + }); + + this.audioScrubber.addEventListener("input", event => { + this.audioScrubbing = true; + this.handleAudioScrubbing(event.target.value); + }); + this.audioScrubber.addEventListener("change", event => { + this.audioScrubbing = false; + }); + this.audioScrubber.addEventListener("pointerdown", event => { + if (this.isMuted) { + this.audioScrubber.max = 1; + } + }); + + for (let radio of document.querySelectorAll( + 'input[type=radio][name="cc-size"]' + )) { + radio.addEventListener("change", event => { + this.onSubtitleChange(event.target.id); + }); + } + + document + .querySelector("#subtitles-toggle") + .addEventListener("change", () => { + this.onToggleChange(); + }); + + // 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; + + const audioScrubber = document.getElementById("audio-scrubber"); + audioScrubber.hidden = false; + } + + if (Services.prefs.getBoolPref(CAPTIONS_ENABLED_PREF, false)) { + this.closedCaptionButton.hidden = false; + } + + if (Services.prefs.getBoolPref(IMPROVED_CONTROLS_ENABLED_PREF, false)) { + const fullscreenButton = document.getElementById("fullscreen"); + fullscreenButton.hidden = false; + + const seekBackwardButton = document.getElementById("seekBackward"); + seekBackwardButton.hidden = false; + + const seekForwardButton = document.getElementById("seekForward"); + seekForwardButton.hidden = false; + + this.scrubber.hidden = false; + this.timestamp.hidden = false; + + const controlsBottomGradient = document.getElementById( + "controls-bottom-gradient" + ); + controlsBottomGradient.hidden = false; + } + + this.alignEndControlsButtonTooltips(); + + this.resizeDebouncer = new DeferredTask(() => { + this.alignEndControlsButtonTooltips(); + this.recordEvent("resize", { + width: window.outerWidth.toString(), + height: window.outerHeight.toString(), + }); + }, RESIZE_DEBOUNCE_RATE_MS); + + 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(); + }); + + let fontSize = Services.prefs.getCharPref( + TEXT_TRACK_FONT_SIZE_PREF, + "medium" + ); + + // fallback to medium if the pref value is not a valid option + if (fontSize === "small" || fontSize === "large") { + document.querySelector(`#${fontSize}`).checked = "true"; + } else { + document.querySelector("#medium").checked = "true"; + } + + // In see-through mode the PiP window is made semi-transparent on hover. + if (Services.prefs.getBoolPref(SEETHROUGH_MODE_ENABLED_PREF, false)) { + document.documentElement.classList.add("seethrough-mode"); + } + + this._isInitialized = true; + }, + + uninit() { + this.resizeDebouncer.disarm(); + PictureInPicture.unload(window, this.actor); + }, + + setupTooltip(elId, l10nId, shortcutId) { + const el = document.getElementById(elId); + const shortcut = document.getElementById(shortcutId); + let l10nObj = shortcut + ? { shortcut: ShortcutUtils.prettifyShortcut(shortcut) } + : {}; + document.l10n.setAttributes(el, l10nId, l10nObj); + }, + + handleEvent(event) { + switch (event.type) { + case "click": { + // Don't run onClick if middle or right click is pressed respectively + if (event.button !== 1 && event.button !== 2) { + 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); + this.showVideoControls(); + } else if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + let isSettingsPanelInFocus = this.settingsPanel.contains( + document.activeElement + ); + + event.preventDefault(); + + if (!this.settingsPanel.classList.contains("hide")) { + // If the subtitles settings panel is open, let the ESC key close it + this.toggleSubtitlesSettingsPanel({ forceHide: true }); + if (isSettingsPanelInFocus) { + document.getElementById("closed-caption").focus(); + } + } else if (this.isFullscreen) { + // We handle the ESC key, in fullscreen modus as intent to leave only the fullscreen mode + document.exitFullscreen(); + } else { + // We handle the ESC key, as an intent to leave the picture-in-picture modus + this.onClose(); + } + } 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; + } + + case "mousemove": { + this.onMouseMove(); + 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"); + } + }); + + // If we are exiting fullscreen we want to resize the window to the + // stored size and position + if (this.deferredResize && event.type === "MozDOMFullscreen:Exited") { + this.resizeToVideo(this.deferredResize); + this.deferredResize = null; + } + + // Sets the title for fullscreen button when PIP is in Enter Fullscreen mode and Exit Fullscreen mode + let strId = this.isFullscreen + ? `pictureinpicture-exit-fullscreen-btn2` + : `pictureinpicture-fullscreen-btn2`; + this.setupTooltip("fullscreen", strId, "fullscreenToggleShortcut"); + + window.focus(); + + if (this.isFullscreen) { + this.actor.sendAsyncMessage("PictureInPicture:EnterFullscreen", { + isFullscreen: true, + isVideoControlsShowing: null, + playerBottomControlsDOMRect: null, + }); + } else { + this.actor.sendAsyncMessage("PictureInPicture:ExitFullscreen", { + isFullscreen: this.isFullscreen, + isVideoControlsShowing: + !!this.controls.getAttribute("showing") || + !!this.controls.getAttribute("keying"), + playerBottomControlsDOMRect: + this.controlsBottom.getBoundingClientRect(), + }); + } + // The subtitles settings panel gets selected when entering/exiting fullscreen even though + // user-select is set to none. I don't know why this happens or how to prevent so we just + // remove the selection when fullscreen is entered/exited. + let selection = window.getSelection(); + selection.removeAllRanges(); + break; + } + + case "oop-browser-crashed": { + this.closePipWindow({ reason: "browser-crash" }); + break; + } + + case "resize": { + this.onResize(event); + break; + } + + case "unload": { + this.uninit(); + break; + } + + case "draggableregionleftmousedown": { + this.toggleSubtitlesSettingsPanel({ forceHide: true }); + break; + } + } + }, + + /** + * This function handles when the scrubber is being scrubbed by the mouse + * because if we get an input event from the keyboard, onKeyDown will set + * this.preventNextInputEvent to true. + * This function is called by input events on the scrubber + * @param {Event} event The input event + */ + handleScrubbing(event) { + // When using the keyboard to scrub, we get both a keydown and an input + // event. The input event is fired after the keydown and we have already + // handled the keydown event in onKeyDown so we set preventNextInputEvent + // to true in onKeyDown as to not set the current time twice. + if (this.preventNextInputEvent) { + this.preventNextInputEvent = false; + return; + } + if (!this.scrubbing) { + this.wasPlaying = this.isPlaying; + if (this.isPlaying) { + this.actor.sendAsyncMessage("PictureInPicture:Pause"); + } + this.scrubbing = true; + } + let scrubberPosition = this.getScrubberPositionFromEvent(event); + this.setVideoTime(scrubberPosition); + }, + + /** + * This function handles setting the scrubbing state to false and playing + * the video if we paused it before scrubbing. + * @param {Event} event The change event + */ + handleScrubbingDone(event) { + if (!this.scrubbing) { + return; + } + let scrubberPosition = this.getScrubberPositionFromEvent(event); + this.setVideoTime(scrubberPosition); + if (this.wasPlaying) { + this.actor.sendAsyncMessage("PictureInPicture:Play"); + } + this.scrubbing = false; + }, + + /** + * Set the volume on the video and unmute if the video was muted. + * If the volume is changed via the keyboard, onKeyDown will set + * this.preventNextInputEvent to true. + * @param {Number} volume A number between 0 and 1 that represents the volume + */ + handleAudioScrubbing(volume) { + // When using the keyboard to adjust the volume, we get both a keydown and + // an input event. The input event is fired after the keydown event and we + // have already handled the keydown event in onKeyDown so we set + // preventNextInputEvent to true in onKeyDown as to not set the volume twice. + if (this.preventNextInputEvent) { + this.preventNextInputEvent = false; + return; + } + + if (this.isMuted) { + this.isMuted = false; + this.actor.sendAsyncMessage("PictureInPicture:Unmute"); + } + + if (volume == 0) { + this.actor.sendAsyncMessage("PictureInPicture:Mute"); + } + + this.actor.sendAsyncMessage("PictureInPicture:SetVolume", { + volume, + }); + }, + + getScrubberPositionFromEvent(event) { + return event.target.value; + }, + + setVideoTime(scrubberPosition) { + let wasPlaying = this.scrubbing ? this.wasPlaying : this.isPlaying; + this.setScrubberPosition(scrubberPosition); + this.actor.sendAsyncMessage("PictureInPicture:SetVideoTime", { + scrubberPosition, + wasPlaying, + }); + }, + + setScrubberPosition(value) { + this.scrubber.value = value; + this.scrubber.hidden = value === undefined; + + // Also hide the seek buttons when we hide the scrubber + this.seekBackward.hidden = value === undefined; + this.seekForward.hidden = value === undefined; + }, + + setTimestamp(timestamp) { + this.timestamp.textContent = timestamp; + this.timestamp.hidden = timestamp === undefined; + }, + + setVolume(volume) { + if (volume < Number.EPSILON) { + this.actor.sendAsyncMessage("PictureInPicture:Mute"); + } + + this.audioScrubber.value = volume; + }, + + closePipWindow(closeData) { + // Set the subtitles font size prefs + Services.prefs.setBoolPref( + CAPTIONS_TOGGLE_ENABLED_PREF, + document.querySelector("#subtitles-toggle").checked + ); + for (let radio of document.querySelectorAll( + 'input[type=radio][name="cc-size"]' + )) { + if (radio.checked) { + Services.prefs.setCharPref(TEXT_TRACK_FONT_SIZE_PREF, radio.id); + break; + } + } + const { reason } = closeData; + PictureInPicture.closeSinglePipWindow({ reason, actorRef: this.actor }); + }, + + onDblClick(event) { + if (event.target.id == "controls") { + this.fullscreenModeToggle(); + event.preventDefault(); + } + }, + + onClick(event) { + switch (event.target.id) { + case "audio": { + this.toggleMute(); + break; + } + + case "close": { + this.onClose(); + 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 "seekBackward": { + this.actor.sendAsyncMessage("PictureInPicture:SeekBackward"); + break; + } + + case "seekForward": { + this.actor.sendAsyncMessage("PictureInPicture:SeekForward"); + break; + } + + case "unpip": { + PictureInPicture.focusTabAndClosePip(window, this.actor); + break; + } + + case "closed-caption": { + let options = {}; + if (event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) { + options.isKeyboard = true; + } + this.toggleSubtitlesSettingsPanel(options); + // Early return to prevent hiding the panel below + return; + } + + case "fullscreen": { + this.fullscreenModeToggle(); + this.recordEvent("fullscreen", { + enter: (!this.isFullscreen).toString(), + }); + break; + } + + case "font-size-selection-radio-small": { + document.getElementById("small").click(); + break; + } + + case "font-size-selection-radio-medium": { + document.getElementById("medium").click(); + break; + } + + case "font-size-selection-radio-large": { + document.getElementById("large").click(); + break; + } + } + // If the click came from a element that is not inside the subtitles settings panel + // then we want to hide the panel + if (!this.settingsPanel.contains(event.target)) { + this.toggleSubtitlesSettingsPanel({ forceHide: true }); + } + }, + + /** + * Function to toggle the visibility of the subtitles settings panel + * @param {Object} options [optional] Object containing options for the function + * - forceHide: true to force hide the subtitles settings panel + * - isKeyboard: true if the subtitles button was activated using the keyboard + * to show or hide the subtitles settings panel + */ + toggleSubtitlesSettingsPanel(options) { + let settingsPanelVisible = !this.settingsPanel.classList.contains("hide"); + if (options?.forceHide || settingsPanelVisible) { + this.settingsPanel.classList.add("hide"); + this.closedCaptionButton.setAttribute("aria-expanded", false); + this.controls.removeAttribute("donthide"); + + if ( + this.controls.getAttribute("keying") || + this.isCurrentHover || + this.controls.getAttribute("showing") + ) { + return; + } + + this.hideVideoControls(); + } else { + this.settingsPanel.classList.remove("hide"); + this.closedCaptionButton.setAttribute("aria-expanded", true); + this.controls.setAttribute("donthide", true); + this.showVideoControls(); + + if (options?.isKeyboard) { + document.querySelector("#subtitles-toggle").focus(); + } + } + }, + + onClose() { + this.actor.sendAsyncMessage("PictureInPicture:Pause", { + reason: "pip-closed", + }); + this.closePipWindow({ reason: "closeButton" }); + }, + + fullscreenModeToggle() { + if (this.isFullscreen) { + document.exitFullscreen(); + } else { + this.deferredResize = { + left: window.screenX, + top: window.screenY, + width: window.innerWidth, + height: window.innerHeight, + }; + document.body.requestFullscreen(); + } + }, + + /** + * Toggle the mute state of the video + */ + toggleMute() { + if (this.isMuted) { + // We unmute in handleAudioScrubbing so no need to also do it here + this.audioScrubber.max = 1; + this.handleAudioScrubbing(this.lastVolume ?? 1); + } else { + this.lastVolume = this.audioScrubber.value; + this.actor.sendAsyncMessage("PictureInPicture:Mute"); + } + }, + + resizeToVideo(rect) { + if (this.isFullscreen) { + // We store the size and position because resizing the PiP window + // while fullscreened will cause issues + this.deferredResize = rect; + } else { + let { left, top, width, height } = rect; + window.resizeTo(width, height); + window.moveTo(left, top); + } + }, + + onKeyDown(event) { + // We don't want to send a keydown event if the event target was one of the + // font sizes in the settings panel + if ( + event.target.parentElement?.parentElement?.classList?.contains( + "font-size-selection" + ) + ) { + return; + } + + let eventKeys = { + altKey: event.altKey, + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + keyCode: event.keyCode, + }; + + // If the up or down arrow is pressed while the scrubber is focused then we + // want to hijack these keydown events to act as left or right arrows + // respectively to correctly seek the video. + if ( + event.target.id === "scrubber" && + event.keyCode === window.KeyEvent.DOM_VK_UP + ) { + eventKeys.keyCode = window.KeyEvent.DOM_VK_RIGHT; + } else if ( + event.target.id === "scrubber" && + event.keyCode === window.KeyEvent.DOM_VK_DOWN + ) { + eventKeys.keyCode = window.KeyEvent.DOM_VK_LEFT; + } + + // If the left or right arrow is pressed while the audio scrubber is focused + // then we want to hijack these keydown events to act as up or down arrows + // respectively to correctly change the volume. + if ( + event.target.id === "audio-scrubber" && + event.keyCode === window.KeyEvent.DOM_VK_RIGHT + ) { + eventKeys.keyCode = window.KeyEvent.DOM_VK_UP; + } else if ( + event.target.id === "audio-scrubber" && + event.keyCode === window.KeyEvent.DOM_VK_LEFT + ) { + eventKeys.keyCode = window.KeyEvent.DOM_VK_DOWN; + } + + // If the keydown event was one of the arrow keys and the scrubber or the + // audio scrubber was focused then we want to prevent the subsequent input + // event from overwriting the keydown event. + if ( + event.target.id === "audio-scrubber" || + (event.target.id === "scrubber" && + [ + window.KeyEvent.DOM_VK_LEFT, + window.KeyEvent.DOM_VK_RIGHT, + window.KeyEvent.DOM_VK_UP, + window.KeyEvent.DOM_VK_DOWN, + ].includes(event.keyCode)) + ) { + this.preventNextInputEvent = true; + } + + this.actor.sendAsyncMessage("PictureInPicture:KeyDown", eventKeys); + }, + + onSubtitleChange(size) { + Services.prefs.setCharPref(TEXT_TRACK_FONT_SIZE_PREF, size); + + this.actor.sendAsyncMessage("PictureInPicture:ChangeFontSizeTextTracks"); + }, + + onToggleChange() { + // The subtitles toggle has been click in the settings panel so we toggle + // the overlay above the font sizes and send a message to toggle the + // visibility of the subtitles and set the toggle pref + document + .querySelector(".font-size-selection") + .classList.toggle("font-size-overlay"); + this.actor.sendAsyncMessage("PictureInPicture:ToggleTextTracks"); + + this.captionsToggleEnabled = !this.captionsToggleEnabled; + Services.prefs.setBoolPref( + CAPTIONS_TOGGLE_ENABLED_PREF, + this.captionsToggleEnabled + ); + }, + + /** + * 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 + ); + }, + + /** + * Uses the PiP window's change in position to determine which direction + * the window has been moved in. + */ + 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; + }, + + /** + * Event handler for "mouseup" events on the PiP window. + * + * @param {Event} event + * Event context details + */ + onMouseUp(event) { + // Corner snapping changes start here. + // Check if metakey pressed and macOS + let quadrant = this.determineCurrentQuadrant(); + let dragAction = this.determineDirectionDragged(); + + if ( + ((event.ctrlKey && AppConstants.platform !== "macosx") || + (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; + }, + + /** + * Event handler for mousemove the PiP Window + */ + onMouseMove() { + if (this.isFullscreen) { + this.revealControls(false); + } + }, + + onMouseEnter() { + if (!this.isFullscreen) { + this.isCurrentHover = true; + this.showVideoControls(); + } + }, + + onMouseLeave() { + if (!this.isFullscreen) { + this.isCurrentHover = false; + if ( + !this.controls.getAttribute("showing") && + !this.controls.getAttribute("keying") && + !this.controls.getAttribute("donthide") + ) { + this.hideVideoControls(); + } + } + }, + + enableSubtitlesButton() { + this.closedCaptionButton.disabled = false; + + this.alignEndControlsButtonTooltips(); + this.captionsToggleEnabled = true; + // If the CAPTIONS_TOGGLE_ENABLED_PREF pref is false then we will click + // the UI toggle to change the toggle to unchecked. This will call + // onToggleChange where this.captionsToggleEnabled will be updated + if (!Services.prefs.getBoolPref(CAPTIONS_TOGGLE_ENABLED_PREF, true)) { + document.querySelector("#subtitles-toggle").click(); + } + }, + + disableSubtitlesButton() { + this.closedCaptionButton.disabled = true; + + this.alignEndControlsButtonTooltips(); + }, + + /** + * Sets focus state inline end tooltip for rightmost playback controls + */ + alignEndControlsButtonTooltips() { + let audioBtn = document.getElementById("audio"); + let width = window.outerWidth; + + if (300 < width && width <= 400) { + audioBtn.classList.replace("center-tooltip", "inline-end-tooltip"); + } else { + audioBtn.classList.replace("inline-end-tooltip", "center-tooltip"); + } + }, + + /** + * Event handler for resizing the PiP Window + * + * @param {Event} event + * Event context data object + */ + onResize(event) { + this.toggleSubtitlesSettingsPanel({ forceHide: true }); + this.resizeDebouncer.disarm(); + this.resizeDebouncer.arm(); + }, + + /** + * Event handler for user issued commands + * + * @param {Event} event + * Event context data object + */ + onCommand(event) { + this.closePipWindow({ reason: "shortcut" }); + }, + + get controls() { + delete this.controls; + return (this.controls = document.getElementById("controls")); + }, + + get scrubber() { + delete this.scrubber; + return (this.scrubber = document.getElementById("scrubber")); + }, + + get audioScrubber() { + delete this.audioScrubber; + return (this.audioScrubber = document.getElementById("audio-scrubber")); + }, + + get timestamp() { + delete this.timestamp; + return (this.timestamp = document.getElementById("timestamp")); + }, + + get controlsBottom() { + delete this.controlsBottom; + return (this.controlsBottom = document.getElementById("controls-bottom")); + }, + + get seekBackward() { + delete this.seekBackward; + return (this.seekBackward = document.getElementById("seekBackward")); + }, + + get seekForward() { + delete this.seekForward; + return (this.seekForward = document.getElementById("seekForward")); + }, + + get closedCaptionButton() { + delete this.closedCaptionButton; + return (this.closedCaptionButton = + document.getElementById("closed-caption")); + }, + + get settingsPanel() { + delete this.settingsPanel; + return (this.settingsPanel = document.getElementById("settings")); + }, + + _isPlaying: false, + /** + * GET isPlaying returns true if the video is currently playing. + * + * SET isPlaying to true if the video is playing, false otherwise. This will + * update the internal state and displayed controls. + * + * @type {Boolean} + */ + get isPlaying() { + return this._isPlaying; + }, + + set isPlaying(isPlaying) { + this._isPlaying = isPlaying; + this.controls.classList.toggle("playing", isPlaying); + let strId = isPlaying + ? `pictureinpicture-pause-btn` + : `pictureinpicture-play-btn`; + this.setupTooltip("playpause", strId); + + if ( + !this._isInitialized || + this.isCurrentHover || + this.controls.getAttribute("keying") + ) { + return; + } + + if (!isPlaying) { + this.revealControls(true); + } else { + this.revealControls(false); + } + }, + + _isMuted: false, + /** + * GET isMuted returns true if the video is currently muted. + * + * SET isMuted to true if the video is muted, false otherwise. This will + * update the internal state and displayed controls. + * + * @type {Boolean} + */ + get isMuted() { + return this._isMuted; + }, + + set isMuted(isMuted) { + this._isMuted = isMuted; + if (!isMuted) { + this.audioScrubber.max = 1; + } else if (!this.audioScrubbing) { + this.audioScrubber.max = 0; + } + this.controls.classList.toggle("muted", isMuted); + let strId = isMuted + ? `pictureinpicture-unmute-btn` + : `pictureinpicture-mute-btn`; + let shortcutId = isMuted ? "unMuteShortcut" : "muteShortcut"; + this.setupTooltip("audio", strId, shortcutId); + }, + + /** + * GET isFullscreen returns true if the video is running in fullscreen mode + * + * @returns {boolean} + */ + get isFullscreen() { + return document.fullscreenElement == document.body; + }, + + /** + * Used for recording telemetry in Picture-in-Picture. + * + * @param {string} type + * The type of PiP event being recorded. + * @param {object} args + * The data to pass to telemetry when the event is recorded. + */ + recordEvent(type, args) { + Services.telemetry.recordEvent( + "pictureinpicture", + type, + "player", + this.id, + args + ); + }, + + /** + * Send a message to PiPChild to adjust the subtitles position + * so that subtitles are visible when showing video controls. + */ + showVideoControls() { + // offsetParent returns null when the element or any ancestor has display: none + // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent + this.actor.sendAsyncMessage("PictureInPicture:ShowVideoControls", { + isFullscreen: this.isFullscreen, + isVideoControlsShowing: true, + playerBottomControlsDOMRect: this.controlsBottom.getBoundingClientRect(), + isScrubberShowing: !!this.scrubber.offsetParent, + }); + }, + + /** + * Send a message to PiPChild to adjust the subtitles position + * so that subtitles take up remaining space when hiding video controls. + */ + hideVideoControls() { + this.actor.sendAsyncMessage("PictureInPicture:HideVideoControls", { + isFullscreen: this.isFullscreen, + isVideoControlsShowing: false, + playerBottomControlsDOMRect: null, + }); + }, + + /** + * Makes the player controls visible. + * + * @param {Boolean} revealIndefinitely + * 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 (!this.isFullscreen) { + // revealControls() is called everytime we hover over fullscreen pip window. + // Only communicate with pipchild when not in fullscreen mode for performance reasons. + this.showVideoControls(); + } + + if (!revealIndefinitely) { + this.showingTimeout = setTimeout(() => { + const isHoverOverControlItem = this.controls.querySelector( + ".control-item:hover" + ); + if (this.isFullscreen && isHoverOverControlItem) { + return; + } + this.controls.removeAttribute("showing"); + + if ( + !this.isFullscreen && + !this.isCurrentHover && + !this.controls.getAttribute("keying") && + !this.controls.getAttribute("donthide") + ) { + this.hideVideoControls(); + } + }, 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 {Number} width + * The width of the video being played. + * @param {Number} height + * 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..5b6892d649 --- /dev/null +++ b/toolkit/components/pictureinpicture/content/player.xhtml @@ -0,0 +1,119 @@ +<?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" + scrolling="false" + > + <head> + <meta charset="utf-8"/> + <link rel="stylesheet" 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:command id="View:Fullscreen" oncommand="Player.fullscreenModeToggle(event);"/> + </xul:commandset> + + <xul:keyset> + <xul:key id="closeShortcut" key="W" modifiers="accel"/> + <xul:key id="muteShortcut" key="↓" modifiers="accel"/> + <xul:key id="unMuteShortcut" key="↑" modifiers="accel"/> +#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:key id="fullscreenToggleShortcut" data-l10n-id="pictureinpicture-toggle-fullscreen-shortcut" command="View:Fullscreen"/> + </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" dir="ltr"> + <button id="close" + class="control-item control-button tooltip-under-controls" data-l10n-attrs="tooltip" +#ifdef XP_MACOSX + mac="true" + tabindex="9" +#else + tabindex="10" +#endif + /> + <button id="unpip" + class="control-item control-button tooltip-under-controls" data-l10n-id="pictureinpicture-unpip-btn" data-l10n-attrs="tooltip" +#ifdef XP_MACOSX + mac="true" + tabindex="10" +#else + tabindex="9" +#endif + /> + <div id="controls-bottom-gradient" class="control-item"></div> + <div id="controls-bottom"> + <div class="controls-bottom-upper"> + <div class="scrubber-no-drag"> + <input id="scrubber" class="control-item" min="0" max="1" step=".001" type="range" tabindex="11" hidden="true"/> + </div> + </div> + <div class="controls-bottom-lower"> + <div class="start-controls"> + <div id="timestamp" class="control-item" hidden="true"></div> + </div> + <div class="center-controls"> + <button id="seekBackward" class="control-item control-button tooltip-over-controls center-tooltip" hidden="true" data-l10n-id="pictureinpicture-seekbackward-btn" data-l10n-attrs="tooltip" tabindex="12"></button> + <button id="playpause" class="control-item control-button tooltip-over-controls center-tooltip" tabindex="1" + data-l10n-id="pictureinpicture-pause-btn" data-l10n-attrs="tooltip"/> + <button id="seekForward" class="control-item control-button tooltip-over-controls center-tooltip" hidden="true" data-l10n-id="pictureinpicture-seekforward-btn" data-l10n-attrs="tooltip" tabindex="2"></button> + </div> + <div class="end-controls"> + <button id="audio" class="control-item control-button tooltip-over-controls center-tooltip" hidden="true" data-l10n-attrs="tooltip" tabindex="3"/> + <input id="audio-scrubber" class="control-item" min="0" max="1" step=".001" type="range" tabindex="4" hidden="true"/> + <button id="closed-caption" class="control-item control-button tooltip-over-controls center-tooltip closed-caption" hidden="true" disabled="true" data-l10n-id="pictureinpicture-subtitles-btn" data-l10n-attrs="tooltip" tabindex="5"></button> + <div id="settings" class="hide panel"> + <fieldset class="box panel-fieldset"> + <legend class="a11y-only panel-legend" data-l10n-id="pictureinpicture-subtitles-panel-accessible"></legend> + <div class="subtitle-grid"> + <label id="subtitles-toggle-label" data-l10n-id="pictureinpicture-subtitles-label" class="bold" for="subtitles-toggle"></label> + <label class="switch"> + <input id="subtitles-toggle" type="checkbox" tabindex="6" checked=""/> + <span class="slider" role="presentation"></span> + </label> + </div> + <div class="grey-line"></div> + <fieldset class="font-size-selection panel-fieldset"> + <legend data-l10n-id="pictureinpicture-font-size-label" class="bold panel-legend"></legend> + <div id="font-size-selection-radio-small" class="font-size-selection-radio"> + <input id="small" type="radio" name="cc-size" tabindex="7"/> + <label data-l10n-id="pictureinpicture-font-size-small" for="small"></label> + </div> + <div id="font-size-selection-radio-medium" class="font-size-selection-radio"> + <input id="medium" type="radio" name="cc-size" tabindex="7"/> + <label data-l10n-id="pictureinpicture-font-size-medium" for="medium"></label> + </div> + <div id="font-size-selection-radio-large" class="font-size-selection-radio"> + <input id="large" type="radio" name="cc-size" tabindex="7"/> + <label data-l10n-id="pictureinpicture-font-size-large" for="large"></label> + </div> + </fieldset> + </fieldset> + <div class="arrow"></div> + </div> + <button id="fullscreen" class="control-item control-button tooltip-over-controls inline-end-tooltip" hidden="true" data-l10n-attrs="tooltip" tabindex="8"></button> + </div> + </div> + </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..391cc7728a --- /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" 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..a5e255d4e3 --- /dev/null +++ b/toolkit/components/pictureinpicture/docs/index.rst @@ -0,0 +1,385 @@ +.. _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.sys.mjs`` 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.sys.mjs +======================== + +This module runs in the parent process, and is also the scope where all ``PictureInPictureParent`` instances reside. ``PictureInPicture.sys.mjs``'s job is to send and receive messages from ``PictureInPictureChild`` instances, and to react appropriately. + +Critically, ``PictureInPicture.sys.mjs`` 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. + +Site-specific video wrappers +============================ + +A site-specific video wrapper allows for the creation of custom scripts that the Picture-in-Picture component can utilize when videos are loaded in specific domains. Currently, some uses of video wrappers include: + +* Integration of captions and subtitles support on certain video streaming sites +* Fixing inconsistent video behaviour when using Picture-in-Picture controls +* Hiding the Picture-in-Picture toggle for videos on particular areas of a page, given a URL (rather than hiding the toggle for all videos on a page) + +``PictureInPictureChildVideoWrapper`` and ``videoWrapperScriptPath`` +-------------------------------------------------------------------- +``PictureInPictureChildVideoWrapper`` is a special class that represents a video wrapper. It is defined in ``PictureInPictureChild.sys.mjs`` and maps to a ``videoWrapperScriptPath``, which is the path of the custom wrapper script to use. +``videoWrapperScriptPath`` is defined in `browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js>`_ for a domain, +and custom wrapper scripts are defined in `browser/extensions/pictureinpicture/video-wrappers <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/video-wrappers>`_. + +If a ``videoWrapperScriptPath`` is detected while initializing the Picture-in-Picture toggle or window, we immediately create a new instance of ``PictureInPictureChildVideoWrapper`` based on the given path, allowing us to run our custom scripts. + +API +^^^ +See the full list of methods at `API References <#toolkit-actors-pictureinpicturechild-jsm>`_. + +Sandbox +^^^^^^^ +Performing video control operations on the originating video requires executing code in the browser content. For security reasons, we utilize a *sandbox* to isolate these operations and prevent direct access to ``PictureInPictureChild``. In other words, we run content code within the sandbox itself. +However, it is necessary to waive :ref:`xray vision <Waiving_Xray_vision>` so that we can execute the video control operations. This is done by reading the wrapper’s ``.wrappedJSObject`` property. + +Adding a new site-specific video wrapper +---------------------------------------- +Creating a new wrapper script file +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Add a new JS file for the new video wrapper in `browser/extensions/pictureinpicture/video-wrappers <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/video-wrappers>`_. +The file must meet several requirements to get the wrapper working. + +**Script file requirements**: + +* Defined class ``PictureInPictureVideoWrapper`` +* Assigned ``this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper`` + +**PictureInPictureVideoWrapper class requirements**: + +* Implementation of at least one overridable method (see :ref:`picture_in_picture_child_video_wrapper_api`) + +**Overriden method requirements**: + +* Return value with a type that corresponds to ``validateRetVal`` in ``PictureInPictureChildVideoWrapper.#callWrapperMethod()`` + +Below is an example of a script file ``mock-wrapper.js`` that overrides an existing method ``setMuted()`` in ``PictureInPictureChildVideoWrapper``: + +.. code-block:: js + + // sample file `mock-wrapper.js` + class PictureInPictureVideoWrapper { + setMuted(video, shouldMute) { + if (video.muted !== shouldMute) { + let muteButton = document.querySelector("#player .mute-button"); + if (muteButton) { + muteButton.click(); + } else { + video.muted = shouldMute; + } + } + } + } + + this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper + +.. note:: + If a new ``PictureInPictureChildVideoWrapper`` video control method is needed, see `Adding a new video control method`_. + +Declaring ``videoWrapperScriptPath`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Declare a property ``videoWrapperScriptPath`` for the site at `browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js>`_: + +.. code-block:: js + + someWebsite: { + "https://*.somewebsite.com/*": { + videoWrapperScriptPath: "video-wrappers/mock-wrapper.js", + }, + } + +In this example, the URL pattern ``https://*.somewebsite.com/*`` is provided for a site named ``someWebsite``. +Picture-in-Picture checks for any overrides upon initialization, and it will load scripts specified by ``videoWrapperScriptPath``. +The scripts located at ``video-wrappers/mock-wrapper.js`` will therefore run whenever we view a video from a URL matching ``somewebsite.com``. + +Registering the new wrapper in ``moz.build`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +We should update `browser/extensions/pictureinpicture/moz.build <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/moz.build>`_ by adding the path of the newly created wrapper: + +.. code-block:: js + + FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["video-wrappers"] += [ + "video-wrappers/mock-wrapper.js", + "video-wrappers/netflix.js", + "video-wrappers/youtube.js", + ] + +As expected for any ``moz.build`` file, order matters. Registered paths should be listed in alphabetical order. Otherwise, the build will fail. + +Adding a new video control method +--------------------------------- +If none of the existing overridable methods in ``PictureInPictureChildVideoWrapper`` are applicable for a bug fix or feature enhancement, +we can create a new one by calling ``#callWrapperMethod()``. Below is an example of how we would define a new overridable method ``setMuted()``: + +.. code-block:: js + + // class PictureInPictureChildVideoWrapper in PictureInPictureChild.sys.mjs + setMuted(video, shouldMute) { + return this.#callWrapperMethod({ + name: "setMuted", + args: [video, shouldMute], + fallback: () => { + video.muted = shouldMute; + }, + validateRetVal: retVal => retVal == null, + }); + } + +The new method passes to ``#callWrapperMethod()``: + +#. The method name +#. The expected arguments that a wrapper script may use +#. A fallback function +#. A conditional expression that validates the return value + +The fallback function only executes if a wrapper script fails or if the method is not overriden. +``validateRetVal`` checks the type of the return value and ensures it matches the expected type. If there is no return value, simply validate if type is ``null``. + +.. note:: + Generic method names are preferred so that they can be used for any video wrapper. + For example: instead of naming a method ``updateCaptionsContainerForSiteA()``, use ``updateCaptionsContainer()``. + +Using the new video control method +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Once the new method is defined, it can be used throughout ``PictureInPictureChild.sys.mjs``. In the current example, we call +``PictureInPictureChildVideoWrapper.setMuted()`` to mute or unmute a video. ``this.videoWrapper`` is an instance of +``PictureInPictureChildVideoWrapper``: + +.. code-block:: js + + // class PictureInPictureChild in PictureInPictureChild.sys.mjs + mute() { + let video = this.getWeakVideo(); + if (video && this.videoWrapper) { + this.videoWrapper.setMuted(video, true); + } + } + + unmute() { + let video = this.getWeakVideo(); + if (video && this.videoWrapper) { + this.videoWrapper.setMuted(video, false); + } + } + +Testing site-specific video wrappers +------------------------------------ +Automated Tests +^^^^^^^^^^^^^^^ +Automated tests for site specific wrappers are currently limited. New tests can be made in `browser/extensions/pictureinpicture/tests/browser <https://searchfox.org/mozilla-central/source/browser/extensions/pictureinpicture/tests/browser>`_ to ensure +general functionality, but these are restricted to Firefox Nightly and do not test functionality on specific sites. + +Some challenges with writing tests include: + +* Accessing DRM content +* Log-in credentials if a site requires a user account +* Detecting modifications to a web page or video player that render a wrapper script obsolete + +Manual Tests +^^^^^^^^^^^^ +The go-to approach right now is to test video wrappers manually, in tandem with reviews provided by the phabricator group `#pip-reviewers <https://phabricator.services.mozilla.com/project/profile/163/>`_. Below are some questions that reviewers will consider: + +* Does Picture-in-Picture crash or freeze? +* Does the wrapper work on Windows, MacOS, and Linux? +* Do Picture-in-Picture features work as expected? (Picture-in-Picture toggle, text tracks, video controls, etc.) +* Do existing automated tests work as they should? + +.. warning:: + DRM content may not load for all local Firefox builds. One possible solution is to test the video wrapper in a try build (ex. Linux). + Depending on the changes made, we may also require the script to run under a temporary pref such as ``media.videocontrols.picture-in-picture.WIP.someWebsiteWrapper`` for the purpose of testing changes in Firefox Nightly. + +API References +============== +``toolkit/components/pictureinpicture`` +--------------------------------------- +.. toctree:: + :maxdepth: 1 + + picture-in-picture-api + player-api + +``toolkit/actors/PictureInPictureChild.sys.mjs`` +------------------------------------------------ +* :ref:`picture_in_picture_child_video_wrapper_api` diff --git a/toolkit/components/pictureinpicture/docs/picture-in-picture-api.rst b/toolkit/components/pictureinpicture/docs/picture-in-picture-api.rst new file mode 100644 index 0000000000..06f2ea5fc4 --- /dev/null +++ b/toolkit/components/pictureinpicture/docs/picture-in-picture-api.rst @@ -0,0 +1,4 @@ +PictureInPicture Reference +=========================== +.. js:autoclass:: PictureInPicture + :members: diff --git a/toolkit/components/pictureinpicture/docs/player-api.rst b/toolkit/components/pictureinpicture/docs/player-api.rst new file mode 100644 index 0000000000..a990967dc1 --- /dev/null +++ b/toolkit/components/pictureinpicture/docs/player-api.rst @@ -0,0 +1,4 @@ +Player Reference +====================== +.. js:autoclass:: Player + :members: 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..e6ca96b903 --- /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", "Picture-in-Picture") + +SPHINX_TREES["pictureinpicture"] = "docs" + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "PictureInPicture.sys.mjs", + "PictureInPictureControls.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += [ + "tests/browser.toml", +] diff --git a/toolkit/components/pictureinpicture/tests/browser.toml b/toolkit/components/pictureinpicture/tests/browser.toml new file mode 100644 index 0000000000..f28a105698 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser.toml @@ -0,0 +1,239 @@ +[DEFAULT] +support-files = [ + "click-event-helper.js", + "head.js", + "short.mp4", + "no-audio-track.webm", + "test-button-overlay.html", + "test-media-stream.html", + "test-opaque-overlay.html", + "test-page.html", + "test-page-without-audio.html", + "test-page-multiple-contexts.html", + "test-page-pipDisabled.html", + "test-page-with-iframe.html", + "test-page-with-sound.html", + "test-page-with-webvtt.html", + "test-pointer-events-none.html", + "test-reversed.html", + "test-transparent-nested-iframes.html", + "test-transparent-overlay-1.html", + "test-transparent-overlay-2.html", + "test-video.mp4", + "test-video-cropped.mp4", + "test-video-long.mp4", + "test-video-selection.html", + "test-video-vertical.mp4", + "test-webvtt-1.vtt", + "test-webvtt-2.vtt", + "test-webvtt-3.vtt", + "test-webvtt-4.vtt", + "test-webvtt-5.vtt", + "../../../../dom/media/test/gizmo.mp4", + "../../../../dom/media/test/owl.mp3", +] + +prefs = [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled=false", + "media.videocontrols.picture-in-picture.enabled=true", + "media.videocontrols.picture-in-picture.video-toggle.always-show=true", + "media.videocontrols.picture-in-picture.video-toggle.enabled=true", + "media.videocontrols.picture-in-picture.video-toggle.has-used=true", + "media.videocontrols.picture-in-picture.video-toggle.position=\"right\"", + "media.videocontrols.picture-in-picture.video-toggle.testing=true", + "media.videocontrols.picture-in-picture.urlbar-button.enabled=true", +] + +["browser_aaa_run_first_firstTimePiPToggleEvents.js"] + +["browser_aaa_telemetry_togglePiP.js"] + +["browser_audioScrubber.js"] + +["browser_backgroundTab.js"] + +["browser_cannotTriggerFromContent.js"] + +["browser_changePiPSrcInFullscreen.js"] + +["browser_closePipPause.js"] + +["browser_closePip_pageNavigationChanges.js"] + +["browser_closePlayer.js"] + +["browser_closeTab.js"] + +["browser_close_unpip_focus.js"] + +["browser_conflictingPips.js"] + +["browser_contextMenu.js"] +skip-if = ["os == 'linux' && bits == 64 && os_version == '18.04'"] # Bug 1569205 + +["browser_controlsHover.js"] + +["browser_cornerSnapping.js"] +run-if = ["os == 'mac'"] + +["browser_dblclickFullscreen.js"] + +["browser_disableSwipeGestures.js"] +skip-if = ["os == 'mac'"] # Bug 1840716 + +["browser_durationChange.js"] + +["browser_flipIconWithRTL.js"] +skip-if = [ + "os == 'linux' && ccov", # Bug 1678091 + "tsan", # Bug 1678091 +] + +["browser_fontSize_change.js"] + +["browser_fullscreen.js"] +skip-if = [ + "os == 'mac' && debug", # Bug 1566173 + "os == 'linux'", # Bug 1664667 +] + +["browser_improved_controls.js"] + +["browser_keyboardClosePIPwithESC.js"] + +["browser_keyboardFullScreenPIPShortcut.js"] + +["browser_keyboardShortcut.js"] + +["browser_keyboardShortcutClosePIP.js"] + +["browser_keyboardShortcutWithNanDuration.js"] +support-files = ["test-page-with-nan-video-duration.html"] + +["browser_keyboardToggle.js"] + +["browser_mediaStreamVideos.js"] + +["browser_mouseButtonVariation.js"] +skip-if = [ + "debug", + "os == 'linux' && bits == 64 && !debug", # Bug 1549875 +] + +["browser_multiPip.js"] + +["browser_nimbusDisplayDuration.js"] + +["browser_nimbusFirstTimeStyleVariant.js"] + +["browser_nimbusMessageFirstTimePip.js"] + +["browser_nimbusShowIconOnly.js"] + +["browser_noPlayerControlsOnMiddleRightClick.js"] + +["browser_noToggleOnAudio.js"] + +["browser_occluded_window.js"] + +["browser_playerControls.js"] + +["browser_preserveTabPipIconOverlay.js"] + +["browser_privateWindow.js"] + +["browser_removeVideoElement.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_subtitles_settings_panel.js"] + +["browser_tabIconOverlayPiP.js"] + +["browser_telemetry_enhancements.js"] + +["browser_text_tracks_webvtt_1.js"] + +["browser_text_tracks_webvtt_2.js"] + +["browser_text_tracks_webvtt_3.js"] + +["browser_thirdPartyIframe.js"] + +["browser_toggleAfterTabTearOutIn.js"] +skip-if = [ + "os == 'linux' && bits == 64", # Bug 1605546 + "os == 'mac' && !debug", # Bug 1605546 +] + +["browser_toggleButtonOnNanDuration.js"] +skip-if = ["os == 'linux' && !debug"] # Bug 1700504 +support-files = ["test-page-with-nan-video-duration.html"] + +["browser_toggleButtonOverlay.js"] +skip-if = ["true"] # Bug 1546455 + +["browser_toggleMode_2.js"] +skip-if = [ + "os == 'linux'", # Bug 1654971 + "os == 'mac'", # 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 + "apple_catalina && debug", # Bug 1605565 +] + +["browser_togglePositionChange.js"] +skip-if = ["os == 'linux' && bits == 64 && !debug"] # Bug 1738532 + +["browser_toggleSimple.js"] +skip-if = ["os == 'linux'"] # Bug 1546455 + +["browser_toggleTransparentOverlay-1.js"] +skip-if = ["os == 'linux' && bits == 64"] # Bug 1552288 + +["browser_toggleTransparentOverlay-2.js"] +skip-if = ["os == 'linux' && bits == 64 && os_version == '18.04'"] # Bug 1546930 + +["browser_toggle_enabled.js"] + +["browser_toggle_videocontrols.js"] + +["browser_toggle_without_audio.js"] + +["browser_touch_toggle_enablepip.js"] + +["browser_urlbar_toggle.js"] + +["browser_videoEmptied.js"] + +["browser_videoSelection.js"] diff --git a/toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js b/toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js new file mode 100644 index 0000000000..df6261584c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js @@ -0,0 +1,314 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const FIRST_TIME_PIP_TOGGLE_STYLES = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-wrapper": DEFAULT_TOGGLE_OPACITY, + }, + hidden: [], + }, + + hoverToggle: { + opacities: { + ".pip-wrapper": 1.0, + }, + hidden: [], + }, + }, +}; + +const FIRST_CONTEXT_MENU_EXPECTED_EVENTS = [ + [ + "pictureinpicture", + "opened_method", + "contextMenu", + null, + { firstTimeToggle: "true" }, + ], +]; + +const SECOND_CONTEXT_MENU_EXPECTED_EVENTS = [ + [ + "pictureinpicture", + "opened_method", + "contextMenu", + null, + { firstTimeToggle: "false" }, + ], +]; + +const FIRST_TOGGLE_EXPECTED_EVENTS = [ + ["pictureinpicture", "saw_toggle", "toggle", null, { firstTime: "true" }], + [ + "pictureinpicture", + "opened_method", + "toggle", + null, + { firstTimeToggle: "true" }, + ], +]; + +const SECOND_TOGGLE_EXPECTED_EVENTS = [ + ["pictureinpicture", "saw_toggle", "toggle", null, { firstTime: "false" }], + [ + "pictureinpicture", + "opened_method", + "toggle", + null, + { firstTimeToggle: "false" }, + ], +]; + +/** + * This function will open the PiP window by clicking the toggle + * and then close the PiP window + * @param browser The current browser + * @param videoID The video element id + */ +async function openAndClosePipWithToggle(browser, videoID) { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + await prepareForToggleClick(browser, videoID); + + await clearAllContentEvents(); + + // 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", + FIRST_TIME_PIP_TOGGLE_STYLES + ); + + 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 assertSawClickEventOnly(browser); + + await BrowserTestUtils.synthesizeMouseAtPoint(1, 1, {}, browser); + await assertSawMouseEvents(browser, true); +} + +/** + * This function will open the PiP window by with the context menu + * @param browser The current browser + * @param videoID The video element id + */ +async function openAndClosePipWithContextMenu(browser, videoID) { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + let menu = document.getElementById("contentAreaContextMenu"); + let popupshown = BrowserTestUtils.waitForPopupEvent(menu, "shown"); + + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "contextmenu", + }, + browser + ); + + await popupshown; + let isContextMenuOpen = menu.state === "showing" || menu.state === "open"; + ok(isContextMenuOpen, "Context menu is open"); + + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + + // clear content events + await clearAllContentEvents(); + + let hidden = BrowserTestUtils.waitForPopupEvent(menu, "hidden"); + menu.activateItem(menu.querySelector("#context-video-pictureinpicture")); + await hidden; + + 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); +} + +async function clearAllContentEvents() { + // Clear everything. + await TestUtils.waitForCondition(() => { + Services.telemetry.clearEvents(); + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ).content; + return !events || !events.length; + }); +} + +add_task(async function test_eventTelemetry() { + Services.telemetry.clearEvents(); + await clearAllContentEvents(); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + Services.telemetry.setEventRecordingEnabled("pictureinpicture", true); + let videoID = "no-controls"; + + const PIP_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; + await SpecialPowers.pushPrefEnv({ + set: [[PIP_PREF, false]], + }); + + // open with context menu for first time + await openAndClosePipWithContextMenu(browser, videoID); + + let filter = { + category: "pictureinpicture", + method: "opened_method", + object: "contextMenu", + }; + await waitForTelemeryEvents( + filter, + FIRST_CONTEXT_MENU_EXPECTED_EVENTS.length, + "content" + ); + + TelemetryTestUtils.assertEvents( + FIRST_CONTEXT_MENU_EXPECTED_EVENTS, + filter, + { clear: true, process: "content" } + ); + + // open with toggle for first time + await SpecialPowers.pushPrefEnv({ + set: [[PIP_PREF, false]], + }); + + await openAndClosePipWithToggle(browser, videoID); + + filter = { + category: "pictureinpicture", + }; + await waitForTelemeryEvents( + filter, + FIRST_TOGGLE_EXPECTED_EVENTS.length, + "content" + ); + + TelemetryTestUtils.assertEvents(FIRST_TOGGLE_EXPECTED_EVENTS, filter, { + clear: true, + process: "content", + }); + + // open with toggle for not first time + await openAndClosePipWithToggle(browser, videoID); + + filter = { + category: "pictureinpicture", + }; + await waitForTelemeryEvents( + filter, + SECOND_TOGGLE_EXPECTED_EVENTS.length, + "content" + ); + + TelemetryTestUtils.assertEvents(SECOND_TOGGLE_EXPECTED_EVENTS, filter, { + clear: true, + process: "content", + }); + + // open with context menu for not first time + await openAndClosePipWithContextMenu(browser, videoID); + + filter = { + category: "pictureinpicture", + method: "opened_method", + object: "contextMenu", + }; + await waitForTelemeryEvents( + filter, + SECOND_CONTEXT_MENU_EXPECTED_EVENTS.length, + "content" + ); + + TelemetryTestUtils.assertEvents( + SECOND_CONTEXT_MENU_EXPECTED_EVENTS, + filter, + { true: false, process: "content" } + ); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js b/toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js new file mode 100644 index 0000000000..d6a7540e15 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function getTelemetryToggleEnabled() { + const scalarData = Services.telemetry.getSnapshotForScalars( + "main", + false + ).parent; + return scalarData["pictureinpicture.toggle_enabled"]; +} + +/** + * Tests telemetry for user toggling on or off PiP. + */ +add_task(async () => { + const TOGGLE_PIP_ENABLED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.enabled"; + + await SpecialPowers.pushPrefEnv({ + set: [[TOGGLE_PIP_ENABLED_PREF, true]], + }); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + let contextPiPDisable = document.getElementById( + "context_HidePictureInPictureToggle" + ); + contextPiPDisable.click(); + const enabled = Services.prefs.getBoolPref( + TOGGLE_PIP_ENABLED_PREF, + false + ); + + Assert.equal(enabled, false, "PiP is disabled."); + + await TestUtils.waitForCondition(() => { + return getTelemetryToggleEnabled() === false; + }); + + Assert.equal( + getTelemetryToggleEnabled(), + false, + "PiP is disabled according to Telemetry." + ); + + await SpecialPowers.pushPrefEnv({ + set: [[TOGGLE_PIP_ENABLED_PREF, true]], + }); + + await TestUtils.waitForCondition(() => { + return getTelemetryToggleEnabled() === true; + }); + + Assert.equal( + getTelemetryToggleEnabled(), + true, + "PiP is enabled according to Telemetry." + ); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_audioScrubber.js b/toolkit/components/pictureinpicture/tests/browser_audioScrubber.js new file mode 100644 index 0000000000..d13d0d13ad --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_audioScrubber.js @@ -0,0 +1,262 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const IMPROVED_CONTROLS_ENABLED_PREF = + "media.videocontrols.picture-in-picture.improved-video-controls.enabled"; + +const videoID = "with-controls"; + +async function getVideoVolume(browser, videoID) { + return SpecialPowers.spawn(browser, [videoID], async videoID => { + return content.document.getElementById(videoID).volume; + }); +} + +async function getVideoMuted(browser, videoID) { + return SpecialPowers.spawn(browser, [videoID], async videoID => { + return content.document.getElementById(videoID).muted; + }); +} + +add_task(async function test_audioScrubber() { + await SpecialPowers.pushPrefEnv({ + set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]], + }); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let ratio = pipWin.innerWidth / pipWin.innerHeight; + let height = 750; + let width = Math.floor(ratio * height); + if (pipWin.innerHeight < height || pipWin.innerWidth < width) { + // Resize the PiP window so we know the audio scrubber is visible + let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize"); + pipWin.resizeTo(width, height); + await resizePromise; + } + + let audioScrubber = pipWin.document.getElementById("audio-scrubber"); + audioScrubber.focus(); + + // Volume should be 1 and video should not be muted when opening this video + let actualVolume = await getVideoVolume(browser, videoID); + let expectedVolume = 1; + + let actualMuted = await getVideoMuted(browser, videoID); + + isfuzzy( + actualVolume, + expectedVolume, + Number.EPSILON, + `The actual volume ${actualVolume}. The expected volume ${expectedVolume}` + ); + + ok(!actualMuted, "Video is not muted"); + + let volumeChangePromise = BrowserTestUtils.waitForContentEvent( + browser, + "volumechange", + true + ); + + // Test that left arrow key changes volume by -0.1 + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin); + + await volumeChangePromise; + + actualVolume = await getVideoVolume(browser, videoID); + expectedVolume = 0.9; + + isfuzzy( + actualVolume, + expectedVolume, + Number.EPSILON, + `The actual volume ${actualVolume}. The expected volume ${expectedVolume}` + ); + + // Test that right arrow key changes volume by +0.1 and does not exceed 1 + EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin); + + actualVolume = await getVideoVolume(browser, videoID); + expectedVolume = 1; + + isfuzzy( + actualVolume, + expectedVolume, + Number.EPSILON, + `The actual volume ${actualVolume}. The expected volume ${expectedVolume}` + ); + + // Test that the mouse can move the audio scrubber to 0.5 + let rect = audioScrubber.getBoundingClientRect(); + + volumeChangePromise = BrowserTestUtils.waitForContentEvent( + browser, + "volumechange", + true + ); + + EventUtils.synthesizeMouse( + audioScrubber, + rect.width / 2, + rect.height / 2, + {}, + pipWin + ); + + await volumeChangePromise; + + actualVolume = await getVideoVolume(browser, videoID); + expectedVolume = 0.5; + + isfuzzy( + actualVolume, + expectedVolume, + 0.01, + `The actual volume ${actualVolume}. The expected volume ${expectedVolume}` + ); + + // Test muting and unmuting the video + let muteButton = pipWin.document.getElementById("audio"); + volumeChangePromise = BrowserTestUtils.waitForContentEvent( + browser, + "volumechange", + true + ); + muteButton.click(); + await volumeChangePromise; + + ok( + await getVideoMuted(browser, videoID), + "The video is muted aftering clicking the mute button" + ); + is( + audioScrubber.max, + "0", + "The max of the audio scrubber is 0, so the volume slider appears that the volume is 0" + ); + + volumeChangePromise = BrowserTestUtils.waitForContentEvent( + browser, + "volumechange", + true + ); + muteButton.click(); + await volumeChangePromise; + + ok( + !(await getVideoMuted(browser, videoID)), + "The video is muted aftering clicking the mute button" + ); + isfuzzy( + actualVolume, + expectedVolume, + 0.01, + `The volume is still ${actualVolume}.` + ); + + volumeChangePromise = BrowserTestUtils.waitForContentEvent( + browser, + "volumechange", + true + ); + + // Test that the audio scrubber can be dragged from 0.5 to 0 and the video gets muted + EventUtils.synthesizeMouse( + audioScrubber, + rect.width / 2, + rect.height / 2, + { type: "mousedown" }, + pipWin + ); + EventUtils.synthesizeMouse( + audioScrubber, + 0, + rect.height / 2, + { type: "mousemove" }, + pipWin + ); + EventUtils.synthesizeMouse( + audioScrubber, + 0, + rect.height / 2, + { type: "mouseup" }, + pipWin + ); + + await volumeChangePromise; + + actualVolume = await getVideoVolume(browser, videoID); + expectedVolume = 0; + + isfuzzy( + actualVolume, + expectedVolume, + Number.EPSILON, + `The actual volume ${actualVolume}. The expected volume ${expectedVolume}` + ); + + ok( + await getVideoMuted(browser, videoID), + "Video is now muted because slider was moved to 0" + ); + + // Test that the left arrow key does not unmute the video and the volume remains at 0 + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin); + + actualVolume = await getVideoVolume(browser, videoID); + + isfuzzy( + actualVolume, + expectedVolume, + Number.EPSILON, + `The actual volume ${actualVolume}. The expected volume ${expectedVolume}` + ); + + ok( + await getVideoMuted(browser, videoID), + "Video is now muted because slider is still at 0" + ); + + volumeChangePromise = BrowserTestUtils.waitForContentEvent( + browser, + "volumechange", + true + ); + + // Test that the right arrow key will increase the volume by +0.1 and will unmute the video + EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin); + + await volumeChangePromise; + + actualVolume = await getVideoVolume(browser, videoID); + expectedVolume = 0.1; + + isfuzzy( + actualVolume, + expectedVolume, + Number.EPSILON, + `The actual volume ${actualVolume}. The expected volume ${expectedVolume}` + ); + + ok( + !(await getVideoMuted(browser, videoID)), + "Video is no longer muted because we moved the slider" + ); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_backgroundTab.js b/toolkit/components/pictureinpicture/tests/browser_backgroundTab.js new file mode 100644 index 0000000000..e1f96748a5 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_backgroundTab.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +/** + * This test creates a PiP window, then switches to another tab and confirms + * that the PiP tab is still active. + */ +add_task(async () => { + let videoID = "no-controls"; + let firstTab = gBrowser.selectedTab; + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let originatingTab = gBrowser.getTabForBrowser(browser); + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + await BrowserTestUtils.switchTab(gBrowser, firstTab); + + let switcher = gBrowser._getSwitcher(); + + Assert.equal( + switcher.getTabState(originatingTab), + switcher.STATE_LOADED, + "The originating browser tab should be in STATE_LOADED." + ); + Assert.equal( + browser.docShellIsActive, + true, + "The docshell should be active in the originating tab" + ); + + // We need to destroy the current AsyncTabSwitcher to avoid + // tabrowser.shouldActivateDocShell going in the + // AsyncTabSwitcher.shouldActivateDocShell code path which isn't PiP aware. + switcher.destroy(); + + // Closing with window.close doesn't actually pause the video, so click + // the close button instead. + pipWin.document.getElementById("close").click(); + await BrowserTestUtils.windowClosed(pipWin); + + Assert.equal( + browser.docShellIsActive, + false, + "The docshell should be inactive in the originating tab" + ); + } + ); +}); + +/** + * This test creates a PiP window, then minimizes the browser and confirms + * that the PiP tab is still active. + */ +add_task(async () => { + let videoID = "no-controls"; + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let originatingTab = gBrowser.getTabForBrowser(browser); + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let promiseSizeModeChange = BrowserTestUtils.waitForEvent( + window, + "sizemodechange" + ); + window.minimize(); + await promiseSizeModeChange; + + let switcher = gBrowser._getSwitcher(); + + Assert.equal( + switcher.getTabState(originatingTab), + switcher.STATE_LOADED, + "The originating browser tab should be in STATE_LOADED." + ); + + await BrowserTestUtils.closeWindow(pipWin); + + // Workaround bug 1782134. + window.restore(); + } + ); +}); 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_changePiPSrcInFullscreen.js b/toolkit/components/pictureinpicture/tests/browser_changePiPSrcInFullscreen.js new file mode 100644 index 0000000000..e971f17296 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_changePiPSrcInFullscreen.js @@ -0,0 +1,511 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const NEW_VIDEO_ASPECT_RATIO = 1.334; + +async function switchVideoSource(browser, src) { + await ContentTask.spawn(browser, { src }, async ({ src }) => { + let doc = content.document; + let video = doc.getElementById("no-controls"); + video.src = src; + }); +} + +/** + * + * @param {Object} actual The actual size and position of the window + * @param {Object} expected The expected size and position of the window + * @param {String} message A message to print before asserting the size and position + */ +function assertEvent(actual, expected, message) { + info(message); + isfuzzy( + actual.width, + expected.width, + ACCEPTABLE_DIFFERENCE, + `The actual width: ${actual.width}. The expected width: ${expected.width}` + ); + isfuzzy( + actual.height, + expected.height, + ACCEPTABLE_DIFFERENCE, + `The actual height: ${actual.height}. The expected height: ${expected.height}` + ); + isfuzzy( + actual.left, + expected.left, + ACCEPTABLE_DIFFERENCE, + `The actual left: ${actual.left}. The expected left: ${expected.left}` + ); + isfuzzy( + actual.top, + expected.top, + ACCEPTABLE_DIFFERENCE, + `The actual top: ${actual.top}. The expected top: ${expected.top}` + ); +} + +/** + * This test is our control test. This tests that when the PiP window exits + * fullscreen it will return to the size it was before being fullscreened. + */ +add_task(async function testNoSrcChangeFullscreen() { + // After opening the PiP window, it is resized to 640x360. There is a change + // the PiP window will open with that size. To prevent that we override the + // last saved position so we open at (0, 0) and 300x300. + overrideSavedPosition(0, 0, 300, 300); + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + let pipWin = await triggerPictureInPicture(browser, "no-controls"); + let controls = pipWin.document.getElementById("controls"); + const screen = pipWin.screen; + + let resizeEventArray = []; + pipWin.addEventListener("resize", event => { + let win = event.target; + let obj = { + width: win.innerWidth, + height: win.innerHeight, + left: win.screenLeft, + top: win.screenTop, + }; + resizeEventArray.push(obj); + }); + + // Move the PiP window to an unsaved location + let left = 100; + let top = 100; + pipWin.moveTo(left, top); + + await BrowserTestUtils.waitForCondition( + () => pipWin.screenLeft === 100 && pipWin.screenTop === 100, + "Waiting for PiP to move to 100, 100" + ); + + let width = 640; + let height = 360; + + let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize"); + pipWin.resizeTo(width, height); + await resizePromise; + + Assert.equal( + resizeEventArray.length, + 1, + "resizeEventArray should have 1 event" + ); + + let actualEvent = resizeEventArray.splice(0, 1)[0]; + let expectedEvent = { width, height, left, top }; + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly positioned before fullscreen" + ); + + await promiseFullscreenEntered(pipWin, async () => { + EventUtils.sendMouseEvent( + { + type: "dblclick", + }, + controls, + pipWin + ); + }); + + Assert.equal( + pipWin.document.fullscreenElement, + pipWin.document.body, + "Double-click caused us to enter fullscreen." + ); + + await BrowserTestUtils.waitForCondition( + () => resizeEventArray.length === 1, + "Waiting for resizeEventArray to have 1 event" + ); + + actualEvent = resizeEventArray.splice(0, 1)[0]; + expectedEvent = { + width: screen.width, + height: screen.height, + left: screen.left, + top: screen.top, + }; + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly fullscreened before switching source" + ); + + await promiseFullscreenExited(pipWin, async () => { + EventUtils.sendMouseEvent( + { + type: "dblclick", + }, + controls, + pipWin + ); + }); + + Assert.ok( + !pipWin.document.fullscreenElement, + "Double-click caused us to exit fullscreen." + ); + + await BrowserTestUtils.waitForCondition( + () => resizeEventArray.length >= 1, + "Waiting for resizeEventArray to have 1 event, got " + + resizeEventArray.length + ); + + actualEvent = resizeEventArray.splice(0, 1)[0]; + expectedEvent = { width, height, left, top }; + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly positioned after exiting fullscreen" + ); + + await ensureMessageAndClosePiP(browser, "no-controls", pipWin, false); + + clearSavedPosition(); + } + ); +}); + +/** + * This function tests changing the src of a Picture-in-Picture player while + * the player is fullscreened and then ensuring the that video stays + * fullscreened after the src change and that the player will resize to the new + * video size. + */ +add_task(async function testChangingSameSizeVideoSrcFullscreen() { + // After opening the PiP window, it is resized to 640x360. There is a change + // the PiP window will open with that size. To prevent that we override the + // last saved position so we open at (0, 0) and 300x300. + overrideSavedPosition(0, 0, 300, 300); + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + let pipWin = await triggerPictureInPicture(browser, "no-controls"); + let controls = pipWin.document.getElementById("controls"); + const screen = pipWin.screen; + let sandbox = sinon.createSandbox(); + let resizeToVideoSpy = sandbox.spy(pipWin, "resizeToVideo"); + + let resizeEventArray = []; + pipWin.addEventListener("resize", event => { + let win = event.target; + let obj = { + width: win.innerWidth, + height: win.innerHeight, + left: win.screenLeft, + top: win.screenTop, + }; + resizeEventArray.push(obj); + }); + + // Move the PiP window to an unsaved location + let left = 100; + let top = 100; + pipWin.moveTo(left, top); + + await BrowserTestUtils.waitForCondition( + () => pipWin.screenLeft === 100 && pipWin.screenTop === 100, + "Waiting for PiP to move to 100, 100" + ); + + let width = 640; + let height = 360; + + let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize"); + pipWin.resizeTo(width, height); + await resizePromise; + + Assert.equal( + resizeEventArray.length, + 1, + "resizeEventArray should have 1 event" + ); + + let actualEvent = resizeEventArray.splice(0, 1)[0]; + let expectedEvent = { width, height, left, top }; + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly positioned before fullscreen" + ); + + await promiseFullscreenEntered(pipWin, async () => { + EventUtils.sendMouseEvent( + { + type: "dblclick", + }, + controls, + pipWin + ); + }); + + Assert.equal( + pipWin.document.fullscreenElement, + pipWin.document.body, + "Double-click caused us to enter fullscreen." + ); + + await BrowserTestUtils.waitForCondition( + () => resizeEventArray.length === 1, + "Waiting for resizeEventArray to have 1 event" + ); + + actualEvent = resizeEventArray.splice(0, 1)[0]; + expectedEvent = { + width: screen.width, + height: screen.height, + left: screen.left, + top: screen.top, + }; + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly fullscreened before switching source" + ); + + await switchVideoSource(browser, "test-video.mp4"); + + await BrowserTestUtils.waitForCondition( + () => resizeToVideoSpy.calledOnce, + "Waiting for deferredResize to be updated" + ); + + await promiseFullscreenExited(pipWin, async () => { + EventUtils.sendMouseEvent( + { + type: "dblclick", + }, + controls, + pipWin + ); + }); + + Assert.ok( + !pipWin.document.fullscreenElement, + "Double-click caused us to exit fullscreen." + ); + + await BrowserTestUtils.waitForCondition( + () => resizeEventArray.length >= 1, + "Waiting for resizeEventArray to have 1 event, got " + + resizeEventArray.length + ); + + actualEvent = resizeEventArray.splice(0, 1)[0]; + expectedEvent = { width, height, left, top }; + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly positioned after exiting fullscreen" + ); + + sandbox.restore(); + await ensureMessageAndClosePiP(browser, "no-controls", pipWin, false); + + clearSavedPosition(); + } + ); +}); + +/** + * This is similar to the previous test but in this test we switch to a video + * with a different aspect ratio to confirm that the PiP window will take the + * new aspect ratio after exiting fullscreen. We also exit fullscreen with the + * escape key instead of double clicking in this test. + */ +add_task(async function testChangingDifferentSizeVideoSrcFullscreen() { + // After opening the PiP window, it is resized to 640x360. There is a change + // the PiP window will open with that size. To prevent that we override the + // last saved position so we open at (0, 0) and 300x300. + overrideSavedPosition(0, 0, 300, 300); + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + let pipWin = await triggerPictureInPicture(browser, "no-controls"); + let controls = pipWin.document.getElementById("controls"); + const screen = pipWin.screen; + let sandbox = sinon.createSandbox(); + let resizeToVideoSpy = sandbox.spy(pipWin, "resizeToVideo"); + + let resizeEventArray = []; + pipWin.addEventListener("resize", event => { + let win = event.target; + let obj = { + width: win.innerWidth, + height: win.innerHeight, + left: win.screenLeft, + top: win.screenTop, + }; + resizeEventArray.push(obj); + }); + + // Move the PiP window to an unsaved location + let left = 100; + let top = 100; + pipWin.moveTo(left, top); + + await BrowserTestUtils.waitForCondition( + () => pipWin.screenLeft === 100 && pipWin.screenTop === 100, + "Waiting for PiP to move to 100, 100" + ); + + let width = 640; + let height = 360; + + let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize"); + pipWin.resizeTo(width, height); + await resizePromise; + + Assert.equal( + resizeEventArray.length, + 1, + "resizeEventArray should have 1 event" + ); + + let actualEvent = resizeEventArray.splice(0, 1)[0]; + let expectedEvent = { width, height, left, top }; + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly positioned before fullscreen" + ); + + await promiseFullscreenEntered(pipWin, async () => { + EventUtils.sendMouseEvent( + { + type: "dblclick", + }, + controls, + pipWin + ); + }); + + Assert.equal( + pipWin.document.fullscreenElement, + pipWin.document.body, + "Double-click caused us to enter fullscreen." + ); + + await BrowserTestUtils.waitForCondition( + () => resizeEventArray.length === 1, + "Waiting for resizeEventArray to have 1 event" + ); + + actualEvent = resizeEventArray.splice(0, 1)[0]; + expectedEvent = { + width: screen.width, + height: screen.height, + left: screen.left, + top: screen.top, + }; + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly fullscreened before switching source" + ); + + let previousWidth = pipWin.getDeferredResize().width; + + await switchVideoSource(browser, "test-video-long.mp4"); + + // Confirm that we are updating the `deferredResize` and not actually resizing + await BrowserTestUtils.waitForCondition( + () => resizeToVideoSpy.calledOnce, + "Waiting for deferredResize to be updated" + ); + + // Confirm that we updated the deferredResize to the new width + await BrowserTestUtils.waitForCondition( + () => previousWidth !== pipWin.getDeferredResize().width, + "Waiting for deferredResize to update" + ); + + await promiseFullscreenExited(pipWin, async () => { + EventUtils.synthesizeKey("KEY_Escape", {}, pipWin); + }); + + Assert.ok( + !pipWin.document.fullscreenElement, + "Escape key caused us to exit fullscreen." + ); + + await BrowserTestUtils.waitForCondition( + () => resizeEventArray.length >= 1, + "Waiting for resizeEventArray to have 1 event, got " + + resizeEventArray.length + ); + + actualEvent = resizeEventArray.splice(0, 1)[0]; + expectedEvent = { + width: height * NEW_VIDEO_ASPECT_RATIO, + height, + left, + top, + }; + + // When two resize events happen very close together we optimize by + // "coalescing" the two resizes into a single resize event. Sometimes + // the events aren't "coalesced" together (I don't know why) so I check + // if the most recent event is what we are looking for and if it is not + // then I'll wait for the resize event we are looking for. + if ( + Math.abs( + actualEvent.width - expectedEvent.width <= ACCEPTABLE_DIFFERENCE + ) + ) { + // The exit fullscreen resize events were "coalesced". + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly positioned after exiting fullscreen" + ); + } else { + // For some reason the exit fullscreen resize events weren't "coalesced" + // so we have to wait for the next resize event. + await BrowserTestUtils.waitForCondition( + () => resizeEventArray.length === 1, + "Waiting for resizeEventArray to have 1 event" + ); + + actualEvent = resizeEventArray.splice(0, 1)[0]; + + assertEvent( + actualEvent, + expectedEvent, + "The PiP window has been correctly positioned after exiting fullscreen" + ); + } + + sandbox.restore(); + await ensureMessageAndClosePiP(browser, "no-controls", pipWin, false); + + clearSavedPosition(); + } + ); +}); 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_closePip_pageNavigationChanges.js b/toolkit/components/pictureinpicture/tests/browser_closePip_pageNavigationChanges.js new file mode 100644 index 0000000000..c64b5f2b14 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_closePip_pageNavigationChanges.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the pip window closes when the pagehide page lifecycle event + * is not detected and if a video is not loaded with a src. + */ +add_task(async function test_close_empty_pip_window() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let videoID = "with-controls"; + + await ensureVideosReady(browser); + + let emptied = SpecialPowers.spawn(browser, [{ videoID }], async args => { + let video = content.document.getElementById(args.videoID); + info("Waiting for emptied event to be called"); + await ContentTaskUtils.waitForEvent(video, "emptied"); + }); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + await SpecialPowers.spawn(browser, [{ videoID }], async args => { + let video = content.document.getElementById(args.videoID); + video.removeAttribute("src"); + video.load(); + }); + await emptied; + await pipClosed; + } + ); +}); + +/** + * Tests that the pip window closes when navigating to another page + * via the pagehide page lifecycle event. + */ +add_task(async function test_close_pagehide() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let videoID = "with-controls"; + + await ensureVideosReady(browser); + await SpecialPowers.spawn(browser, [{ videoID }], async args => { + let video = content.document.getElementById(args.videoID); + video.onemptied = () => { + // Since we handle pagehide first, handle emptied should not be invoked + ok(false, "emptied not expected to be called"); + }; + }); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + await SpecialPowers.spawn(browser, [{ videoID }], async args => { + content.location.href = "otherpage.html"; + }); + + await pipClosed; + } + ); +}); + +/** + * Tests that the pip window remains open if the pagehide page lifecycle + * event is not detected and if the video is still loaded with a src. + */ +add_task(async function test_open_pip_window_history_nav() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + let videoID = "with-controls"; + + await ensureVideosReady(browser); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + await SpecialPowers.spawn(browser, [{ videoID }], async args => { + let popStatePromise = ContentTaskUtils.waitForEvent( + content, + "popstate" + ); + content.history.pushState({}, "new page", "test-page-with-sound.html"); + content.history.back(); + await popStatePromise; + }); + + ok(!pipWin.closed, "pip windows should still be open"); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("close"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + await pipClosed; + } + ); +}); 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_close_unpip_focus.js b/toolkit/components/pictureinpicture/tests/browser_close_unpip_focus.js new file mode 100644 index 0000000000..f535add96a --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_close_unpip_focus.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that closing a pip window will not focus on the originating video's window. +add_task(async function test_close_button_focus() { + // initialize + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + // Open PiP + let videoID = "with-controls"; + let pipTab = await BrowserTestUtils.openNewForegroundTab( + win1.gBrowser, + TEST_PAGE + ); + let browser = pipTab.linkedBrowser; + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let focus = BrowserTestUtils.waitForEvent(win2, "focus", true); + win2.focus(); + await focus; + + // Close PiP + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("close"); + let oldFocus = win1.focus; + win1.focus = () => { + ok(false, "Window is not supposed to be focused on"); + }; + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + await pipClosed; + ok(true, "Window did not get focus"); + + win1.focus = oldFocus; + // close windows + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); +}); + +// Tests that pressing the unpip button will focus on the originating video's window. +add_task(async function test_unpip_button_focus() { + // initialize + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + // Open PiP + let videoID = "with-controls"; + let pipTab = await BrowserTestUtils.openNewForegroundTab( + win1.gBrowser, + TEST_PAGE + ); + let browser = pipTab.linkedBrowser; + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let focus = BrowserTestUtils.waitForEvent(win2, "focus", true); + win2.focus(); + await focus; + + // Close PiP + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("unpip"); + let pipWinFocusedPromise = BrowserTestUtils.waitForEvent(win1, "focus", true); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + + await pipClosed; + await pipWinFocusedPromise; + ok(true, "Originating window got focus"); + + // close windows + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_conflictingPips.js b/toolkit/components/pictureinpicture/tests/browser_conflictingPips.js new file mode 100644 index 0000000000..cf9e27e327 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_conflictingPips.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * If multiple PiPs try to open in the same place, they should not overlap. + */ +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"); + + await ensureMessageAndClosePiP(browser, "with-controls", firstPip, false); + info("Closed first PiP to save location"); + + let secondPip = await triggerPictureInPicture(browser, "with-controls"); + ok(secondPip, "Got second PiP window"); + + let thirdPip = await triggerPictureInPicture(browser, "no-controls"); + ok(thirdPip, "Got third PiP window"); + + Assert.ok( + secondPip.screenX != thirdPip.screenX || + secondPip.screenY != thirdPip.screenY, + "Conflicting PiPs were successfully opened in different locations" + ); + + await ensureMessageAndClosePiP( + browser, + "with-controls", + secondPip, + false + ); + info("Second PiP was still open and is now closed"); + + await ensureMessageAndClosePiP(browser, "no-controls", thirdPip, false); + info("Third PiP was still open and is now closed"); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_contextMenu.js b/toolkit/components/pictureinpicture/tests/browser_contextMenu.js new file mode 100644 index 0000000000..ce6a85e91c --- /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 => { + let contextMenu = 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." + ); + + contextMenu.activateItem(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."); + + contextMenu = await openContextMenu(browser, videoId); + + info("Context menu is open again."); + + contextMenu.activateItem(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_controlsHover.js b/toolkit/components/pictureinpicture/tests/browser_controlsHover.js new file mode 100644 index 0000000000..7a3a33733f --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_controlsHover.js @@ -0,0 +1,191 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests functionality for the hover states of the various controls for the Picture-in-Picture + * video window. + */ +add_task(async () => { + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + 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."); + + const l10n = new Localization( + ["toolkit/pictureinpicture/pictureinpicture.ftl"], + true + ); + + let [ + close, + play, + unmute, + unpip, + subtitles, + pause, + mute, + fullscreenEnter, + fullscreenExit, + ] = l10n.formatMessagesSync([ + { + id: "pictureinpicture-close-btn", + args: { + shortcut: ShortcutUtils.prettifyShortcut( + pipWin.document.getElementById("closeShortcut") + ), + }, + }, + { id: "pictureinpicture-play-btn" }, + { + id: "pictureinpicture-unmute-btn", + args: { + shortcut: ShortcutUtils.prettifyShortcut( + pipWin.document.getElementById("unMuteShortcut") + ), + }, + }, + { id: "pictureinpicture-unpip-btn" }, + { id: "pictureinpicture-subtitles-btn" }, + { id: "pictureinpicture-pause-btn" }, + { + id: "pictureinpicture-mute-btn", + args: { + shortcut: ShortcutUtils.prettifyShortcut( + pipWin.document.getElementById("muteShortcut") + ), + }, + }, + { + id: "pictureinpicture-fullscreen-btn2", + args: { + shortcut: ShortcutUtils.prettifyShortcut( + pipWin.document.getElementById("fullscreenToggleShortcut") + ), + }, + }, + { + id: "pictureinpicture-exit-fullscreen-btn2", + args: { + shortcut: ShortcutUtils.prettifyShortcut( + pipWin.document.getElementById("fullscreenToggleShortcut") + ), + }, + }, + ]); + + let closeButton = pipWin.document.getElementById("close"); + let playPauseButton = pipWin.document.getElementById("playpause"); + let unpipButton = pipWin.document.getElementById("unpip"); + let muteUnmuteButton = pipWin.document.getElementById("audio"); + let subtitlesButton = pipWin.document.getElementById("closed-caption"); + let fullscreenButton = pipWin.document.getElementById("fullscreen"); + + // checks hover title for close button + await pipWin.document.l10n.translateFragment(closeButton); + Assert.equal( + close.attributes[1].value, + closeButton.getAttribute("tooltip"), + "The close button title matches Fluent string" + ); + + // checks hover title for play button + await pipWin.document.l10n.translateFragment(playPauseButton); + Assert.equal( + pause.attributes[1].value, + playPauseButton.getAttribute("tooltip"), + "The play button title matches Fluent string" + ); + + // checks hover title for unpip button + await pipWin.document.l10n.translateFragment(unpipButton); + Assert.equal( + unpip.attributes[1].value, + unpipButton.getAttribute("tooltip"), + "The unpip button title matches Fluent string" + ); + + // checks hover title for subtitles button + await pipWin.document.l10n.translateFragment(subtitlesButton); + Assert.equal( + subtitles.attributes[1].value, + subtitlesButton.getAttribute("tooltip"), + "The subtitles button title matches Fluent string" + ); + + // checks hover title for unmute button + await pipWin.document.l10n.translateFragment(muteUnmuteButton); + Assert.equal( + mute.attributes[1].value, + muteUnmuteButton.getAttribute("tooltip"), + "The Unmute button title matches Fluent string" + ); + + // Pause the video + let pausedPromise = waitForVideoEvent("pause"); + EventUtils.synthesizeMouseAtCenter(playPauseButton, {}, pipWin); + await pausedPromise; + ok(await isVideoPaused(browser, videoID), "The video is paused."); + + // checks hover title for pause button + await pipWin.document.l10n.translateFragment(playPauseButton); + Assert.equal( + play.attributes[1].value, + playPauseButton.getAttribute("tooltip"), + "The pause button title matches Fluent string" + ); + + // Mute the video + let mutedPromise = waitForVideoEvent("volumechange"); + EventUtils.synthesizeMouseAtCenter(muteUnmuteButton, {}, pipWin); + await mutedPromise; + ok(await isVideoMuted(browser, videoID), "The audio is muted."); + + // checks hover title for mute button + await pipWin.document.l10n.translateFragment(muteUnmuteButton); + Assert.equal( + unmute.attributes[1].value, + muteUnmuteButton.getAttribute("tooltip"), + "The mute button title matches Fluent string" + ); + + // checks hover title for enter fullscreen button + await pipWin.document.l10n.translateFragment(fullscreenButton); + Assert.equal( + fullscreenEnter.attributes[1].value, + fullscreenButton.getAttribute("tooltip"), + "The enter fullscreen button title matches Fluent string" + ); + + // enable fullscreen + await promiseFullscreenEntered(pipWin, async () => { + EventUtils.synthesizeMouseAtCenter(fullscreenButton, {}, pipWin); + }); + + // checks hover title for exit fullscreen button + await pipWin.document.l10n.translateFragment(fullscreenButton); + Assert.equal( + fullscreenExit.attributes[1].value, + fullscreenButton.getAttribute("tooltip"), + "The exit fullscreen button title matches Fluent string" + ); + } + ); +}); 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..c30d316fe3 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js @@ -0,0 +1,101 @@ +/* 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 () => { + EventUtils.sendMouseEvent( + { + type: "dblclick", + }, + controls, + pipWin + ); + }); + + Assert.equal( + pipWin.document.fullscreenElement, + pipWin.document.body, + "Double-click caused us to enter fullscreen." + ); + + await BrowserTestUtils.waitForCondition( + () => { + let close = pipWin.document.getElementById("close"); + let opacity = parseFloat(pipWin.getComputedStyle(close).opacity); + return opacity == 0.0; + }, + "Close button in player should have reached 0.0 opacity", + 100, + 100 + ); + + // First, we'll test exiting fullscreen by double-clicking again + // on the document body. + + await promiseFullscreenExited(pipWin, async () => { + 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 () => { + 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_disableSwipeGestures.js b/toolkit/components/pictureinpicture/tests/browser_disableSwipeGestures.js new file mode 100644 index 0000000000..b8eddf33f2 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_disableSwipeGestures.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/gfx/layers/apz/test/mochitest/apz_test_native_event_utils.js", + this +); + +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let pipWin = await triggerPictureInPicture(browser, "with-controls"); + + let receivedSwipeGestureEvents = false; + pipWin.addEventListener("MozSwipeGestureMayStart", () => { + receivedSwipeGestureEvents = true; + }); + + const wheelEventPromise = BrowserTestUtils.waitForEvent(pipWin, "wheel"); + + // Try swiping left to right. + await panLeftToRightBegin(pipWin, 100, 100, 100); + await panLeftToRightEnd(pipWin, 100, 100, 100); + + // Wait a wheel event and a couple of frames to give a chance to receive + // the MozSwipeGestureMayStart event. + await wheelEventPromise; + await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise(resolve => requestAnimationFrame(resolve)); + + Assert.ok( + !receivedSwipeGestureEvents, + "No swipe gesture events observed" + ); + + 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_fontSize_change.js b/toolkit/components/pictureinpicture/tests/browser_fontSize_change.js new file mode 100644 index 0000000000..df0fa4f403 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_fontSize_change.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const videoID = "with-controls"; +const TEXT_TRACK_FONT_SIZE = + "media.videocontrols.picture-in-picture.display-text-tracks.size"; +const ACCEPTABLE_DIFF = 1; + +const checkFontSize = (actual, expected, str) => { + let fs1 = actual.substring(0, actual.length - 2); + let fs2 = expected; + let diff = Math.abs(fs1 - fs2); + info(`Actual font size: ${fs1}. Expected font size: ${fs2}`); + Assert.lessOrEqual(diff, ACCEPTABLE_DIFF, str); +}; + +function getFontSize(pipBrowser) { + return SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + ok(textTracks, "TextTracks container should exist in the pip window"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + return content.window.getComputedStyle(textTracks).fontSize; + }); +} + +function promiseResize(win, width, height) { + if (win.outerWidth == width && win.outerHeight == height) { + return Promise.resolve(); + } + return new Promise(resolve => { + // More than one "resize" might be received if the window was recently + // created. + win.addEventListener("resize", () => { + if (win.outerWidth == width && win.outerHeight == height) { + resolve(); + } + }); + win.resizeTo(width, height); + }); +} + +/** + * Tests the font size is correct for PiP windows + */ +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE_WITH_WEBVTT, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + // move PiP window to 0, 0 so resizing the window doesn't go offscreen + pipWin.moveTo(0, 0); + + let width = pipWin.innerWidth; + let height = pipWin.innerHeight; + + await promiseResize(pipWin, Math.round(250 * (width / height)), 250); + + width = pipWin.innerWidth; + height = pipWin.innerHeight; + + let pipBrowser = pipWin.document.getElementById("browser"); + + let fontSize = await getFontSize(pipBrowser); + checkFontSize( + fontSize, + Math.round(height * 0.06 * 10) / 10, + "The medium font size is .06 of the PiP window height" + ); + + await ensureMessageAndClosePiP(browser, videoID, pipWin, false); + + // change font size to small + await SpecialPowers.pushPrefEnv({ + set: [[TEXT_TRACK_FONT_SIZE, "small"]], + }); + + pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + pipBrowser = pipWin.document.getElementById("browser"); + + fontSize = await getFontSize(pipBrowser); + checkFontSize(fontSize, 14, "The small font size is the minimum 14px"); + + await ensureMessageAndClosePiP(browser, videoID, pipWin, false); + + // change font size to large + await SpecialPowers.pushPrefEnv({ + set: [[TEXT_TRACK_FONT_SIZE, "large"]], + }); + + pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + pipBrowser = pipWin.document.getElementById("browser"); + + fontSize = await getFontSize(pipBrowser); + checkFontSize( + fontSize, + Math.round(height * 0.09 * 10) / 10, + "The large font size is .09 of the PiP window height" + ); + + // resize PiP window to a larger size + width = pipWin.innerWidth * 2; + height = pipWin.innerHeight * 2; + await promiseResize(pipWin, width, height); + + fontSize = await getFontSize(pipBrowser); + checkFontSize(fontSize, 40, "The large font size is the max of 40px"); + + await ensureMessageAndClosePiP(browser, videoID, pipWin, false); + + // change font size to small + await SpecialPowers.pushPrefEnv({ + set: [[TEXT_TRACK_FONT_SIZE, "small"]], + }); + + pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + pipBrowser = pipWin.document.getElementById("browser"); + + fontSize = await getFontSize(pipBrowser); + checkFontSize( + fontSize, + Math.round(height * 0.03 * 10) / 10, + "The small font size is .03 of the PiP window height" + ); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_fullscreen.js b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js new file mode 100644 index 0000000000..439901c0f3 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_fullscreen.js @@ -0,0 +1,161 @@ +/* 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) { + info(`Start test of video fullscreen for video ${videoID}.`); + await promiseFullscreenEntered(window, () => { + return SpecialPowers.spawn(browser, [videoID], videoID => { + let video = this.content.document.getElementById(videoID); + return video.requestFullscreen(); + }); + }); + + info(`Entered video fullscreen, about to mouseover the video.`); + + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mouseover", + }, + browser + ); + + info(`Mouseover complete.`); + + let args = { videoID, toggleID: DEFAULT_TOGGLE_STYLES.rootID }; + + await promiseFullscreenExited(window, () => { + return SpecialPowers.spawn(browser, [args], args => { + let { videoID, toggleID } = args; + let video = this.content.document.getElementById(videoID); + let toggle = video.openOrClosedShadowRoot.getElementById(toggleID); + ok( + ContentTaskUtils.isHidden(toggle), + `Toggle should be hidden in video fullscreen mode for video ${videoID}.` + ); + return this.content.document.exitFullscreen(); + }); + }); + + info(`Exited video fullscreen.`); + } + } + ); +}); + +/** + * 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 => { + info(`Start test of browser fullscreen.`); + await promiseFullscreenEntered(window, () => { + return SpecialPowers.spawn(browser, [], () => { + return this.content.document.body.requestFullscreen(); + }); + }); + + info(`Entered browser fullscreen.`); + + for (let videoID of VIDEOS) { + info(`About to mouseover for video ${videoID}.`); + + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${videoID}`, + { + type: "mouseover", + }, + browser + ); + + info(`Mouseover complete.`); + + 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.isHidden(toggle), + `Toggle should be hidden in body fullscreen mode for video ${videoID}.` + ); + }); + } + + await promiseFullscreenExited(window, () => { + return SpecialPowers.spawn(browser, [], () => { + return this.content.document.exitFullscreen(); + }); + }); + + info(`Exited browser fullscreen.`); + } + ); +}); + +/** + * 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 for video ${videoID}.`); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + + // need to focus first, since fullscreen request will be blocked otherwise + await SimpleTest.promiseFocus(window); + + await promiseFullscreenEntered(window, () => { + return SpecialPowers.spawn(browser, [], () => { + return this.content.document.body.requestFullscreen(); + }); + }); + + await pipClosed; + ok( + pipWin.closed, + `Picture-In-Picture successfully closed for video ${videoID}.` + ); + + await promiseFullscreenExited(window, () => { + return SpecialPowers.spawn(browser, [], () => { + return this.content.document.exitFullscreen(); + }); + }); + } + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_improved_controls.js b/toolkit/components/pictureinpicture/tests/browser_improved_controls.js new file mode 100644 index 0000000000..deadc8d53b --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_improved_controls.js @@ -0,0 +1,303 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const TEST_PAGE_LONG = TEST_ROOT + "test-video-selection.html"; + +const IMPROVED_CONTROLS_ENABLED_PREF = + "media.videocontrols.picture-in-picture.improved-video-controls.enabled"; + +async function getVideoCurrentTime(browser, videoID) { + return SpecialPowers.spawn(browser, [videoID], async videoID => { + return content.document.getElementById(videoID).currentTime; + }); +} + +async function getVideoDuration(browser, videoID) { + return SpecialPowers.spawn(browser, [videoID], async videoID => { + return content.document.getElementById(videoID).duration; + }); +} + +async function timestampUpdated(timestampEl, expectedTimestamp) { + await BrowserTestUtils.waitForMutationCondition( + timestampEl, + { childList: true }, + () => { + return expectedTimestamp === timestampEl.textContent; + } + ); +} + +function checkTimeCloseEnough(actual, expected, message) { + let equal = Math.abs(actual - expected); + if (equal <= 0.5) { + is(equal <= 0.5, true, message); + } else { + is(actual, expected, message); + } +} + +/** + * Tests the functionality of improved Picture-in-picture + * playback controls. + */ +add_task(async () => { + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + 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(); + }); + + await SpecialPowers.pushPrefEnv({ + set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]], + }); + + // Open the video in PiP + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let fullscreenButton = pipWin.document.getElementById("fullscreen"); + let seekForwardButton = pipWin.document.getElementById("seekForward"); + let seekBackwardButton = pipWin.document.getElementById("seekBackward"); + + // Try seek forward button + let seekedForwardPromise = waitForVideoEvent("seeked"); + EventUtils.synthesizeMouseAtCenter(seekForwardButton, {}, pipWin); + ok(await seekedForwardPromise, "The Forward button triggers"); + + // Try seek backward button + let seekedBackwardPromise = waitForVideoEvent("seeked"); + EventUtils.synthesizeMouseAtCenter(seekBackwardButton, {}, pipWin); + ok(await seekedBackwardPromise, "The Backward button triggers"); + + // The Fullsreen button appears when the pref is enabled and the fullscreen hidden property is set to false + Assert.ok(!fullscreenButton.hidden, "The Fullscreen button is visible"); + + // The seek Forward button appears when the pref is enabled and the seek forward button hidden property is set to false + Assert.ok(!seekForwardButton.hidden, "The Forward button is visible"); + + // The seek Backward button appears when the pref is enabled and the seek backward button hidden property is set to false + Assert.ok(!seekBackwardButton.hidden, "The Backward button is visible"); + + // CLose the PIP window + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("close"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + await pipClosed; + + await SpecialPowers.pushPrefEnv({ + set: [[IMPROVED_CONTROLS_ENABLED_PREF, false]], + }); + + // Open the video in PiP + pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + fullscreenButton = pipWin.document.getElementById("fullscreen"); + seekForwardButton = pipWin.document.getElementById("seekForward"); + seekBackwardButton = pipWin.document.getElementById("seekBackward"); + + // The Fullsreen button disappears when the pref is disabled and the fullscreen hidden property is set to true + Assert.ok( + fullscreenButton.hidden, + "The Fullscreen button is not visible" + ); + + // The seek Forward button disappears when the pref is disabled and the seek forward button hidden property is set to true + Assert.ok(seekForwardButton.hidden, "The Forward button is not visible"); + + // The seek Backward button disappears when the pref is disabled and the seek backward button hidden property is set to true + Assert.ok( + seekBackwardButton.hidden, + "The Backward button is not visible" + ); + } + ); +}); + +/** + * Tests the functionality of Picture-in-picture + * video scrubber + */ +add_task(async function testVideoScrubber() { + let videoID = "long"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_LONG, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + await SpecialPowers.pushPrefEnv({ + set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]], + }); + + // Open the video in PiP + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let scrubber = pipWin.document.getElementById("scrubber"); + scrubber.focus(); + + let currentTime = await getVideoCurrentTime(browser, videoID); + let expectedVideoTime = 0; + const duration = await getVideoDuration(browser, videoID); + checkTimeCloseEnough( + currentTime, + expectedVideoTime, + "Video current time is 0" + ); + + let timestampEl = pipWin.document.getElementById("timestamp"); + let expectedTimestamp = "0:00 / 0:08"; + + // Wait for the timestamp to update + await timestampUpdated(timestampEl, expectedTimestamp); + let actualTimestamp = timestampEl.textContent; + is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:00 / 0:08"); + + EventUtils.synthesizeKey("KEY_ArrowRight", {}, pipWin); + + currentTime = await getVideoCurrentTime(browser, videoID); + expectedVideoTime = 5; + checkTimeCloseEnough( + currentTime, + expectedVideoTime, + "Video current time is 5" + ); + + expectedTimestamp = "0:05 / 0:08"; + await timestampUpdated(timestampEl, expectedTimestamp); + actualTimestamp = timestampEl.textContent; + is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:05 / 0:08"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, pipWin); + + currentTime = await getVideoCurrentTime(browser, videoID); + expectedVideoTime = 0; + checkTimeCloseEnough( + currentTime, + expectedVideoTime, + "Video current time is 0" + ); + + expectedTimestamp = "0:00 / 0:08"; + await timestampUpdated(timestampEl, expectedTimestamp); + actualTimestamp = timestampEl.textContent; + is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:00 / 0:08"); + + let rect = scrubber.getBoundingClientRect(); + + EventUtils.synthesizeMouse( + scrubber, + rect.width / 2, + rect.height / 2, + {}, + pipWin + ); + + expectedVideoTime = duration / 2; + currentTime = await getVideoCurrentTime(browser, videoID); + checkTimeCloseEnough( + currentTime, + expectedVideoTime, + "Video current time is 3.98..." + ); + + expectedTimestamp = "0:04 / 0:08"; + await timestampUpdated(timestampEl, expectedTimestamp); + actualTimestamp = timestampEl.textContent; + is(actualTimestamp, expectedTimestamp, "Timestamp reads 0:04 / 0:08"); + + EventUtils.synthesizeMouse( + scrubber, + rect.width / 2, + rect.height / 2, + { type: "mousedown" }, + pipWin + ); + + EventUtils.synthesizeMouse( + scrubber, + rect.width, + rect.height / 2, + { type: "mousemove" }, + pipWin + ); + + EventUtils.synthesizeMouse( + scrubber, + rect.width, + rect.height / 2, + { type: "mouseup" }, + pipWin + ); + + expectedVideoTime = duration; + currentTime = await getVideoCurrentTime(browser, videoID); + checkTimeCloseEnough( + currentTime, + expectedVideoTime, + "Video current time is 7.96..." + ); + + await ensureMessageAndClosePiP(browser, videoID, pipWin, false); + } + ); +}); + +/** + * Tests the behavior of the scrubber and position/duration indicator for a + * video with an invalid/non-finite duration. + */ +add_task(async function testInvalidDuration() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_NAN_VIDEO_DURATION, + gBrowser, + }, + async browser => { + const videoID = "nan-duration"; + + // This tests skips calling ensureVideosReady, because canplaythrough + // will never fire for the NaN duration video. + + await SpecialPowers.pushPrefEnv({ + set: [[IMPROVED_CONTROLS_ENABLED_PREF, true]], + }); + + // Open the video in PiP + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + // Both the scrubber and the duration should be hidden. + let timestampEl = pipWin.document.getElementById("timestamp"); + ok(timestampEl.hidden, "Timestamp in the PIP window should be hidden."); + + let scrubberEl = pipWin.document.getElementById("scrubber"); + ok( + scrubberEl.hidden, + "Scrubber control in the PIP window should be hidden" + ); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js b/toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js new file mode 100644 index 0000000000..14dd3d1b7a --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * tests that the ESC key would stop the player and close the PiP player floating window + */ +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(); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "The Picture-in-Picture window is not there."); + ok( + !(await isVideoPaused(browser, videoID)), + "The video is paused, but should not." + ); + ok( + !pipWin.document.fullscreenElement, + "PiP should not yet be in fullscreen." + ); + + let controls = pipWin.document.getElementById("controls"); + await promiseFullscreenEntered(pipWin, async () => { + EventUtils.sendMouseEvent({ type: "dblclick" }, controls, pipWin); + }); + + Assert.equal( + pipWin.document.fullscreenElement, + pipWin.document.body, + "Double-click should have caused to enter fullscreen." + ); + + await promiseFullscreenExited(pipWin, async () => { + EventUtils.synthesizeKey("KEY_Escape", {}, pipWin); + }); + + ok( + !pipWin.document.fullscreenElement, + "ESC should have caused to leave fullscreen." + ); + ok( + !(await isVideoPaused(browser, videoID)), + "The video is paused, but should not." + ); + + // Try to close the PiP window via the ESC button, since now it is not in fullscreen anymore. + EventUtils.synthesizeKey("KEY_Escape", {}, pipWin); + + // then the PiP should have been closed + ok(pipWin.closed, "Picture-in-Picture window is not closed, but should."); + // and the video should not be playing anymore + ok( + await isVideoPaused(browser, videoID), + "The video is not paused, but should." + ); + + await BrowserTestUtils.removeTab(tab); + } +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.js b/toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.js new file mode 100644 index 0000000000..da4191cb0e --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.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 F-key would enter and exit full screen mode in PiP for the default locale (en-US). + */ +add_task(async () => { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE); + let browser = tab.linkedBrowser; + let videoID = "with-controls"; + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "The Picture-in-Picture window is there."); + + ok( + !pipWin.document.fullscreenElement, + "PiP should not yet be in fullscreen." + ); + + await promiseFullscreenEntered(pipWin, async () => { + EventUtils.synthesizeKey("f", {}, pipWin); + }); + + Assert.equal( + pipWin.document.fullscreenElement, + pipWin.document.body, + "F-key should have caused to enter fullscreen." + ); + + await promiseFullscreenExited(pipWin, async () => { + EventUtils.synthesizeKey("f", {}, pipWin); + }); + + ok( + !pipWin.document.fullscreenElement, + "F-key should have caused to leave fullscreen." + ); + + await BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js new file mode 100644 index 0000000000..a81d99e18c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const PIP_SHORTCUT_OPEN_EVENTS = [ + { + category: "pictureinpicture", + method: "opened_method", + object: "shortcut", + }, +]; + +const PIP_SHORTCUT_CLOSE_EVENTS = [ + { + category: "pictureinpicture", + method: "closed_method", + object: "shortcut", + }, +]; + +/** + * 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 => { + Services.telemetry.clearEvents(); + 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); + + let openFilter = { + category: "pictureinpicture", + method: "opened_method", + object: "shortcut", + }; + await waitForTelemeryEvents( + openFilter, + PIP_SHORTCUT_OPEN_EVENTS.length, + "content" + ); + TelemetryTestUtils.assertEvents(PIP_SHORTCUT_OPEN_EVENTS, openFilter, { + clear: true, + process: "content", + }); + + // 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."); + + let closeFilter = { + category: "pictureinpicture", + method: "closed_method", + object: "shortcut", + }; + await waitForTelemeryEvents( + closeFilter, + PIP_SHORTCUT_CLOSE_EVENTS.length, + "parent" + ); + TelemetryTestUtils.assertEvents(PIP_SHORTCUT_CLOSE_EVENTS, closeFilter, { + clear: true, + process: "parent", + }); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js new file mode 100644 index 0000000000..a85cdd5a63 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that keyboard shortcut ctr + w / cmd + w closing PIP window + */ + +add_task(async function test_pip_close_keyboard_shortcut() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + 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."); + + EventUtils.synthesizeKey("w", { accelKey: true }, pipWin); + await BrowserTestUtils.windowClosed(pipWin); + ok(await isVideoPaused(browser, VIDEO_ID), "The video is paused"); + ok(pipWin.closed, "Closed PIP"); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js new file mode 100644 index 0000000000..c812dc3e4e --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_pip_keyboard_shortcut_with_nan_video_duration() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_NAN_VIDEO_DURATION, + gBrowser, + }, + async browser => { + const VIDEO_ID = "test-video"; + + await SpecialPowers.spawn(browser, [VIDEO_ID], async videoID => { + let video = content.document.getElementById(videoID); + if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) { + info(`Waiting for 'canplaythrough' for ${videoID}`); + await ContentTaskUtils.waitForEvent(video, "canplaythrough"); + } + }); + + 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."); + + pipWin.close(); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js b/toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js new file mode 100644 index 0000000000..a70100de85 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests to ensure that tabbing to the pip button and pressing space works + * to open the picture-in-picture window. + */ +add_task(async () => { + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + // Open the video in PiP + let pipWin = await triggerPictureInPicture(browser, videoID, () => { + EventUtils.synthesizeKey("KEY_Tab", {}); // play button + EventUtils.synthesizeKey("KEY_Tab", {}); // pip button + EventUtils.synthesizeKey(" ", {}); + }); + ok(pipWin, "Got Picture-in-Picture window."); + + await BrowserTestUtils.closeWindow(pipWin); + } + ); +}); 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..5744c83496 --- /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 assertSawClickEventOnly(browser); + + 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..2821c0d484 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_multiPip.js @@ -0,0 +1,225 @@ +/* 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"]; +} + +/** + * 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_nimbusDisplayDuration.js b/toolkit/components/pictureinpicture/tests/browser_nimbusDisplayDuration.js new file mode 100644 index 0000000000..a2e7e66b40 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_nimbusDisplayDuration.js @@ -0,0 +1,214 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); + +const TOGGLE_HAS_USED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; +const TOGGLE_FIRST_SEEN_PREF = + "media.videocontrols.picture-in-picture.video-toggle.first-seen-secs"; + +/** + * This tests that the first-time toggle doesn't change to the icon toggle. + */ +add_task(async function test_experiment_control_displayDuration() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + await SpecialPowers.pushPrefEnv({ + set: [ + [TOGGLE_FIRST_SEEN_PREF, 0], + [TOGGLE_HAS_USED_PREF, false], + ], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF); + const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF); + + Assert.ok(!hasUsed, "has-used is false and toggle is not icon"); + Assert.notEqual(firstSeen, 0, "First seen should not be 0"); + } + ); +}); + +/** + * This tests that the first-time toggle changes to the icon toggle + * if the displayDuration end date is reached or passed. + */ +add_task(async function test_experiment_displayDuration_end_date_was_reached() { + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "pictureinpicture", + value: { + displayDuration: 1, + }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + await SpecialPowers.pushPrefEnv({ + set: [ + [TOGGLE_FIRST_SEEN_PREF, 222], + [TOGGLE_HAS_USED_PREF, false], + ], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF); + const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF); + + Assert.ok(hasUsed, "has-used is true and toggle is icon"); + Assert.equal(firstSeen, 222, "First seen should remain unchanged"); + } + ); + + await doExperimentCleanup(); +}); + +/** + * This tests that the first-time toggle does not change to the icon toggle + * if the displayDuration end date is not yet reached or passed. + */ +add_task(async function test_experiment_displayDuration_end_date_not_reached() { + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "pictureinpicture", + value: { + displayDuration: 5, + }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + const currentDateSec = Math.round(Date.now() / 1000); + + await SpecialPowers.pushPrefEnv({ + set: [ + [TOGGLE_FIRST_SEEN_PREF, currentDateSec], + [TOGGLE_HAS_USED_PREF, false], + ], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF); + const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF); + + Assert.ok(!hasUsed, "has-used is false and toggle is not icon"); + Assert.equal( + firstSeen, + currentDateSec, + "First seen should remain unchanged" + ); + } + ); + + await doExperimentCleanup(); +}); + +/** + * This tests that the toggle does not change to the icon toggle if duration is negative. + */ +add_task(async function test_experiment_displayDuration_negative_duration() { + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "pictureinpicture", + value: { + displayDuration: -1, + }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + await SpecialPowers.pushPrefEnv({ + set: [ + [TOGGLE_FIRST_SEEN_PREF, 0], + [TOGGLE_HAS_USED_PREF, false], + ], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + const hasUsed = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF); + const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF); + + Assert.ok(!hasUsed, "has-used is false and toggle is not icon"); + Assert.notEqual(firstSeen, 0, "First seen should not be 0"); + } + ); + + await doExperimentCleanup(); +}); + +/** + * This tests that first-seen is only recorded for the first-time toggle. + */ +add_task(async function test_experiment_displayDuration_already_icon() { + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "pictureinpicture", + value: { + displayDuration: 1, + }, + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + await SpecialPowers.pushPrefEnv({ + set: [ + [TOGGLE_FIRST_SEEN_PREF, 0], + [TOGGLE_HAS_USED_PREF, true], + ], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + const firstSeen = Services.prefs.getIntPref(TOGGLE_FIRST_SEEN_PREF); + Assert.equal(firstSeen, 0, "First seen should be 0"); + } + ); + + await doExperimentCleanup(); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js b/toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js new file mode 100644 index 0000000000..492dc6851d --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); + +const EXPERIMENT_CLASS_NAME = "experiment"; + +/** + * This tests that the original PiP toggle design is shown. + */ +add_task(async function test_experiment_control_toggle_style() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + const PIP_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; + await SpecialPowers.pushPrefEnv({ + set: [[PIP_PREF, false]], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + await SpecialPowers.spawn( + browser, + [EXPERIMENT_CLASS_NAME], + async EXPERIMENT_CLASS_NAME => { + let video = content.document.getElementById("with-controls"); + let shadowRoot = video.openOrClosedShadowRoot; + + let controlsContainer = + shadowRoot.querySelector(".controlsContainer"); + let pipWrapper = shadowRoot.querySelector(".pip-wrapper"); + let pipExplainer = shadowRoot.querySelector(".pip-explainer"); + + Assert.ok( + !controlsContainer.classList.contains(EXPERIMENT_CLASS_NAME) + ); + Assert.ok(!pipWrapper.classList.contains(EXPERIMENT_CLASS_NAME)); + Assert.ok( + ContentTaskUtils.isVisible(pipExplainer), + "The PiP message should be visible on the toggle" + ); + } + ); + } + ); +}); + +/** + * This tests that the variant PiP toggle design is shown if Nimbus + * variable `oldToggle` is false. + */ +add_task(async function test_experiment_toggle_style() { + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "pictureinpicture", + value: { + oldToggle: false, + }, + }); + + registerCleanupFunction(async function () { + await doExperimentCleanup(); + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + const PIP_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; + await SpecialPowers.pushPrefEnv({ + set: [[PIP_PREF, false]], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + await SpecialPowers.spawn( + browser, + [EXPERIMENT_CLASS_NAME], + async EXPERIMENT_CLASS_NAME => { + let video = content.document.getElementById("with-controls"); + let shadowRoot = video.openOrClosedShadowRoot; + + let controlsContainer = + shadowRoot.querySelector(".controlsContainer"); + let pipWrapper = shadowRoot.querySelector(".pip-wrapper"); + let pipExplainer = shadowRoot.querySelector(".pip-explainer"); + + Assert.ok( + controlsContainer.classList.contains(EXPERIMENT_CLASS_NAME) + ); + Assert.ok(pipWrapper.classList.contains(EXPERIMENT_CLASS_NAME)); + Assert.ok( + ContentTaskUtils.isHidden(pipExplainer), + "The PiP message should not be visible on the toggle" + ); + } + ); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js b/toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js new file mode 100644 index 0000000000..e998b4f65d --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); + +const PIP_EXPERIMENT_MESSAGE = "Hello world message"; +const PIP_EXPERIMENT_TITLE = "Hello world title"; + +/** + * This tests that the original DTD string is shown for the PiP toggle + */ +add_task(async function test_experiment_control() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + const l10n = new Localization( + ["branding/brand.ftl", "toolkit/global/videocontrols.ftl"], + true + ); + + let pipExplainerMessage = l10n.formatValueSync( + "videocontrols-picture-in-picture-explainer3" + ); + + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + const PIP_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; + await SpecialPowers.pushPrefEnv({ + set: [[PIP_PREF, false]], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + await SpecialPowers.spawn( + browser, + [pipExplainerMessage], + async function (pipExplainerMessage) { + let video = content.document.getElementById("with-controls"); + let shadowRoot = video.openOrClosedShadowRoot; + let pipButton = shadowRoot.querySelector(".pip-explainer"); + + Assert.equal( + pipButton.textContent.trim(), + pipExplainerMessage, + "The PiP explainer is default" + ); + } + ); + } + ); +}); + +/** + * This tests that the experiment message is shown for the PiP toggle + */ +add_task(async function test_experiment_message() { + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "pictureinpicture", + value: { + title: PIP_EXPERIMENT_TITLE, + message: PIP_EXPERIMENT_MESSAGE, + }, + }); + + registerCleanupFunction(async function () { + await doExperimentCleanup(); + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + const PIP_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; + await SpecialPowers.pushPrefEnv({ + set: [[PIP_PREF, false]], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + await SpecialPowers.spawn( + browser, + [PIP_EXPERIMENT_MESSAGE, PIP_EXPERIMENT_TITLE], + async function (PIP_EXPERIMENT_MESSAGE, PIP_EXPERIMENT_TITLE) { + let video = content.document.getElementById("with-controls"); + let shadowRoot = video.openOrClosedShadowRoot; + let pipExplainer = shadowRoot.querySelector(".pip-explainer"); + let pipLabel = shadowRoot.querySelector(".pip-label"); + + Assert.equal( + pipExplainer.textContent.trim(), + PIP_EXPERIMENT_MESSAGE, + "The PiP explainer is being overridden by the experiment" + ); + + Assert.equal( + pipLabel.textContent.trim(), + PIP_EXPERIMENT_TITLE, + "The PiP label is being overridden by the experiment" + ); + } + ); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js b/toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js new file mode 100644 index 0000000000..7c6dcea01b --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); + +const PIP_EXPERIMENT_MESSAGE = "Hello world message"; +const PIP_EXPERIMENT_TITLE = "Hello world title"; + +/** + * This tests that the original DTD string is shown for the PiP toggle + */ +add_task(async function test_experiment_control() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + const l10n = new Localization( + ["branding/brand.ftl", "toolkit/global/videocontrols.ftl"], + true + ); + + let pipExplainerMessage = l10n.formatValueSync( + "videocontrols-picture-in-picture-explainer3" + ); + + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + const PIP_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; + await SpecialPowers.pushPrefEnv({ + set: [[PIP_PREF, false]], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + await SpecialPowers.spawn( + browser, + [pipExplainerMessage], + async function (pipExplainerMessage) { + let video = content.document.getElementById("with-controls"); + let shadowRoot = video.openOrClosedShadowRoot; + let pipButton = shadowRoot.querySelector(".pip-explainer"); + + Assert.equal( + pipButton.textContent.trim(), + pipExplainerMessage, + "The PiP explainer is default" + ); + } + ); + } + ); +}); + +/** + * This tests that the experiment is showing the icon only + */ +add_task(async function test_experiment_iconOnly() { + let experimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "pictureinpicture", + value: { + showIconOnly: true, + }, + }); + + registerCleanupFunction(async function () { + await experimentCleanup(); + }); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + await SimpleTest.promiseFocus(browser); + await ensureVideosReady(browser); + + const PIP_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; + await SpecialPowers.pushPrefEnv({ + set: [[PIP_PREF, false]], + }); + + let videoID = "with-controls"; + await hoverToggle(browser, videoID); + + await SpecialPowers.spawn(browser, [], async function () { + let video = content.document.getElementById("with-controls"); + let shadowRoot = video.openOrClosedShadowRoot; + let pipExpanded = shadowRoot.querySelector(".pip-expanded"); + let pipIcon = shadowRoot.querySelector("div.pip-icon"); + + Assert.ok( + ContentTaskUtils.isHidden(pipExpanded), + "The PiP explainer hidden by the experiment" + ); + + Assert.ok( + ContentTaskUtils.isVisible(pipIcon), + "The PiP icon is visible by the experiment" + ); + }); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js b/toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js new file mode 100644 index 0000000000..e021900cad --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that no player controls are triggered by middle or + * right click. + */ +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 => { + 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); + + let playPause = pipWin.document.getElementById("playpause"); + let audioButton = pipWin.document.getElementById("audio"); + + // Middle click the pause button + EventUtils.synthesizeMouseAtCenter(playPause, { button: 1 }, pipWin); + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused."); + + // Right click the pause button + EventUtils.synthesizeMouseAtCenter(playPause, { button: 2 }, pipWin); + ok(!(await isVideoPaused(browser, videoID)), "The video is not paused."); + + // Middle click the mute button + EventUtils.synthesizeMouseAtCenter(audioButton, { button: 1 }, pipWin); + ok(!(await isVideoMuted(browser, videoID)), "The audio is not muted."); + + // Right click the mute button + EventUtils.synthesizeMouseAtCenter(audioButton, { button: 2 }, pipWin); + ok(!(await isVideoMuted(browser, videoID)), "The audio is not muted."); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js new file mode 100644 index 0000000000..b6d434cf8c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js @@ -0,0 +1,46 @@ +/* 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_occluded_window.js b/toolkit/components/pictureinpicture/tests/browser_occluded_window.js new file mode 100644 index 0000000000..6ee66f2ade --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_occluded_window.js @@ -0,0 +1,258 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the forceAppWindowActive flag is correctly set whenever a PiP window is opened + * and closed across multiple tabs on the same browser window. + */ +add_task(async function forceActiveMultiPiPTabs() { + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + let bc = browser.ownerGlobal.browsingContext; + info("is window active: " + bc.isActive); + + info("Opening new tab"); + let newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_PAGE + ); + let newTabBrowser = newTab.linkedBrowser; + await ensureVideosReady(newTabBrowser); + + ok(!bc.forceAppWindowActive, "Forced window active should be false"); + info("is window active: " + bc.isActive); + + info("Now opening PiP windows"); + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + ok( + bc.forceAppWindowActive, + "Forced window active should be true since PiP is open" + ); + info("is window active: " + bc.isActive); + + let newTabPiPWin = await triggerPictureInPicture(newTabBrowser, videoID); + ok(newTabPiPWin, "Got Picture-in-Picture window in new tab"); + ok( + bc.forceAppWindowActive, + "Force window active should still be true after opening a new PiP window in new tab" + ); + info("is window active: " + bc.isActive); + + let pipClosedNewTab = BrowserTestUtils.domWindowClosed(newTabPiPWin); + let pipUnloadedNewTab = BrowserTestUtils.waitForEvent( + newTabPiPWin, + "unload" + ); + let closeButtonNewTab = newTabPiPWin.document.getElementById("close"); + info("Selecting close button"); + EventUtils.synthesizeMouseAtCenter(closeButtonNewTab, {}, newTabPiPWin); + info("Waiting for PiP window to close"); + await pipUnloadedNewTab; + await pipClosedNewTab; + + ok( + bc.forceAppWindowActive, + "Force window active should still be true after removing new tab's PiP window" + ); + + info("is window active: " + bc.isActive); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let pipUnloaded = BrowserTestUtils.waitForEvent(pipWin, "unload"); + let closeButton = pipWin.document.getElementById("close"); + info("Selecting close button"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + info("Waiting for PiP window to close"); + await pipUnloaded; + await pipClosed; + + ok( + !bc.forceAppWindowActive, + "Force window active should now be false after removing the last PiP window" + ); + + info("is window active: " + bc.isActive); + + await BrowserTestUtils.removeTab(newTab); + } + ); +}); + +/** + * Tests that the forceAppWindowActive flag is correctly set when a tab with PiP enabled is + * moved to another window. + */ +add_task(async function forceActiveMovePiPToWindow() { + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + info("Opening first tab"); + + await ensureVideosReady(browser); + + let tab = gBrowser.getTabForBrowser(browser); + let bc = browser.ownerGlobal.browsingContext; + + info("is window active: " + bc.isActive); + + ok(!bc.forceAppWindowActive, "Forced window active should be false"); + info("is window active: " + bc.isActive); + + info("Now opening PiP windows"); + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window in first tab."); + + ok( + bc.forceAppWindowActive, + "Forced window active should be true since PiP is open" + ); + info("is window active: " + bc.isActive); + + let swapDocShellsPromise = BrowserTestUtils.waitForEvent( + browser, + "SwapDocShells" + ); + let tabClosePromise = BrowserTestUtils.waitForEvent(tab, "TabClose"); + let tabSwapPiPPromise = BrowserTestUtils.waitForEvent( + tab, + "TabSwapPictureInPicture" + ); + info("Replacing tab with window"); + let newWindow = gBrowser.replaceTabWithWindow(tab); + let newWinLoadedPromise = BrowserTestUtils.waitForEvent( + newWindow, + "load" + ); + + info("Waiting for new window to initialize after swap"); + await Promise.all([ + tabSwapPiPPromise, + swapDocShellsPromise, + tabClosePromise, + newWinLoadedPromise, + ]); + + let newWindowBC = newWindow.browsingContext; + tab = newWindow.gBrowser.selectedTab; + + ok( + !bc.forceAppWindowActive, + "Force window active should no longer be true after moving the previous tab to a new window" + ); + info("is window active: " + bc.isActive); + ok( + newWindowBC.forceAppWindowActive, + "Force window active should be true for new window since PiP is open" + ); + info("is secondary window active: " + newWindowBC.isActive); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let pipUnloaded = BrowserTestUtils.waitForEvent(pipWin, "unload"); + let closeButton = pipWin.document.getElementById("close"); + info("Selecting close button"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + info("Waiting for PiP window to close"); + await pipUnloaded; + await pipClosed; + + ok( + !newWindowBC.forceAppWindowActive, + "Force window active should now be false for new window after removing the last PiP window" + ); + info("is secondary window active: " + newWindowBC.isActive); + + await BrowserTestUtils.removeTab(tab); + } + ); +}); + +/** + * Tests that the forceAppWindowActive flag is correctly set when multiple PiP + * windows are created for a single PiP window. + */ +add_task(async function forceActiveMultiPiPSamePage() { + let videoID1 = "with-controls"; + let videoID2 = "no-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + let bc = browser.ownerGlobal.browsingContext; + + ok( + !bc.forceAppWindowActive, + "Forced window active should be false at the start of the test" + ); + info("is window active: " + bc.isActive); + + let pipWin1 = await triggerPictureInPicture(browser, videoID1); + ok(pipWin1, "Got Picture-in-Picture window 1."); + + ok( + bc.forceAppWindowActive, + "Forced window active should be true since PiP is open" + ); + info("is window active: " + bc.isActive); + + let pipWin2 = await triggerPictureInPicture(browser, videoID2); + ok(pipWin2, "Got Picture-in-Picture window 2."); + + ok( + bc.forceAppWindowActive, + "Forced window active should be true after opening another PiP window on the same page" + ); + info("is window active: " + bc.isActive); + + let pipClosed1 = BrowserTestUtils.domWindowClosed(pipWin1); + let pipUnloaded1 = BrowserTestUtils.waitForEvent(pipWin1, "unload"); + let closeButton1 = pipWin1.document.getElementById("close"); + info("Selecting close button"); + EventUtils.synthesizeMouseAtCenter(closeButton1, {}, pipWin1); + info("Waiting for PiP window to close"); + await pipUnloaded1; + await pipClosed1; + + ok( + bc.forceAppWindowActive, + "Force window active should still be true after removing PiP window 1" + ); + info("is window active: " + bc.isActive); + + let pipClosed2 = BrowserTestUtils.domWindowClosed(pipWin2); + let pipUnloaded2 = BrowserTestUtils.waitForEvent(pipWin2, "unload"); + let closeButton2 = pipWin2.document.getElementById("close"); + info("Selecting close button"); + EventUtils.synthesizeMouseAtCenter(closeButton2, {}, pipWin2); + info("Waiting for PiP window to close"); + await pipUnloaded2; + await pipClosed2; + + ok( + !bc.forceAppWindowActive, + "Force window active should now be false after removing PiP window 2" + ); + info("is window active: " + bc.isActive); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_playerControls.js b/toolkit/components/pictureinpicture/tests/browser_playerControls.js new file mode 100644 index 0000000000..9929da597a --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_playerControls.js @@ -0,0 +1,86 @@ +/* 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 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(browser, videoID)), "The audio is playing."); + EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin); + await mutedPromise; + ok(await isVideoMuted(browser, videoID), "The audio is muted."); + + // Try the unmute button + let unmutedPromise = waitForVideoEvent("volumechange"); + EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin); + await unmutedPromise; + ok(!(await isVideoMuted(browser, videoID)), "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_preserveTabPipIconOverlay.js b/toolkit/components/pictureinpicture/tests/browser_preserveTabPipIconOverlay.js new file mode 100644 index 0000000000..55cd003a2c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_preserveTabPipIconOverlay.js @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +const EVENTUTILS_URL = + "chrome://mochikit/content/tests/SimpleTest/EventUtils.js"; +var EventUtils = {}; + +Services.scriptloader.loadSubScript(EVENTUTILS_URL, EventUtils); + +async function detachTab(tab) { + let newWindowPromise = new Promise((resolve, reject) => { + let observe = (win, topic, data) => { + Services.obs.removeObserver(observe, "domwindowopened"); + resolve(win); + }; + Services.obs.addObserver(observe, "domwindowopened"); + }); + + await EventUtils.synthesizePlainDragAndDrop({ + srcElement: tab, + + // destElement is null because tab detaching happens due + // to a drag'n'drop on an invalid drop target. + destElement: null, + + // don't move horizontally because that could cause a tab move + // animation, and there's code to prevent a tab detaching if + // the dragged tab is released while the animation is running. + stepX: 0, + stepY: 100, + }); + + return newWindowPromise; +} + +/** + * Tests that tabs dragged between windows with PiP open, the pip attribute stays + */ +add_task(async function test_dragging_pip_to_other_window() { + // initialize + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + let pipTab = await BrowserTestUtils.openNewForegroundTab( + win1.gBrowser, + TEST_PAGE + ); + let destTab = await BrowserTestUtils.openNewForegroundTab(win2.gBrowser); + + let awaitCloseEventPromise = BrowserTestUtils.waitForEvent( + pipTab, + "TabClose" + ); + let tabSwapPictureInPictureEventPromise = BrowserTestUtils.waitForEvent( + pipTab, + "TabSwapPictureInPicture" + ); + + // Open PiP + let videoID = "with-controls"; + let browser = pipTab.linkedBrowser; + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + // tear out window + let effect = EventUtils.synthesizeDrop( + pipTab, + destTab, + [[{ type: TAB_DROP_TYPE, data: pipTab }]], + null, + win1, + win2 + ); + is(effect, "move", "Tab should be moved from win1 to win2."); + + let closeEvent = await awaitCloseEventPromise; + let swappedPipTabsEvent = await tabSwapPictureInPictureEventPromise; + + is( + closeEvent.detail.adoptedBy, + swappedPipTabsEvent.detail, + "Pip tab adopted by new tab created when original tab closed" + ); + + // make sure we reassign the pip tab to the new one + pipTab = swappedPipTabsEvent.detail; + + // check PiP attribute + ok(pipTab.hasAttribute("pictureinpicture"), "Tab should have PiP attribute"); + + // end PiP + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("close"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + await pipClosed; + + // ensure PiP attribute is gone + await TestUtils.waitForCondition( + () => !pipTab.hasAttribute("pictureinpicture"), + "pictureinpicture attribute was removed" + ); + + ok(true, "pictureinpicture attribute successfully cleared"); + + // close windows + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); +}); + +/** + * Tests that tabs torn out into a new window with PiP open, the pip attribute stays + */ +add_task(async function test_dragging_pip_into_new_window() { + // initialize + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + // Create PiP + let videoID = "with-controls"; + let pipTab = gBrowser.getTabForBrowser(browser); + + let pipWin = await triggerPictureInPicture(browser, videoID); + + let tabSwapPictureInPictureEventPromise = BrowserTestUtils.waitForEvent( + pipTab, + "TabSwapPictureInPicture" + ); + + // tear out into new window + let newWin = await detachTab(pipTab); + + let swappedPipTabsEvent = await tabSwapPictureInPictureEventPromise; + pipTab = swappedPipTabsEvent.detail; + + // check PiP attribute + ok( + pipTab.hasAttribute("pictureinpicture"), + "Tab should have PiP attribute" + ); + + // end PiP + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("close"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + await pipClosed; + + // ensure pip attribute is gone + await TestUtils.waitForCondition( + () => !pipTab.hasAttribute("pictureinpicture"), + "pictureinpicture attribute was removed" + ); + ok(true, "pictureinpicture attribute successfully cleared"); + + // close windows + await BrowserTestUtils.closeWindow(newWin); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_privateWindow.js b/toolkit/components/pictureinpicture/tests/browser_privateWindow.js new file mode 100644 index 0000000000..26d051b14a --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_privateWindow.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that a Picture-in-Picture window opened by a Private browsing + * window has the "private" feature set on its window (which is important + * for some things, eg: taskbar grouping on Windows). + */ +add_task(async () => { + for (let videoID of ["with-controls", "no-controls"]) { + info(`Testing ${videoID} case.`); + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let pipTab = await BrowserTestUtils.openNewForegroundTab( + privateWin.gBrowser, + TEST_PAGE + ); + let browser = pipTab.linkedBrowser; + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + Assert.equal( + pipWin.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags & + Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, + Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, + "Picture-in-Picture window should be marked as private" + ); + + await BrowserTestUtils.closeWindow(privateWin); + } +}); 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_resizeVideo.js b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js new file mode 100644 index 0000000000..9254ca10cc --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_resizeVideo.js @@ -0,0 +1,293 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Global values for the left and top edge pixel coordinates. These will be written to +// during the add_setup function in this test file. +let gLeftEdge = 0; +let gTopEdge = 0; + +/** + * 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 == gLeftEdge) { + 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 + ); +} + +add_setup(async () => { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + // Reset the saved PiP location to top-left edge of the screen, wherever + // that may be. We record the top-left edge of the screen coordinates into + // global variables to do later coordinate comparisons after resizes. + let clearWin = await triggerPictureInPicture(browser, "with-controls"); + let initialScreenX = clearWin.mozInnerScreenX; + let initialScreenY = clearWin.mozInnerScreenY; + let PiPScreen = PictureInPicture.getWorkingScreen( + initialScreenX, + initialScreenY + ); + [gLeftEdge, gTopEdge] = PictureInPicture.getAvailScreenSize(PiPScreen); + clearWin.moveTo(gLeftEdge, gTopEdge); + + await BrowserTestUtils.closeWindow(clearWin); + } + ); +}); + +/** + * 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(gLeftEdge, gTopEdge); + + 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..1e787dafc7 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js @@ -0,0 +1,398 @@ +/* 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. + */ +async function doTest() { + 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; + } + + function getAvailScreenSize(screen) { + let screenLeft = {}, + screenTop = {}, + screenWidth = {}, + screenHeight = {}; + screen.GetAvailRectDisplayPix( + screenLeft, + screenTop, + screenWidth, + screenHeight + ); + + // 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 *= scaleFactor; + screenTop.value *= scaleFactor; + + 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 + clearSavedPosition(); + + // 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 + isfuzzy( + pipWin.screenX, + rightEdge - defaultPiPWidth, + ACCEPTABLE_DIFFERENCE, + "Default PiP X location" + ); + if (AppConstants.platform == "linux") { + isfuzzy( + pipWin.screenY, + bottomEdge - defaultPiPHeight - tabHeight, + ACCEPTABLE_DIFFERENCE, + "Default PiP Y location" + ); + } else { + isfuzzy( + pipWin.screenY, + bottomEdge - defaultPiPHeight, + ACCEPTABLE_DIFFERENCE, + "Default PiP Y location" + ); + } + isfuzzy( + pipWin.innerHeight, + defaultPiPHeight, + ACCEPTABLE_DIFFERENCE, + "Default PiP height" + ); + isfuzzy( + pipWin.innerWidth, + defaultPiPWidth, + ACCEPTABLE_DIFFERENCE, + "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 + isfuzzy( + pipWin.screenX, + left, + ACCEPTABLE_DIFFERENCE, + "Opened at last X location" + ); + isfuzzy( + pipWin.screenY, + top, + ACCEPTABLE_DIFFERENCE, + "Opened at last Y location" + ); + isfuzzy( + pipWin.innerHeight, + height, + ACCEPTABLE_DIFFERENCE, + "Opened with 1/2 default height" + ); + isfuzzy( + pipWin.innerWidth, + width, + ACCEPTABLE_DIFFERENCE, + "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 + isfuzzy( + pipWin.screenX, + rightEdge - defaultPiPWidth, + ACCEPTABLE_DIFFERENCE, + "Opened at default X location" + ); + isfuzzy( + pipWin.screenY, + bottomEdge - defaultPiPHeight, + ACCEPTABLE_DIFFERENCE, + "Opened at default Y location" + ); + isfuzzy( + pipWin.innerWidth, + defaultPiPWidth, + ACCEPTABLE_DIFFERENCE, + "Opened at default PiP width" + ); + isfuzzy( + pipWin.innerHeight, + defaultPiPHeight, + ACCEPTABLE_DIFFERENCE, + "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."); + + isfuzzy( + pipWin.screenX, + left, + ACCEPTABLE_DIFFERENCE, + "Opened at last X location" + ); + isfuzzy( + pipWin.screenY, + top, + ACCEPTABLE_DIFFERENCE, + "Opened at last Y location" + ); + isfuzzy( + pipWin.innerHeight, + height, + ACCEPTABLE_DIFFERENCE, + "Opened height with previous width" + ); + isfuzzy( + pipWin.innerWidth, + height * (pipWin.innerWidth / pipWin.innerHeight), + ACCEPTABLE_DIFFERENCE, + "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."); + + isfuzzy( + pipWin.screenX, + left, + ACCEPTABLE_DIFFERENCE, + "Opened at last X location" + ); + isfuzzy( + pipWin.screenY, + top, + ACCEPTABLE_DIFFERENCE, + "Opened at last Y location" + ); + isfuzzy( + pipWin.innerHeight, + height, + ACCEPTABLE_DIFFERENCE, + "Opened with previous height" + ); + isfuzzy( + pipWin.innerWidth, + height * (pipWin.innerWidth / pipWin.innerHeight), + ACCEPTABLE_DIFFERENCE, + "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 + isfuzzy( + pipWin.screenX, + left, + ACCEPTABLE_DIFFERENCE, + "Opened at last X location but shifted back on screen" + ); + if (AppConstants.platform == "linux") { + isfuzzy( + pipWin.screenY, + top - tabHeight, + ACCEPTABLE_DIFFERENCE, + "Opened at last Y location but shifted back on screen" + ); + } else { + isfuzzy( + pipWin.screenY, + top, + ACCEPTABLE_DIFFERENCE, + "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 + isfuzzy( + pipWin.screenX, + defaultX, + ACCEPTABLE_DIFFERENCE, + "Opened at last X location but shifted back on screen" + ); + isfuzzy( + pipWin.screenY, + defaultY, + ACCEPTABLE_DIFFERENCE, + "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; + isfuzzy( + pipWin.innerWidth + pipWin.screenX, + rightEdge, + ACCEPTABLE_DIFFERENCE, + "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."); + + Assert.less(pipWin.innerWidth, width, "New video width is smaller"); + isfuzzy( + pipWin.innerWidth + pipWin.screenX, + rightEdge, + ACCEPTABLE_DIFFERENCE, + "Video is on right edge after video is changed" + ); + } + + await ensureMessageAndClosePiP(browser, "with-controls", pipWin, true); + } + ); +} + +add_task(async function test_pip_save_last_loc() { + await doTest(); +}); + +add_task(async function test_pip_save_last_loc_with_os_zoom() { + await SpecialPowers.pushPrefEnv({ set: [["ui.textScaleFactor", 120]] }); + await doTest(); +}); 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..84866fba3e --- /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": DEFAULT_TOGGLE_OPACITY, + }, + hidden: [], + }, + + hoverToggle: { + opacities: { + ".pip-wrapper": 1.0, + }, + hidden: [".pip-expanded"], + }, + }, + }; + + const TOGGLE_LARGE = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-small": 0.0, + ".pip-wrapper": DEFAULT_TOGGLE_OPACITY, + ".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_subtitles_settings_panel.js b/toolkit/components/pictureinpicture/tests/browser_subtitles_settings_panel.js new file mode 100644 index 0000000000..bbb81cde55 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_subtitles_settings_panel.js @@ -0,0 +1,273 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that pressing the Escape key will close the subtitles settings panel and + * not remove focus if activated via the mouse. + */ +add_task(async function test_closePanelESCMouseFocus() { + clearSavedPosition(); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + [ + "media.videocontrols.picture-in-picture.display-text-tracks.size", + "medium", + ], + ], + }); + + let videoID = "with-controls"; + + await ensureVideosReady(browser); + await prepareVideosAndWebVTTTracks(browser, videoID); + await SpecialPowers.spawn(browser, [videoID], async videoID => { + await content.document.getElementById(videoID).play(); + }); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + // Resize PiP window so that subtitles button is visible + let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize"); + pipWin.resizeTo(640, 360); + await resizePromise; + + let subtitlesButton = pipWin.document.getElementById("closed-caption"); + Assert.ok(subtitlesButton, "Subtitles button found"); + + let subtitlesPanel = pipWin.document.getElementById("settings"); + let panelVisiblePromise = BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isVisible(subtitlesPanel), + "Wait for panel to be visible" + ); + + EventUtils.synthesizeMouseAtCenter(subtitlesButton, {}, pipWin); + + await panelVisiblePromise; + + let audioButton = pipWin.document.getElementById("audio"); + audioButton.focus(); + + let panelHiddenPromise = BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isHidden(subtitlesPanel), + "Wait for panel to be hidden" + ); + + EventUtils.synthesizeKey("KEY_Escape", {}, pipWin); + + info("Make sure subtitles settings panel closes after pressing ESC"); + await panelHiddenPromise; + + Assert.notEqual( + pipWin.document.activeElement, + subtitlesButton, + "Subtitles button does not have focus after closing panel" + ); + Assert.ok(pipWin, "PiP window is still open"); + + clearSavedPosition(); + } + ); +}); + +/** + * Tests that pressing the Escape key will close the subtitles settings panel and + * refocus on the subtitles button if activated via the keyboard. + */ +add_task(async function test_closePanelESCKeyboardFocus() { + clearSavedPosition(); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + + let videoID = "with-controls"; + + await ensureVideosReady(browser); + await prepareVideosAndWebVTTTracks(browser, videoID); + await SpecialPowers.spawn(browser, [videoID], async videoID => { + await content.document.getElementById(videoID).play(); + }); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + // Resize PiP window so that subtitles button is visible + let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize"); + pipWin.resizeTo(640, 360); + await resizePromise; + + let subtitlesButton = pipWin.document.getElementById("closed-caption"); + Assert.ok(subtitlesButton, "Subtitles button found"); + + let subtitlesPanel = pipWin.document.getElementById("settings"); + let subtitlesToggle = pipWin.document.getElementById("subtitles-toggle"); + let panelVisiblePromise = BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isVisible(subtitlesPanel), + "Wait for panel to be visible" + ); + + subtitlesButton.focus(); + EventUtils.synthesizeKey(" ", {}, pipWin); + + await panelVisiblePromise; + + Assert.equal( + pipWin.document.activeElement, + subtitlesToggle, + "Subtitles switch toggle should have focus after opening panel" + ); + + let panelHiddenPromise = BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isHidden(subtitlesPanel), + "Wait for panel to be hidden" + ); + + EventUtils.synthesizeKey("KEY_Escape", {}, pipWin); + + info("Make sure subtitles settings panel closes after pressing ESC"); + await panelHiddenPromise; + + Assert.equal( + pipWin.document.activeElement, + subtitlesButton, + "Subtitles button has focus after closing panel" + ); + Assert.ok(pipWin, "PiP window is still open"); + + clearSavedPosition(); + } + ); +}); + +/** + * Tests keyboard navigation for the subtitles settings panel and that it closes after selecting + * the subtitles button. + */ +add_task(async function test_panelKeyboardButtons() { + clearSavedPosition(); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + + let videoID = "with-controls"; + + await ensureVideosReady(browser); + await prepareVideosAndWebVTTTracks(browser, videoID); + await SpecialPowers.spawn(browser, [videoID], async videoID => { + await content.document.getElementById(videoID).play(); + // Mute video + content.document.getElementById(videoID).muted = true; + }); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + // Resize PiP window so that subtitles button is visible + let resizePromise = BrowserTestUtils.waitForEvent(pipWin, "resize"); + pipWin.resizeTo(640, 360); + await resizePromise; + + let subtitlesButton = pipWin.document.getElementById("closed-caption"); + Assert.ok(subtitlesButton, "Subtitles button found"); + + let subtitlesPanel = pipWin.document.getElementById("settings"); + let subtitlesToggle = pipWin.document.getElementById("subtitles-toggle"); + let panelVisiblePromise = BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isVisible(subtitlesPanel), + "Wait for panel to be visible" + ); + + subtitlesButton.focus(); + EventUtils.synthesizeKey(" ", {}, pipWin); + + await panelVisiblePromise; + + Assert.equal( + pipWin.document.activeElement, + subtitlesToggle, + "Subtitles switch toggle should have focus after opening panel" + ); + + let fontMediumRadio = pipWin.document.getElementById("medium"); + EventUtils.synthesizeKey("KEY_Tab", {}, pipWin); + + Assert.equal( + pipWin.document.activeElement, + fontMediumRadio, + "Medium font size radio button should have focus" + ); + + let fontSmallRadio = pipWin.document.getElementById("small"); + EventUtils.synthesizeKey("KEY_ArrowUp", {}, pipWin); + + Assert.equal( + pipWin.document.activeElement, + fontSmallRadio, + "Small font size radio button should have focus" + ); + Assert.ok(isVideoMuted(browser, videoID), "Video should still be muted"); + Assert.equal( + SpecialPowers.getCharPref( + "media.videocontrols.picture-in-picture.display-text-tracks.size" + ), + "small", + "Font size changed to small" + ); + + subtitlesButton.focus(); + + let panelHiddenPromise = BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.isHidden(subtitlesPanel), + "Wait for panel to be hidden" + ); + + EventUtils.synthesizeKey(" ", {}, pipWin); + + info( + "Make sure subtitles settings panel closes after pressing the subtitles button" + ); + await panelHiddenPromise; + + Assert.ok(pipWin, "PiP window is still open"); + + clearSavedPosition(); + } + ); +}); 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_telemetry_enhancements.js b/toolkit/components/pictureinpicture/tests/browser_telemetry_enhancements.js new file mode 100644 index 0000000000..3f027de170 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_telemetry_enhancements.js @@ -0,0 +1,230 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_PAGE_LONG = TEST_ROOT + "test-video-selection.html"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const EXPECTED_EVENT_CREATE = [ + [ + "pictureinpicture", + "create", + "player", + undefined, + { ccEnabled: "false", webVTTSubtitles: "false" }, + ], +]; + +const EXPECTED_EVENT_CREATE_WITH_TEXT_TRACKS = [ + [ + "pictureinpicture", + "create", + "player", + undefined, + { ccEnabled: "true", webVTTSubtitles: "true" }, + ], +]; + +const EXPECTED_EVENT_CLOSED_METHOD_CLOSE_BUTTON = [ + { + category: "pictureinpicture", + method: "closed_method", + object: "closeButton", + }, +]; + +const videoID = "with-controls"; + +const EXPECTED_EVENT_CLOSED_METHOD_UNPIP = [ + { + category: "pictureinpicture", + method: "closed_method", + object: "unpip", + }, +]; + +const FULLSCREEN_EVENTS = [ + { + category: "pictureinpicture", + method: "fullscreen", + object: "player", + extraKey: { enter: "true" }, + }, + { + category: "pictureinpicture", + method: "fullscreen", + object: "player", + extraKey: { enter: "true" }, + }, +]; + +add_task(async function testCreateAndCloseButtonTelemetry() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + Services.telemetry.clearEvents(); + + await ensureVideosReady(browser); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let filter = { + category: "pictureinpicture", + method: "create", + object: "player", + }; + await waitForTelemeryEvents( + filter, + EXPECTED_EVENT_CREATE.length, + "parent" + ); + + TelemetryTestUtils.assertEvents(EXPECTED_EVENT_CREATE, filter, { + clear: true, + process: "parent", + }); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let closeButton = pipWin.document.getElementById("close"); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin); + await pipClosed; + + filter = { + category: "pictureinpicture", + method: "closed_method", + object: "closeButton", + }; + await waitForTelemeryEvents( + filter, + EXPECTED_EVENT_CLOSED_METHOD_CLOSE_BUTTON.length, + "parent" + ); + + TelemetryTestUtils.assertEvents( + EXPECTED_EVENT_CLOSED_METHOD_CLOSE_BUTTON, + filter, + { clear: true, process: "parent" } + ); + + let hist = TelemetryTestUtils.getAndClearHistogram( + "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION" + ); + + Assert.ok(hist, "Histogram exists"); + } + ); +}); + +add_task(async function textTextTracksAndUnpipTelemetry() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + Services.telemetry.clearEvents(); + + await ensureVideosReady(browser); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let filter = { + category: "pictureinpicture", + method: "create", + object: "player", + }; + await waitForTelemeryEvents( + filter, + EXPECTED_EVENT_CREATE_WITH_TEXT_TRACKS.length, + "parent" + ); + + TelemetryTestUtils.assertEvents( + EXPECTED_EVENT_CREATE_WITH_TEXT_TRACKS, + filter, + { clear: true, process: "parent" } + ); + + let pipClosed = BrowserTestUtils.domWindowClosed(pipWin); + let unpipButton = pipWin.document.getElementById("unpip"); + EventUtils.synthesizeMouseAtCenter(unpipButton, {}, pipWin); + await pipClosed; + + filter = { + category: "pictureinpicture", + method: "closed_method", + object: "unpip", + }; + await waitForTelemeryEvents( + filter, + EXPECTED_EVENT_CLOSED_METHOD_UNPIP.length, + "parent" + ); + + TelemetryTestUtils.assertEvents( + EXPECTED_EVENT_CLOSED_METHOD_UNPIP, + filter, + { clear: true, process: "parent" } + ); + } + ); +}); + +add_task(async function test_fullscreen_events() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + Services.telemetry.clearEvents(); + + await ensureVideosReady(browser); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + let fullscreenBtn = pipWin.document.getElementById("fullscreen"); + + await promiseFullscreenEntered(pipWin, () => { + fullscreenBtn.click(); + }); + + await promiseFullscreenExited(pipWin, () => { + fullscreenBtn.click(); + }); + + let filter = { + category: "pictureinpicture", + method: "fullscreen", + object: "player", + }; + await waitForTelemeryEvents(filter, FULLSCREEN_EVENTS.length, "parent"); + + TelemetryTestUtils.assertEvents(FULLSCREEN_EVENTS, filter, { + clear: true, + process: "parent", + }); + + await ensureMessageAndClosePiP(browser, videoID, pipWin, false); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js new file mode 100644 index 0000000000..b2b1ded13d --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures that text tracks shown on the source video + * do not appear on a newly created pip window if the pref + * is disabled. + */ +add_task(async function test_text_tracks_new_window_pref_disabled() { + info("Running test: new window - pref disabled"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + false, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + + ok(textTracks, "TextTracks container should exist in the pip window"); + ok( + !textTracks.textContent, + "Text tracks should not be visible on the pip window" + ); + }); + } + ); +}); + +/** + * This test ensures that text tracks shown on the source video + * appear on a newly created pip window if the pref is enabled. + */ +add_task(async function test_text_tracks_new_window_pref_enabled() { + info("Running test: new window - pref enabled"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + ok(textTracks, "TextTracks container should exist in the pip window"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + }); + } + ); +}); + +/** + * This test ensures that text tracks do not appear on a new pip window + * if no track is loaded and the pref is enabled. + */ +add_task(async function test_text_tracks_new_window_no_track() { + info("Running test: new window - no track"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID, -1); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + + ok(textTracks, "TextTracks container should exist in the pip window"); + ok( + !textTracks.textContent, + "Text tracks should not be visible on the pip window" + ); + }); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js new file mode 100644 index 0000000000..9f1ebefbf5 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js @@ -0,0 +1,448 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures that text tracks disappear from the pip window + * when the pref is disabled. + */ +add_task(async function test_text_tracks_existing_window_pref_disabled() { + info("Running test: existing window - pref disabled"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + ok(textTracks, "TextTracks container should exist in the pip window"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + }); + + info("Turning off pref"); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + false, + ], + ], + }); + + // Verify that cue is no longer on the pip window + info("Checking that cue is no longer on pip window"); + await SpecialPowers.spawn(pipBrowser, [], async () => { + let textTracks = content.document.getElementById("texttracks"); + await ContentTaskUtils.waitForCondition(() => { + return !textTracks.textContent; + }, `Text track is still visible on the pip window. Got ${textTracks.textContent}`); + info("Successfully removed text tracks from pip window"); + }); + } + ); +}); + +/** + * This test ensures that text tracks shown on the source video + * window appear on an existing pip window when the pref is enabled. + */ +add_task(async function test_text_tracks_existing_window_pref_enabled() { + info("Running test: existing window - pref enabled"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + false, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + + ok(textTracks, "TextTracks container should exist in the pip window"); + ok( + !textTracks.textContent, + "Text tracks should not be visible on the pip window" + ); + }); + + info("Turning on pref"); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + + info("Checking that cue is on pip window"); + await SpecialPowers.spawn(pipBrowser, [], async () => { + let textTracks = content.document.getElementById("texttracks"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + info("Successfully displayed text tracks on pip window"); + }); + } + ); +}); + +/** + * This test ensures that text tracks update to the correct track + * when a new track is selected. + */ +add_task(async function test_text_tracks_existing_window_new_track() { + info("Running test: existing window - new track"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + ok(textTracks, "TextTracks container should exist in the pip window"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + ok( + textTracks.textContent.includes("track 1"), + "Track 1 should be loaded" + ); + }); + + // Change track in the content window + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + let tracks = video.textTracks; + + info("Changing to a new track"); + let track1 = tracks[0]; + track1.mode = "disabled"; + let track2 = tracks[1]; + track2.mode = "showing"; + }); + + // Ensure new track is loaded + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking new text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + ok( + textTracks.textContent.includes("track 2"), + "Track 2 should be loaded" + ); + }); + } + ); +}); + +/** + * This test ensures that text tracks are correctly updated. + */ +add_task(async function test_text_tracks_existing_window_cues() { + info("Running test: existing window - cues"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + let textTracks = content.document.getElementById("texttracks"); + ok(textTracks, "TextTracks container should exist in the pip window"); + + // Verify that first cue appears + info("Checking first cue on pip window"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + ok( + textTracks.textContent.includes("cue 1"), + `Expected text should be displayed on the pip window. Got ${textTracks.textContent}.` + ); + }); + + // Play video to move to the next cue + await waitForNextCue(browser, videoID); + + // Test remaining cues + await SpecialPowers.spawn(pipBrowser, [], async () => { + let textTracks = content.document.getElementById("texttracks"); + + // Verify that empty cue makes text disappear + info("Checking empty cue in pip window"); + await ContentTaskUtils.waitForCondition(() => { + info(`Current text content is: ${textTracks.textContent}`); + return !textTracks.textContent; + }, `Text track is still visible on the pip window. Got ${textTracks.textContent}`); + }); + + await waitForNextCue(browser, videoID); + + // Wait and verify second cue + await SpecialPowers.spawn(pipBrowser, [], async () => { + let textTracks = content.document.getElementById("texttracks"); + info("Checking second cue in pip window"); + await ContentTaskUtils.waitForCondition(() => { + // Cue may not appear right away after cuechange event. + // Wait until it appears before verifying text content. + info(`Current text content is: ${textTracks.textContent}`); + return ( + textTracks.textContent && textTracks.textContent.includes("cue 2") + ); + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + }); + } + ); +}); + +/** + * This test ensures that text tracks disappear if no track is selected. + */ +add_task(async function test_text_tracks_existing_window_no_track() { + info("Running test: existing window - no track"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + ok(textTracks, "TextTracks container should exist in the pip window"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + }); + + // Remove track in the content window + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + let tracks = video.textTracks; + + info("Removing tracks"); + let track1 = tracks[0]; + track1.mode = "disabled"; + let track2 = tracks[1]; + track2.mode = "disabled"; + }); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking that text track disappears from pip window"); + let textTracks = content.document.getElementById("texttracks"); + + await ContentTaskUtils.waitForCondition(() => { + return !textTracks.textContent; + }, `Text track is still visible on the pip window. Got ${textTracks.textContent}`); + }); + } + ); +}); + +/** + * This test ensures that text tracks appear correctly if there are multiple active cues. + */ +add_task(async function test_text_tracks_existing_window_multi_cue() { + info("Running test: existing window - multi cue"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID, 2); + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + + // Verify multiple active cues + ok(textTracks, "TextTracks container should exist in the pip window"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + is(textTracks.children.length, 2, "Text tracks should load 2 cues"); + }); + } + ); +}); + +/** + * This test ensures that the showHiddenTextTracks override correctly shows + * text tracks with a mode of "hidden". + */ +const prepareHiddenTrackTest = () => + new Promise((resolve, reject) => { + BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + + async browser => { + const videoID = "with-controls"; + await prepareVideosAndWebVTTTracks(browser, videoID, 0, "hidden"); + await SpecialPowers.spawn(browser, [{ videoID }], async args => { + let video = content.document.getElementById(args.videoID); + const tracks = video.textTracks; + Assert.strictEqual( + tracks[0].mode, + "hidden", + "Track 1 mode is 'hidden'" + ); + }); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + if (!pipBrowser) { + reject(); + } + resolve(pipBrowser); + } + ); + }); + +add_task(async function test_hidden_text_tracks_override() { + info("Running test - showHiddenTextTracks"); + + info("hidden mode with override"); + Services.ppmm.sharedData.set(SHARED_DATA_KEY, { + "*://example.com/*": { showHiddenTextTracks: true }, + }); + Services.ppmm.sharedData.flush(); + + await prepareHiddenTrackTest().then(async pipBrowser => { + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + + // Verify text track is showing in PiP window. + ok(textTracks, "TextTracks container should exist in the pip window"); + ok(textTracks.textContent.includes("track 1"), "Track 1 should be shown"); + }); + }); + + info("hidden mode without override"); + Services.ppmm.sharedData.set(SHARED_DATA_KEY, {}); + Services.ppmm.sharedData.flush(); + + await prepareHiddenTrackTest().then(async pipBrowser => { + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + + // Verify text track is [not] showing in PiP window. + ok( + !textTracks || !textTracks.textContent.length, + "Text track should NOT appear in PiP window." + ); + }); + }); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js new file mode 100644 index 0000000000..3e62556d45 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js @@ -0,0 +1,218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Verifies the value of a cue's .line property. + * @param {Element} browser The <xul:browser> hosting the <video> + * @param {String} videoID The ID of the video being checked + * @param {Integer} trackIndex The index of the track to be loaded + * @param {Integer} cueIndex The index of the cue to be tested on + * @param {Integer|String} expectedValue The expected line value of the cue + */ +async function verifyLineForCue( + browser, + videoID, + trackIndex, + cueIndex, + expectedValue +) { + await SpecialPowers.spawn( + browser, + [{ videoID, trackIndex, cueIndex, expectedValue }], + async args => { + info("Checking .line property values"); + const video = content.document.getElementById(args.videoID); + const activeCues = video.textTracks[args.trackIndex].activeCues; + const vttCueLine = activeCues[args.cueIndex].line; + is(vttCueLine, args.expectedValue, "Cue line should have expected value"); + } + ); +} + +/** + * This test ensures that text tracks appear in expected order if + * VTTCue.line property is auto. + */ +add_task(async function test_text_tracks_new_window_line_auto() { + info("Running test: new window - line auto"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + let trackIndex = 2; + await prepareVideosAndWebVTTTracks(browser, videoID, trackIndex); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await verifyLineForCue(browser, videoID, trackIndex, 0, "auto"); + await verifyLineForCue(browser, videoID, trackIndex, 1, "auto"); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + ok(textTracks, "TextTracks container should exist in the pip window"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + + let cueDivs = textTracks.children; + + is(cueDivs.length, 2, "There should be 2 active cues"); + // cue1 in this case refers to the first cue to be defined in the vtt file. + // cue2 is therefore the next cue to be defined right after in the vtt file. + ok( + cueDivs[0].textContent.includes("cue 2"), + `cue 2 should be above. Got: ${cueDivs[0].textContent}` + ); + ok( + cueDivs[1].textContent.includes("cue 1"), + `cue 1 should be below. Got: ${cueDivs[1].textContent}` + ); + }); + } + ); +}); + +/** + * This test ensures that text tracks appear in expected order if + * VTTCue.line property is an integer. + */ +add_task(async function test_text_tracks_new_window_line_integer() { + info("Running test: new window - line integer"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + let trackIndex = 3; + await prepareVideosAndWebVTTTracks(browser, videoID, trackIndex); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await verifyLineForCue(browser, videoID, trackIndex, 0, 2); + await verifyLineForCue(browser, videoID, trackIndex, 1, 3); + await verifyLineForCue(browser, videoID, trackIndex, 2, 1); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + ok(textTracks, "TextTracks container should exist in the pip window"); + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + + let cueDivs = textTracks.children; + + is(cueDivs.length, 3, "There should be 3 active cues"); + + // cue1 in this case refers to the first cue to be defined in the vtt file. + // cue2 is therefore the next cue to be defined right after in the vtt file. + ok( + cueDivs[0].textContent.includes("cue 3"), + `cue 3 should be above. Got: ${cueDivs[0].textContent}` + ); + ok( + cueDivs[1].textContent.includes("cue 1"), + `cue 1 should be next. Got: ${cueDivs[1].textContent}` + ); + ok( + cueDivs[2].textContent.includes("cue 2"), + `cue 2 should be below. Got: ${cueDivs[2].textContent}` + ); + }); + } + ); +}); + +/** + * This test ensures that text tracks appear in expected order if + * VTTCue.line property is a percentage value. + */ +add_task(async function test_text_tracks_new_window_line_percent() { + info("Running test: new window - line percent"); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + let trackIndex = 4; + await prepareVideosAndWebVTTTracks(browser, videoID, trackIndex); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + let pipBrowser = pipWin.document.getElementById("browser"); + + await verifyLineForCue(browser, videoID, trackIndex, 0, 90); + await verifyLineForCue(browser, videoID, trackIndex, 1, 10); + await verifyLineForCue(browser, videoID, trackIndex, 2, 50); + + await SpecialPowers.spawn(pipBrowser, [], async () => { + info("Checking text track content in pip window"); + let textTracks = content.document.getElementById("texttracks"); + ok(textTracks, "TextTracks container should exist in the pip window"); + + await ContentTaskUtils.waitForCondition(() => { + return textTracks.textContent; + }, `Text track is still not visible on the pip window. Got ${textTracks.textContent}`); + + let cueDivs = textTracks.children; + is(cueDivs.length, 3, "There should be 3 active cues"); + + // cue1 in this case refers to the first cue to be defined in the vtt file. + // cue2 is therefore the next cue to be defined right after in the vtt file. + ok( + cueDivs[0].textContent.includes("cue 2"), + `cue 2 should be above. Got: ${cueDivs[0].textContent}` + ); + ok( + cueDivs[1].textContent.includes("cue 3"), + `cue 3 should be next. Got: ${cueDivs[1].textContent}` + ); + ok( + cueDivs[2].textContent.includes("cue 1"), + `cue 1 should be below. Got: ${cueDivs[2].textContent}` + ); + }); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js new file mode 100644 index 0000000000..e02fe21f4e --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const EXAMPLE_COM = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); +const EXAMPLE_ORG = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" +); +const EXAMPLE_COM_TEST_PAGE = EXAMPLE_COM + "test-page.html"; +const EXAMPLE_ORG_WITH_IFRAME = EXAMPLE_ORG + "test-page-with-iframe.html"; + +/** + * 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: EXAMPLE_ORG_WITH_IFRAME, + gBrowser, + }, + async browser => { + // EXAMPLE_ORG_WITH_IFRAME is hosted at a different domain from + // EXAMPLE_COM_TEST_PAGE, so loading EXAMPLE_COM_TEST_PAGE within + // the iframe will act as our third-party iframe. + await SpecialPowers.spawn( + browser, + [EXAMPLE_COM_TEST_PAGE], + async EXAMPLE_COM_TEST_PAGE => { + let iframe = content.document.getElementById("iframe"); + let loadPromise = ContentTaskUtils.waitForEvent(iframe, "load"); + iframe.src = EXAMPLE_COM_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); + + await SimpleTest.promiseFocus(window); + + // Now try using the command / keyboard shortcut + pipWin = await triggerPictureInPicture(iframeBc, videoID, () => { + document.getElementById("View:PictureInPicture").doCommand(); + }); + ok(pipWin, "Got Picture-in-Picture window using command."); + + 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_toggleButtonOnNanDuration.js b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOnNanDuration.js new file mode 100644 index 0000000000..6eaa4b5bcd --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleButtonOnNanDuration.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that we do not show the Picture-in-Picture toggle on video + * elements that have a NaN duration. + */ +add_task(async function test_toggleButtonOnNanDuration() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_NAN_VIDEO_DURATION, + gBrowser, + }, + async browser => { + const VIDEO_ID = "test-video"; + + await SpecialPowers.spawn(browser, [VIDEO_ID], async videoID => { + let video = content.document.getElementById(videoID); + if (video.readyState < content.HTMLMediaElement.HAVE_ENOUGH_DATA) { + info(`Waiting for 'canplaythrough' for ${videoID}`); + await ContentTaskUtils.waitForEvent(video, "canplaythrough"); + } + }); + + await testToggleHelper(browser, "nan-duration", false); + + await testToggleHelper(browser, "test-video", true); + } + ); +}); 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..a845137a69 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js @@ -0,0 +1,204 @@ +/* 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": DEFAULT_TOGGLE_OPACITY, + ".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": DEFAULT_TOGGLE_OPACITY, + ".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": DEFAULT_TOGGLE_OPACITY, + }, + hidden: [".pip-expanded"], + }, + + hoverToggle: { + opacities: { + ".pip-wrapper": 1.0, + }, + hidden: [".pip-expanded"], + }, + }, +}; + +const TOGGLE_STYLES_RIGHT_SMALL = { + rootID: "pictureInPictureToggle", + stages: { + hoverVideo: { + opacities: { + ".pip-wrapper": DEFAULT_TOGGLE_OPACITY, + }, + 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, + shouldSeeClickEventAfterToggle: true, + }, + }); + + 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, + shouldSeeClickEventAfterToggle: true, + }, + }); + + 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..36172feeb4 --- /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.importESModule( + "resource://testing-common/ContentTaskUtils.sys.mjs" + ); + 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..a95bbb0d48 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_togglePolicies.js @@ -0,0 +1,127 @@ +/* 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.MIDDLE, + 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_togglePositionChange.js b/toolkit/components/pictureinpicture/tests/browser_togglePositionChange.js new file mode 100644 index 0000000000..a868bc3d71 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_togglePositionChange.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +/** + * Tests that Picture-in-Picture "move toggle" context menu item + * successfully changes preference. + */ +add_task(async () => { + let videoID = "with-controls"; + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + await SpecialPowers.spawn(browser, [videoID], async videoID => { + await content.document.getElementById(videoID).play(); + }); + + const TOGGLE_POSITION_PREF = + "media.videocontrols.picture-in-picture.video-toggle.position"; + const TOGGLE_POSITION_RIGHT = "right"; + const TOGGLE_POSITION_LEFT = "left"; + + await SpecialPowers.pushPrefEnv({ + set: [[TOGGLE_POSITION_PREF, TOGGLE_POSITION_RIGHT]], + }); + + let contextMoveToggle = document.getElementById( + "context_MovePictureInPictureToggle" + ); + contextMoveToggle.click(); + let position = Services.prefs.getStringPref( + TOGGLE_POSITION_PREF, + TOGGLE_POSITION_RIGHT + ); + + Assert.ok( + position === TOGGLE_POSITION_LEFT, + "Picture-in-Picture toggle position value should be 'left'." + ); + + contextMoveToggle.click(); + position = Services.prefs.getStringPref( + TOGGLE_POSITION_PREF, + TOGGLE_POSITION_RIGHT + ); + + Assert.ok( + position === TOGGLE_POSITION_RIGHT, + "Picture-in-Picture toggle position value should be 'right'." + ); + } + ); +}); 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_toggle_enabled.js b/toolkit/components/pictureinpicture/tests/browser_toggle_enabled.js new file mode 100644 index 0000000000..197bb9357c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggle_enabled.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const videoID = "with-controls"; +const TOGGLE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.enabled"; +const PIP_ENABLED_PREF = "media.videocontrols.picture-in-picture.enabled"; +const ACCEPTABLE_DIFF = 1; + +function checkDifference(actual, expected) { + let diff = Math.abs(actual - expected); + return diff <= ACCEPTABLE_DIFF; +} + +function isVideoRect(videoRect, rect) { + info( + "Video rect and toggle rect will be the same if the toggle doesn't show" + ); + info(`Video rect: ${JSON.stringify(videoRect)}`); + info(`Toggle rect: ${JSON.stringify(rect)}`); + return ( + checkDifference(videoRect.top, rect.top) && + checkDifference(videoRect.left, rect.left) && + checkDifference(videoRect.width, rect.width) && + checkDifference(videoRect.height, rect.height) + ); +} + +/** + * Tests if the toggle is available depending on prefs + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE, + }, + async browser => { + let videoRect = await SpecialPowers.spawn( + browser, + [videoID], + async videoID => { + let video = content.document.getElementById(videoID); + let rect = video.getBoundingClientRect(); + + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + } + ); + // both toggle and pip true + await SpecialPowers.pushPrefEnv({ + set: [ + [TOGGLE_ENABLED_PREF, true], + [PIP_ENABLED_PREF, true], + ], + }); + + let rect = await getToggleClientRect(browser, videoID); + + Assert.ok(!isVideoRect(videoRect, rect), "Toggle is showing"); + + // only toggle false + await SpecialPowers.pushPrefEnv({ + set: [[TOGGLE_ENABLED_PREF, false]], + }); + + rect = await getToggleClientRect(browser, videoID); + Assert.ok(isVideoRect(videoRect, rect), "The toggle is not showing"); + + // only pip false + await SpecialPowers.pushPrefEnv({ + set: [ + [TOGGLE_ENABLED_PREF, true], + [PIP_ENABLED_PREF, false], + ], + }); + + rect = await getToggleClientRect(browser, videoID); + Assert.ok(isVideoRect(videoRect, rect), "The toggle is not showing"); + + // both toggle and pip false + await SpecialPowers.pushPrefEnv({ + set: [ + [TOGGLE_ENABLED_PREF, false], + [PIP_ENABLED_PREF, false], + ], + }); + + rect = await getToggleClientRect(browser, videoID); + Assert.ok(isVideoRect(videoRect, rect), "The toggle is not showing"); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js b/toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js new file mode 100644 index 0000000000..248f816fba --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js @@ -0,0 +1,76 @@ +/* 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 opening the closed captions menu + * and is visible when closing the closed captions menu. + */ +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ["media.videocontrols.picture-in-picture.video-toggle.enabled", true], + ], + }); + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID, -1); + await prepareForToggleClick(browser, videoID); + + let args = { + videoID, + DEFAULT_TOGGLE_STYLES, + }; + + await SpecialPowers.spawn(browser, [args], async args => { + let { videoID, DEFAULT_TOGGLE_STYLES } = args; + let video = this.content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + let closedCaptionButton = shadowRoot.querySelector( + "#closedCaptionButton" + ); + let toggle = shadowRoot.querySelector( + `#${DEFAULT_TOGGLE_STYLES.rootID}` + ); + let textTrackListContainer = shadowRoot.querySelector( + "#textTrackListContainer" + ); + + Assert.ok(!toggle.hidden, "Toggle should be visible"); + Assert.ok( + textTrackListContainer.hidden, + "textTrackListContainer should be hidden" + ); + + info("Opening text track list from cc button"); + closedCaptionButton.click(); + + Assert.ok(toggle.hidden, "Toggle should be hidden"); + Assert.ok( + !textTrackListContainer.hidden, + "textTrackListContainer should be visible" + ); + + info("Clicking the cc button again to close it"); + closedCaptionButton.click(); + + Assert.ok(!toggle.hidden, "Toggle should be visible again"); + Assert.ok( + textTrackListContainer.hidden, + "textTrackListContainer should be hidden again" + ); + }); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js b/toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js new file mode 100644 index 0000000000..0c9ca5eeba --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const videoID = "without-audio"; +const MIN_DURATION_PREF = + "media.videocontrols.picture-in-picture.video-toggle.min-video-secs"; +const ALWAYS_SHOW_PREF = + "media.videocontrols.picture-in-picture.video-toggle.always-show"; +const ACCEPTABLE_DIFF = 1; + +function checkDifference(actual, expected) { + let diff = Math.abs(actual - expected); + return diff <= ACCEPTABLE_DIFF; +} + +function isVideoRect(videoRect, rect) { + info( + "Video rect and toggle rect will be the same if the toggle doesn't show" + ); + info(`Video rect: ${JSON.stringify(videoRect)}`); + info(`Toggle rect: ${JSON.stringify(rect)}`); + return ( + checkDifference(videoRect.top, rect.top) && + checkDifference(videoRect.left, rect.left) && + checkDifference(videoRect.width, rect.width) && + checkDifference(videoRect.height, rect.height) + ); +} + +/** + * Tests if the toggle is available for a video without an audio track + */ +add_task(async () => { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_PAGE_WITHOUT_AUDIO, + }, + async browser => { + let videoRect = await SpecialPowers.spawn( + browser, + [videoID], + async videoID => { + let video = content.document.getElementById(videoID); + Assert.ok(!video.mozHasAudio, "Video does not have an audio track"); + let rect = video.getBoundingClientRect(); + + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + } + ); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ALWAYS_SHOW_PREF, false], // don't always show, we're testing the display logic + [MIN_DURATION_PREF, 3], // sample video is only 4s + ], + }); + + let rect = await getToggleClientRect(browser, videoID); + + Assert.ok(!isVideoRect(videoRect, rect), "Toggle is showing"); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js b/toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js new file mode 100644 index 0000000000..e50c430d0c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js @@ -0,0 +1,242 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that Picture-in-Picture intializes without changes to video playback + * when opened via the toggle using a touch event. Also ensures that elements + * in the content window can still be interacted with afterwards. + */ +add_task(async () => { + let videoID = "with-controls"; + + await SpecialPowers.pushPrefEnv({ + set: [ + ["media.videocontrols.picture-in-picture.video-toggle.position", "right"], + ], + }); + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + let toggleStyles = DEFAULT_TOGGLE_STYLES; + let stage = "hoverVideo"; + let toggleStylesForStage = toggleStyles.stages[stage]; + + // Remove other page elements before reading PiP toggle's client rect. + // Otherwise, we will provide the wrong coordinates when simulating the touch event. + await SpecialPowers.spawn(browser, [], async args => { + info( + "Removing other elements first to make the PiP toggle more visible" + ); + this.content.document.getElementById("no-controls").remove(); + let otherEls = this.content.document.querySelectorAll("h1"); + for (let el of otherEls) { + el.remove(); + } + }); + + let toggleClientRect = await getToggleClientRect(browser, videoID); + await prepareForToggleClick(browser, videoID); + + await SpecialPowers.spawn( + browser, + [{ videoID, toggleClientRect, toggleStylesForStage }], + async args => { + // waitForToggleOpacity is based on toggleOpacityReachesThreshold. + // Waits for toggle to reach target opacity. + async function waitForToggleOpacity( + shadowRoot, + toggleStylesForStage + ) { + for (let hiddenElement of toggleStylesForStage.hidden) { + let el = shadowRoot.querySelector(hiddenElement); + ok( + ContentTaskUtils.isHidden(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."); + } + } + let { videoID, toggleClientRect, toggleStylesForStage } = args; + let video = this.content.document.getElementById(videoID); + let shadowRoot = video.openOrClosedShadowRoot; + + info("Creating a new button in the content window"); + let button = this.content.document.createElement("button"); + let buttonSelected = false; + button.ontouchstart = () => { + info("Button selected via touch event"); + buttonSelected = true; + return true; + }; + button.id = "testbutton"; + button.style.backgroundColor = "red"; + // Move button to the top for better visibility. + button.style.position = "absolute"; + button.style.top = "0"; + button.textContent = "test button"; + this.content.document.body.appendChild(button); + + await video.play(); + + info("Hover over the video to show the Picture-in-Picture toggle"); + await EventUtils.synthesizeMouseAtCenter( + video, + { type: "mousemove" }, + this.content.window + ); + await EventUtils.synthesizeMouseAtCenter( + video, + { type: "mouseover" }, + this.content.window + ); + + let toggleCenterX = + toggleClientRect.left + toggleClientRect.width / 2; + let toggleCenterY = + toggleClientRect.top + toggleClientRect.height / 2; + + // We want to wait for the toggle to reach opacity so that we can select it. + info("Waiting for toggle to become fully visible"); + await waitForToggleOpacity(shadowRoot, toggleStylesForStage); + + info("Simulating touch event on PiP toggle"); + let utils = EventUtils._getDOMWindowUtils(this.content.window); + let id = utils.DEFAULT_TOUCH_POINTER_ID; + let rx = 1; + let ry = 1; + let angle = 0; + let force = 1; + let tiltX = 0; + let tiltY = 0; + let twist = 0; + + let defaultPrevented = utils.sendTouchEvent( + "touchstart", + [id], + [toggleCenterX], + [toggleCenterY], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + false /* modifiers */ + ); + utils.sendTouchEvent( + "touchend", + [id], + [toggleCenterX], + [toggleCenterY], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + false /* modifiers */ + ); + + ok( + defaultPrevented, + "Touchstart event's default actions should be prevented" + ); + ok(!video.paused, "Video should still be playing"); + + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video is being cloned visually."); + + let testButton = this.content.document.getElementById("testbutton"); + let buttonRect = testButton.getBoundingClientRect(); + let buttonCenterX = buttonRect.left + buttonRect.width / 2; + let buttonCenterY = buttonRect.top + buttonRect.height / 2; + + info("Simulating touch event on new button"); + defaultPrevented = utils.sendTouchEvent( + "touchstart", + [id], + [buttonCenterX], + [buttonCenterY], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + false /* modifiers */ + ); + utils.sendTouchEvent( + "touchend", + [id], + [buttonCenterX], + [buttonCenterY], + [rx], + [ry], + [angle], + [force], + [tiltX], + [tiltY], + [twist], + false /* modifiers */ + ); + + ok( + buttonSelected, + "Button in content window was selected via touchstart" + ); + ok( + !defaultPrevented, + "Touchstart event's default actions should no longer be prevented" + ); + } + ); + + try { + info("Picture-in-Picture window should open"); + await BrowserTestUtils.waitForCondition( + () => Services.wm.getEnumerator(WINDOW_TYPE).hasMoreElements(), + "Found a Picture-in-Picture" + ); + for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) { + if (!win.closed) { + ok(true, "Found a Picture-in-Picture window as expected"); + win.close(); + } + } + } catch { + ok(false, "No Picture-in-Picture window found, which is unexpected"); + } + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js b/toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js new file mode 100644 index 0000000000..cf808c5177 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js @@ -0,0 +1,456 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const { ASRouter } = ChromeUtils.importESModule( + "resource:///modules/asrouter/ASRouter.sys.mjs" +); + +const PIP_URLBAR_EVENTS = [ + { + category: "pictureinpicture", + method: "opened_method", + object: "urlBar", + }, +]; + +const PIP_DISABLED_EVENTS = [ + { + category: "pictureinpicture", + method: "opened_method", + object: "urlBar", + extra: { disableDialog: "true" }, + }, + { + category: "pictureinpicture", + method: "disrespect_disable", + object: "urlBar", + }, +]; + +const FIRST_TOGGLE_AFTER_CALLOUT_EXPECTED_EVENTS = [ + { + category: "pictureinpicture", + method: "opened_method", + object: "urlBar", + extra: { firstTimeToggle: "true", callout: "true" }, + }, +]; + +add_task(async function test_urlbar_toggle_multiple_contexts() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_MULTIPLE_CONTEXTS, + gBrowser, + }, + async browser => { + Services.telemetry.clearEvents(); + await ensureVideosReady(browser); + await ensureVideosReady(browser.browsingContext.children[0]); + + await TestUtils.waitForCondition( + () => + PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount === + 2, + "Waiting for videos to register" + ); + + let { totalPipCount } = + PictureInPicture.getEligiblePipVideoCount(browser); + is(totalPipCount, 2, "Total PiP count is 2"); + + let pipUrlbarToggle = document.getElementById( + "picture-in-picture-button" + ); + ok( + BrowserTestUtils.isHidden(pipUrlbarToggle), + "PiP urlbar toggle is hidden because there is more than 1 video" + ); + + // Remove one video from page so urlbar toggle will show + await SpecialPowers.spawn(browser, [], async () => { + let video = content.document.getElementById("with-controls"); + video.remove(); + }); + + await BrowserTestUtils.waitForMutationCondition( + pipUrlbarToggle, + { attributeFilter: ["hidden"] }, + () => BrowserTestUtils.isVisible(pipUrlbarToggle) + ); + + ok( + BrowserTestUtils.isVisible(pipUrlbarToggle), + "PiP urlbar toggle is visible" + ); + + ({ totalPipCount } = PictureInPicture.getEligiblePipVideoCount(browser)); + is(totalPipCount, 1, "Total PiP count is 1"); + + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + pipUrlbarToggle.click(); + let win = await domWindowOpened; + ok(win, "A Picture-in-Picture window opened."); + + await assertVideoIsBeingCloned( + browser.browsingContext.children[0], + "video" + ); + + let filter = { + category: "pictureinpicture", + method: "opened_method", + object: "urlBar", + }; + await waitForTelemeryEvents(filter, PIP_URLBAR_EVENTS.length, "content"); + + TelemetryTestUtils.assertEvents(PIP_URLBAR_EVENTS, filter, { + clear: true, + process: "content", + }); + + let domWindowClosed = BrowserTestUtils.domWindowClosed(win); + pipUrlbarToggle.click(); + await domWindowClosed; + + await SpecialPowers.spawn(browser, [], async () => { + let iframe = content.document.getElementById("iframe"); + iframe.remove(); + }); + + await BrowserTestUtils.waitForMutationCondition( + pipUrlbarToggle, + { attributeFilter: ["hidden"] }, + () => BrowserTestUtils.isHidden(pipUrlbarToggle) + ); + + ok( + BrowserTestUtils.isHidden(pipUrlbarToggle), + "PiP urlbar toggle is hidden because there are no videos on the page" + ); + + ({ totalPipCount } = PictureInPicture.getEligiblePipVideoCount(browser)); + is(totalPipCount, 0, "Total PiP count is 0"); + } + ); +}); + +add_task(async function test_urlbar_toggle_switch_tabs() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_TRANSPARENT_NESTED_IFRAMES, + gBrowser, + }, + async browser => { + await ensureVideosReady(browser); + + await TestUtils.waitForCondition( + () => + PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount === + 1, + "Waiting for video to register" + ); + + let { totalPipCount } = + PictureInPicture.getEligiblePipVideoCount(browser); + is(totalPipCount, 1, "Total PiP count is 1"); + + let pipUrlbarToggle = document.getElementById( + "picture-in-picture-button" + ); + ok( + BrowserTestUtils.isVisible(pipUrlbarToggle), + "PiP urlbar toggle is visible because there is 1 video" + ); + + let pipActivePromise = BrowserTestUtils.waitForMutationCondition( + pipUrlbarToggle, + { attributeFilter: ["pipactive"] }, + () => pipUrlbarToggle.hasAttribute("pipactive") + ); + + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + pipUrlbarToggle.click(); + let win = await domWindowOpened; + ok(win, "A Picture-in-Picture window opened."); + + await assertVideoIsBeingCloned(browser, "video"); + + await pipActivePromise; + + ok( + pipUrlbarToggle.hasAttribute("pipactive"), + "We are PiP'd in this tab so the icon is active" + ); + + let newTab = BrowserTestUtils.addTab( + gBrowser, + TEST_PAGE_TRANSPARENT_NESTED_IFRAMES + ); + await BrowserTestUtils.browserLoaded(newTab.linkedBrowser); + + await BrowserTestUtils.switchTab(gBrowser, newTab); + + await BrowserTestUtils.waitForMutationCondition( + pipUrlbarToggle, + { attributeFilter: ["pipactive"] }, + () => !pipUrlbarToggle.hasAttribute("pipactive") + ); + + ok( + !pipUrlbarToggle.hasAttribute("pipactive"), + "After switching tabs the pip icon is not active" + ); + + BrowserTestUtils.removeTab(newTab); + + await ensureMessageAndClosePiP( + browser, + "video-transparent-background", + win, + false + ); + } + ); +}); + +add_task(async function test_pipDisabled() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_PIP_DISABLED, + gBrowser, + }, + async browser => { + Services.telemetry.clearEvents(); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.respect-disablePictureInPicture", + true, + ], + ], + }); + + const VIDEO_ID = "with-controls"; + await ensureVideosReady(browser); + + await TestUtils.waitForCondition( + () => + PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount === + 1, + "Waiting for video to register" + ); + + let { totalPipCount, totalPipDisabled } = + PictureInPicture.getEligiblePipVideoCount(browser); + is(totalPipCount, 1, "Total PiP count is 1"); + is(totalPipDisabled, 1, "PiP is disabled on 1 video"); + + // Confirm that the toggle is hidden because we are respecting disablePictureInPicture + await testToggleHelper(browser, VIDEO_ID, false); + + let pipUrlbarToggle = document.getElementById( + "picture-in-picture-button" + ); + ok( + BrowserTestUtils.isVisible(pipUrlbarToggle), + "PiP urlbar toggle is visible because PiP is disabled" + ); + + pipUrlbarToggle.click(); + + let panel = browser.ownerDocument.querySelector("#PictureInPicturePanel"); + await BrowserTestUtils.waitForCondition(async () => { + if (!panel) { + panel = browser.ownerDocument.querySelector("#PictureInPicturePanel"); + } + return BrowserTestUtils.isVisible(panel); + }); + + let respectPipDisableSwitch = panel.querySelector( + "#respect-pipDisabled-switch" + ); + ok( + !respectPipDisableSwitch.pressed, + "Respect PiP disabled is not pressed" + ); + + EventUtils.synthesizeMouseAtCenter(respectPipDisableSwitch.buttonEl, {}); + await BrowserTestUtils.waitForEvent(respectPipDisableSwitch, "toggle"); + ok(respectPipDisableSwitch.pressed, "Respect PiP disabled is pressed"); + + pipUrlbarToggle.click(); + + await BrowserTestUtils.waitForCondition(async () => { + return BrowserTestUtils.isHidden(panel); + }); + + let filter = { + category: "pictureinpicture", + object: "urlBar", + }; + await waitForTelemeryEvents(filter, PIP_DISABLED_EVENTS.length, "parent"); + TelemetryTestUtils.assertEvents(PIP_DISABLED_EVENTS, filter, { + clear: true, + process: "parent", + }); + + // Confirm that the toggle is now visible because we no longer respect disablePictureInPicture + await testToggleHelper(browser, VIDEO_ID, true); + + let pipActivePromise = BrowserTestUtils.waitForMutationCondition( + pipUrlbarToggle, + { attributeFilter: ["pipactive"] }, + () => pipUrlbarToggle.hasAttribute("pipactive") + ); + + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + pipUrlbarToggle.click(); + let win = await domWindowOpened; + ok(win, "A Picture-in-Picture window opened."); + + await assertVideoIsBeingCloned(browser, "video"); + + await pipActivePromise; + + ok( + pipUrlbarToggle.hasAttribute("pipactive"), + "We are PiP'd in this tab so the icon is active" + ); + + let domWindowClosed = BrowserTestUtils.domWindowClosed(win); + pipUrlbarToggle.click(); + await domWindowClosed; + + await BrowserTestUtils.waitForMutationCondition( + pipUrlbarToggle, + { attributeFilter: ["pipactive"] }, + () => !pipUrlbarToggle.hasAttribute("pipactive") + ); + + ok( + !pipUrlbarToggle.hasAttribute("pipactive"), + "We closed the PiP window so the urlbar button is no longer active" + ); + + await SpecialPowers.popPrefEnv(); + } + ); +}); + +add_task(async function test_urlbar_toggle_telemetry() { + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_MULTIPLE_CONTEXTS, + gBrowser, + }, + async browser => { + Services.telemetry.clearEvents(); + await ensureVideosReady(browser); + await ensureVideosReady(browser.browsingContext.children[0]); + + await TestUtils.waitForCondition( + () => + PictureInPicture.getEligiblePipVideoCount(browser).totalPipCount === + 2, + "Waiting for videos to register" + ); + + let { totalPipCount } = + PictureInPicture.getEligiblePipVideoCount(browser); + is(totalPipCount, 2, "Total PiP count is 2"); + + let pipUrlbarToggle = document.getElementById( + "picture-in-picture-button" + ); + ok( + BrowserTestUtils.isHidden(pipUrlbarToggle), + "PiP urlbar toggle is hidden because there is more than 1 video" + ); + + // Remove one video from page so urlbar toggle will show + await SpecialPowers.spawn(browser, [], async () => { + let video = content.document.getElementById("with-controls"); + video.remove(); + }); + await BrowserTestUtils.waitForMutationCondition( + pipUrlbarToggle, + { attributeFilter: ["hidden"] }, + () => BrowserTestUtils.isVisible(pipUrlbarToggle) + ); + ok( + BrowserTestUtils.isVisible(pipUrlbarToggle), + "PiP urlbar toggle is visible" + ); + + // open for first time after seeing feature callout message + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.video-toggle.has-used", + false, + ], + ], + }); + let fakeMessage = { + template: "feature_callout", + id: "FAKE_PIP_FEATURE_CALLOUT", + content: { + screens: [{ anchors: [{ selector: "#picture-in-picture-button" }] }], + }, + }; + await ASRouter.waitForInitialized; + let originalASRouterState; + await ASRouter.setState(state => { + originalASRouterState = state; + return { + messages: [...state.messages, fakeMessage], + messageImpressions: { + ...state.messageImpressions, + [fakeMessage.id]: [Date.now()], + }, + }; + }); + + ({ totalPipCount } = PictureInPicture.getEligiblePipVideoCount(browser)); + is(totalPipCount, 1, "Total PiP count is 1"); + + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + pipUrlbarToggle.click(); + let win = await domWindowOpened; + ok(win, "A Picture-in-Picture window opened."); + await assertVideoIsBeingCloned( + browser.browsingContext.children[0], + "video" + ); + + let filter = { + category: "pictureinpicture", + method: "opened_method", + object: "urlBar", + }; + await waitForTelemeryEvents( + filter, + FIRST_TOGGLE_AFTER_CALLOUT_EXPECTED_EVENTS.length, + "content" + ); + + TelemetryTestUtils.assertEvents( + FIRST_TOGGLE_AFTER_CALLOUT_EXPECTED_EVENTS, + filter, + { clear: true, process: "content" } + ); + + let domWindowClosed = BrowserTestUtils.domWindowClosed(win); + pipUrlbarToggle.click(); + await domWindowClosed; + + await ASRouter.setState(originalASRouterState); + } + ); +}); diff --git a/toolkit/components/pictureinpicture/tests/browser_videoEmptied.js b/toolkit/components/pictureinpicture/tests/browser_videoEmptied.js new file mode 100644 index 0000000000..bc96a9ea58 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/browser_videoEmptied.js @@ -0,0 +1,155 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the subtitles button hides after switching to a video that does not have subtitles + */ +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "media.videocontrols.picture-in-picture.display-text-tracks.enabled", + true, + ], + ], + }); + + let videoID = "with-controls"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + await prepareVideosAndWebVTTTracks(browser, videoID); + + let pipWin = await triggerPictureInPicture(browser, videoID); + ok(pipWin, "Got Picture-in-Picture window."); + + // Need to make sure that the PiP window is at least the minimum height + let multiplier = 1; + while (true) { + if (multiplier * pipWin.innerHeight > 325) { + break; + } + multiplier += 0.5; + } + + pipWin.moveTo(50, 50); + pipWin.resizeTo( + pipWin.innerWidth * multiplier, + pipWin.innerHeight * multiplier + ); + + let subtitlesButton = pipWin.document.querySelector("#closed-caption"); + await TestUtils.waitForCondition(() => { + return !subtitlesButton.disabled; + }, "Waiting for subtitles button to be enabled"); + ok(!subtitlesButton.disabled, "The subtitles button is enabled"); + + let emptied = SpecialPowers.spawn(browser, [{ videoID }], async args => { + let video = content.document.getElementById(args.videoID); + info("Waiting for emptied event to be called"); + await ContentTaskUtils.waitForEvent(video, "emptied"); + }); + + await SpecialPowers.spawn(browser, [{ videoID }], async args => { + let video = content.document.getElementById(args.videoID); + video.setAttribute("src", video.src); + let len = video.textTracks.length; + for (let i = 0; i < len; i++) { + video.removeChild(video.children[0]); + } + video.load(); + }); + + await emptied; + + await TestUtils.waitForCondition(() => { + return subtitlesButton.disabled; + }, "Waiting for subtitles button to be disabled after it was enabled"); + ok(subtitlesButton.disabled, "The subtitles button is disabled"); + + await BrowserTestUtils.closeWindow(pipWin); + } + ); +}); + +/** + * Tests the the subtitles button shows after switching from a video with no subtitles to a video with subtitles + */ +add_task(async () => { + const videoID = "with-controls"; + const videoID2 = "with-controls-no-tracks"; + + await BrowserTestUtils.withNewTab( + { + url: TEST_PAGE_WITH_WEBVTT, + gBrowser, + }, + async browser => { + let pipWin = await triggerPictureInPicture(browser, videoID2); + ok(pipWin, "Got Picture-in-Picture window."); + + // Need to make sure that the PiP window is at least the minimum height + let multiplier = 1; + while (true) { + if (multiplier * pipWin.innerHeight > 325) { + break; + } + multiplier += 0.5; + } + + pipWin.moveTo(50, 50); + pipWin.resizeTo( + pipWin.innerWidth * multiplier, + pipWin.innerHeight * multiplier + ); + + let subtitlesButton = pipWin.document.querySelector("#closed-caption"); + await TestUtils.waitForCondition(() => { + return subtitlesButton.disabled; + }, "Making sure the subtitles button is disabled initially"); + ok(subtitlesButton.disabled, "The subtitles button is disabled"); + + await SpecialPowers.spawn( + browser, + [{ videoID, videoID2 }], + async args => { + let video2 = content.document.getElementById(args.videoID2); + + let track = video2.addTextTrack("captions", "English", "en"); + track.mode = "showing"; + track.addCue( + new content.window.VTTCue(0, 12, "[Test] This is the first cue") + ); + track.addCue( + new content.window.VTTCue(18.7, 21.5, "This is the second cue") + ); + + video2.setAttribute("src", video2.src); + video2.load(); + + is( + video2.textTracks.length, + 1, + "Number of tracks loaded should be 1" + ); + video2.play(); + video2.pause(); + } + ); + + subtitlesButton = pipWin.document.querySelector("#closed-caption"); + await TestUtils.waitForCondition(() => { + return !subtitlesButton.disabled; + }, "Waiting for the subtitles button to be enabled after switching to a video with subtitles."); + ok(!subtitlesButton.disabled, "The subtitles button is enabled"); + + await BrowserTestUtils.closeWindow(pipWin); + } + ); +}); 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..7e792b2e9e --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/head.js @@ -0,0 +1,1153 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TOGGLE_POLICIES } = ChromeUtils.importESModule( + "resource://gre/modules/PictureInPictureControls.sys.mjs" +); + +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 TEST_PAGE_WITHOUT_AUDIO = TEST_ROOT + "test-page-without-audio.html"; +const TEST_PAGE_WITH_NAN_VIDEO_DURATION = + TEST_ROOT + "test-page-with-nan-video-duration.html"; +const TEST_PAGE_WITH_WEBVTT = TEST_ROOT + "test-page-with-webvtt.html"; +const TEST_PAGE_MULTIPLE_CONTEXTS = + TEST_ROOT + "test-page-multiple-contexts.html"; +const TEST_PAGE_TRANSPARENT_NESTED_IFRAMES = + TEST_ROOT + "test-transparent-nested-iframes.html"; +const TEST_PAGE_PIP_DISABLED = TEST_ROOT + "test-page-pipDisabled.html"; +const WINDOW_TYPE = "Toolkit:PictureInPicture"; +const TOGGLE_POSITION_PREF = + "media.videocontrols.picture-in-picture.video-toggle.position"; +/* As of Bug 1811312, 80% toggle opacity is for the PiP toggle experiment control. */ +const DEFAULT_TOGGLE_OPACITY = 0.8; +const HAS_USED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.has-used"; +const SHARED_DATA_KEY = "PictureInPicture:SiteOverrides"; +// Used for clearing the size and location of the PiP window +const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml"; +const ACCEPTABLE_DIFFERENCE = 2; + +/** + * 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": DEFAULT_TOGGLE_OPACITY, + }, + 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. + * + * If triggerFn is not specified, then open using the + * MozTogglePictureInPicture event. + * + * @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 {boolean} triggerFn Use the given function to open the pip window, + * which runs in the parent process. + * + * @return Promise + * @resolves With the Picture-in-Picture window when ready. + */ +async function triggerPictureInPicture(browser, videoID, triggerFn) { + let domWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded(null); + + let videoReady = null; + if (triggerFn) { + await SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + video.focus(); + }); + + triggerFn(); + + videoReady = SpecialPowers.spawn(browser, [videoID], async videoID => { + let video = content.document.getElementById(videoID); + await ContentTaskUtils.waitForCondition(() => { + return video.isCloningElementVisually; + }, "Video is being cloned visually."); + }); + } else { + 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 or browsing contect 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, selector) { + return SpecialPowers.spawn(browser, [selector], async slctr => { + let video = content.document.querySelector(slctr); + 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) or the browsing context + * + * @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) { + video.currentTime = 0; + 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.isHidden(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.sys.mjs. + * 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.importESModule( + "resource://gre/modules/PictureInPictureControls.sys.mjs" + ); + 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." + ); +} + +/** + * Tests that a click event is 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. + * @return Promise + * @resolves When the check has completed. + */ +async function assertSawClickEventOnly(browser) { + let mouseEvents = await SpecialPowers.spawn(browser, [], async () => { + return this.content.wrappedJSObject.getRecordedEvents(); + }); + Assert.deepEqual( + mouseEvents, + ["click"], + "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.importESModule( + "resource://gre/actors/PictureInPictureChild.sys.mjs" + ); + 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.importESModule( + "resource://gre/modules/Geometry.sys.mjs" + ); + + 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, + }; + }); +} + +/** + * This function will hover over the middle of the video and then + * hover over the toggle. + * @param browser The current browser + * @param videoID The video element id + */ +async function hoverToggle(browser, videoID) { + 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, null); + + let toggleClientRect = await getToggleClientRect(browser, videoID); + + 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 + ); +} + +/** + * 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.sys.mjs. + * + * 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, shouldSeeClickEventAfterToggle }, + ] of Object.entries(expectations)) { + await SimpleTest.promiseFocus(browser); + info(`Testing video with id: ${videoID}`); + + await testToggleHelper( + browser, + videoID, + canToggle, + policy, + toggleStyles, + shouldSeeClickEventAfterToggle + ); + } + } + ); +} + +/** + * 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.sys.mjs. + * @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, + shouldSeeClickEventAfterToggle +) { + 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); + + // We do get a "Click" sometimes, it depends on many + // factors such as whether the video has control and + // the style of the toggle. + if (shouldSeeClickEventAfterToggle) { + await assertSawClickEventOnly(browser); + } else { + // 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"); + }); + + 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 + dump(`BJW promiseFullscreenEntered: waiting for 2 second timeout.\n`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } +} + +/** + * 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; + }); +} + +/** + * Helper function that returns True if the specified video is muted + * and False if the specified video is not muted. + * + * @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 isVideoMuted(browser, videoID) { + return SpecialPowers.spawn(browser, [videoID], async videoID => { + return content.document.getElementById(videoID).muted; + }); +} + +/** + * Initializes videos and text tracks for the current test case. + * First track is the default track to be loaded onto the video. + * Once initialization is done, play then pause the requested video. + * so that text tracks are loaded. + * @param {Element} browser The <xul:browser> hosting the <video> + * @param {String} videoID The ID of the video being checked + * @param {Integer} defaultTrackIndex The index of the track to be loaded, or none if -1 + * @param {String} trackMode the mode that the video's textTracks should be set to + */ +async function prepareVideosAndWebVTTTracks( + browser, + videoID, + defaultTrackIndex = 0, + trackMode = "showing" +) { + info("Preparing video and initial text tracks"); + await ensureVideosReady(browser); + await SpecialPowers.spawn( + browser, + [{ videoID, defaultTrackIndex, trackMode }], + async args => { + let video = content.document.getElementById(args.videoID); + let tracks = video.textTracks; + + is(tracks.length, 5, "Number of tracks loaded should be 5"); + + // Enable track for originating video + if (args.defaultTrackIndex >= 0) { + info(`Loading track ${args.defaultTrackIndex + 1}`); + let track = tracks[args.defaultTrackIndex]; + tracks.mode = args.trackMode; + track.mode = args.trackMode; + } + + // Briefly play the video to load text tracks onto the pip window. + info("Playing video to load text tracks"); + video.play(); + info("Pausing video"); + video.pause(); + ok(video.paused, "Video should be paused before proceeding with test"); + } + ); +} + +/** + * Plays originating video until the next cue is loaded. + * Once the next cue is loaded, pause the video. + * @param {Element} browser The <xul:browser> hosting the <video> + * @param {String} videoID The ID of the video being checked + * @param {Integer} textTrackIndex The index of the track to be loaded, or none if -1 + */ +async function waitForNextCue(browser, videoID, textTrackIndex = 0) { + if (textTrackIndex < 0) { + ok(false, "Cannot wait for next cue with invalid track index"); + } + + await SpecialPowers.spawn( + browser, + [{ videoID, textTrackIndex }], + async args => { + let video = content.document.getElementById(args.videoID); + info("Playing video to activate next cue"); + video.play(); + ok(!video.paused, "Video is playing"); + + info("Waiting until cuechange is called"); + await ContentTaskUtils.waitForEvent( + video.textTracks[args.textTrackIndex], + "cuechange" + ); + + info("Pausing video to read text track"); + video.pause(); + ok(video.paused, "Video is paused"); + } + ); +} + +/** + * The PiP window saves the positon when closed and sometimes we don't want + * this information to persist to other tests. This function will clear the + * position so the PiP window will open in the default position. + */ +function clearSavedPosition() { + 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 overrideSavedPosition(left, top, width, height) { + let xulStore = Services.xulStore; + 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); +} + +/** + * Function used to filter events when waiting for the correct number + * telemetry events. + * @param {String} expected The expected string or undefined + * @param {String} actual The actual string + * @returns true if the expected is undefined or if expected matches actual + */ +function matches(expected, actual) { + if (expected === undefined) { + return true; + } + return expected === actual; +} + +/** + * Function that waits for the expected number of events aftering filtering. + * @param {Object} filter An object containing optional filters + * { + * category: (optional) The category of the event. Ex. "pictureinpicture" + * method: (optional) The method of the event. Ex. "create" + * object: (optional) The object of the event. Ex. "player" + * } + * @param {Number} length The number of events to wait for + * @param {String} process Should be "content" or "parent" depending on the event + */ +async function waitForTelemeryEvents(filter, length, process) { + let { + category: filterCategory, + method: filterMethod, + object: filterObject, + } = filter; + + let events = []; + await TestUtils.waitForCondition( + () => { + events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + )[process]; + if (!events) { + return false; + } + + let filtered = events + .map(([, /* timestamp */ category, method, object, value, extra]) => { + // We don't care about the `timestamp` value. + // Tests that examine that value should use `snapshotEvents` directly. + return [category, method, object, value, extra]; + }) + .filter(([category, method, object]) => { + return ( + matches(filterCategory, category) && + matches(filterMethod, method) && + matches(filterObject, object) + ); + }); + info(JSON.stringify(filtered, null, 2)); + return filtered && filtered.length >= length; + }, + `Waiting for ${length} pictureinpicture telemetry event(s) with filter ${JSON.stringify( + filter, + null, + 2 + )}`, + 200, + 100 + ); +} diff --git a/toolkit/components/pictureinpicture/tests/no-audio-track.webm b/toolkit/components/pictureinpicture/tests/no-audio-track.webm Binary files differnew file mode 100644 index 0000000000..72b0297233 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/no-audio-track.webm 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-multiple-contexts.html b/toolkit/components/pictureinpicture/tests/test-page-multiple-contexts.html new file mode 100644 index 0000000000..420370aff2 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-page-multiple-contexts.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Picture-in-Picture tests</title> +</head> +<style> + video { + display: block; + border: 1px solid black; + } +</style> +<body> + <h1>Video in page</h1> + <video id="with-controls" src="test-video.mp4" controls loop="true" width="400" height="225"></video> + <h1>Video in frame</h1> + <iframe id="iframe" width="400" height="225" src="test-video.mp4"></iframe> +</body> +</html> diff --git a/toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html b/toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html new file mode 100644 index 0000000000..142d2d74d2 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html @@ -0,0 +1,18 @@ +<!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 PiPDisabled</h1> + <video id="with-controls" src="test-video.mp4" controls loop="true" width="400" height="225" disablePictureInPicture="true"></video> +</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-nan-video-duration.html b/toolkit/components/pictureinpicture/tests/test-page-with-nan-video-duration.html new file mode 100644 index 0000000000..b16c3682a0 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-page-with-nan-video-duration.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>Bug 1679174</title> + <script type="text/javascript" src="click-event-helper.js"></script> + </head> + <body> + <video id="nan-duration"></video> + <video controls id="test-video"> + <source src="test-video.mp4" type="video/mp4"> + </video> + </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-with-webvtt.html b/toolkit/components/pictureinpicture/tests/test-page-with-webvtt.html new file mode 100644 index 0000000000..e05ad8dd2c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-page-with-webvtt.html @@ -0,0 +1,66 @@ +<!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-long.mp4" controls width="400" height="225"> + <track + id="track1" + kind="captions" + label="[test] en" + srclang="en" + src="test-webvtt-1.vtt" + /> + <track + id="track2" + kind="subtitles" + label="[test] fr" + srclang="fr" + src="test-webvtt-2.vtt" + /> + <track + id="track3" + kind="subtitles" + label="[test] eo" + srclang="eo" + src="test-webvtt-3.vtt" + /> + <track + id="track4" + kind="subtitles" + label="[test] zh" + srclang="zh" + src="test-webvtt-4.vtt" + /> + <track + id="track5" + kind="subtitles" + label="[test] es" + srclang="es" + src="test-webvtt-5.vtt" + /> + </video> + + <video id="with-controls-no-tracks" src="test-video-long.mp4" controls width="400" height="225"></video> + + <script> + function fireEvents() { + for (let videoID of ["with-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-page-without-audio.html b/toolkit/components/pictureinpicture/tests/test-page-without-audio.html new file mode 100644 index 0000000000..862042cd59 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-page-without-audio.html @@ -0,0 +1,18 @@ +<!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 without audio track</h1> + <video id="without-audio" src="no-audio-track.webm" controls width="400" height="225"></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 diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt new file mode 100644 index 0000000000..fd16ef6d32 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt @@ -0,0 +1,10 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:01.000 +track 1 - cue 1 + +2 +00:00:02.000 --> 00:00:05.000 +- <b>track 1 - cue 2 bold</b> +- <i>track 1 - cue 2 italicized<i> diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt new file mode 100644 index 0000000000..21fda2b75c --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt @@ -0,0 +1,10 @@ +WEBVTT + +1 +00:00:00.000 --> 00:00:01.000 +track 2 - cue 1 + +2 +00:00:02.000 --> 00:00:05.000 +- <b>track 2 - cue 2 bold</b> +- <i>track 2 - cue 2 italicized<i> diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt new file mode 100644 index 0000000000..0207d9e65f --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt @@ -0,0 +1,11 @@ +WEBVTT + +Test file with multiple active cues and VTTCue.line as "auto" + +1 +00:00:00.000 --> 00:00:01.000 +track 3 - cue 1 + +2 +00:00:00.000 --> 00:00:01.000 +track 3 - cue 2 diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt new file mode 100644 index 0000000000..9e4a540f7f --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt @@ -0,0 +1,15 @@ +WEBVTT + +Test file with multiple active cues and VTTCue.line as an integer + +1 +00:00:00.000 --> 00:00:01.000 line:2 +track 4 - cue 1 - integer line + +2 +00:00:00.000 --> 00:00:01.000 line:3 +track 4 - cue 2 - integer line + +3 +00:00:00.000 --> 00:00:01.000 line:1 +track 4 - cue 3 - integer line diff --git a/toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt b/toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt new file mode 100644 index 0000000000..3a25d83529 --- /dev/null +++ b/toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt @@ -0,0 +1,12 @@ +WEBVTT + +Test file with multiple active cues and VTTCue.line as a percentage value + +00:00:00.000 --> 00:00:01.000 line:90% +track 5 - cue 1 - percent line + +00:00:00.000 --> 00:00:01.000 line:10% +track 5 - cue 2 - percent line + +00:00:00.000 --> 00:00:01.000 line:50% +track 5 - cue 3 - percent line |