From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- .../pictureinpicture/PictureInPicture.sys.mjs | 1603 ++++++++++++++++++++ .../PictureInPictureControls.sys.mjs | 40 + .../content/pictureInPicturePanel.xhtml | 47 + .../components/pictureinpicture/content/player.js | 1283 ++++++++++++++++ .../pictureinpicture/content/player.xhtml | 117 ++ .../pictureinpicture/docs/PiP-diagram.svg | 4 + toolkit/components/pictureinpicture/docs/index.rst | 385 +++++ .../docs/picture-in-picture-api.rst | 4 + .../pictureinpicture/docs/player-api.rst | 4 + toolkit/components/pictureinpicture/jar.mn | 8 + toolkit/components/pictureinpicture/moz.build | 21 + .../components/pictureinpicture/tests/browser.ini | 155 ++ ...owser_aaa_run_first_firstTimePiPToggleEvents.js | 314 ++++ .../tests/browser_aaa_telemetry_togglePiP.js | 69 + .../tests/browser_backgroundTab.js | 93 ++ .../tests/browser_cannotTriggerFromContent.js | 32 + .../tests/browser_changePiPSrcInFullscreen.js | 511 +++++++ .../tests/browser_closePipPause.js | 68 + .../browser_closePip_pageNavigationChanges.js | 113 ++ .../pictureinpicture/tests/browser_closePlayer.js | 48 + .../pictureinpicture/tests/browser_closeTab.js | 25 + .../tests/browser_close_unpip_focus.js | 72 + .../tests/browser_conflictingPips.js | 46 + .../pictureinpicture/tests/browser_contextMenu.js | 238 +++ .../tests/browser_controlsHover.js | 191 +++ .../tests/browser_cornerSnapping.js | 271 ++++ .../tests/browser_dblclickFullscreen.js | 101 ++ .../tests/browser_durationChange.js | 61 + .../tests/browser_flipIconWithRTL.js | 66 + .../tests/browser_fontSize_change.js | 152 ++ .../pictureinpicture/tests/browser_fullscreen.js | 142 ++ .../tests/browser_improved_controls.js | 303 ++++ .../tests/browser_keyboardClosePIPwithESC.js | 71 + .../tests/browser_keyboardFullScreenPIPShortcut.js | 41 + .../tests/browser_keyboardShortcut.js | 134 ++ .../tests/browser_keyboardShortcutClosePIP.js | 53 + .../browser_keyboardShortcutWithNanDuration.js | 54 + .../tests/browser_keyboardToggle.js | 32 + .../tests/browser_mediaStreamVideos.js | 59 + .../tests/browser_mouseButtonVariation.js | 97 ++ .../pictureinpicture/tests/browser_multiPip.js | 225 +++ .../tests/browser_nimbusDisplayDuration.js | 214 +++ .../tests/browser_nimbusFirstTimeStyleVariant.js | 118 ++ .../tests/browser_nimbusMessageFirstTimePip.js | 121 ++ .../tests/browser_nimbusShowIconOnly.js | 114 ++ .../browser_noPlayerControlsOnMiddleRightClick.js | 53 + .../tests/browser_noToggleOnAudio.js | 46 + .../tests/browser_occluded_window.js | 258 ++++ .../tests/browser_playerControls.js | 86 ++ .../tests/browser_preserveTabPipIconOverlay.js | 167 ++ .../tests/browser_privateWindow.js | 38 + .../tests/browser_removeVideoElement.js | 84 + .../pictureinpicture/tests/browser_resizeVideo.js | 293 ++++ .../pictureinpicture/tests/browser_reversePiP.js | 145 ++ .../tests/browser_saveLastPiPLoc.js | 398 +++++ .../tests/browser_shortcutsAfterFocus.js | 67 + .../pictureinpicture/tests/browser_showMessage.js | 29 + .../tests/browser_smallVideoLayout.js | 210 +++ .../tests/browser_stripVideoStyles.js | 49 + .../tests/browser_subtitles_settings_panel.js | 273 ++++ .../tests/browser_tabIconOverlayPiP.js | 90 ++ .../tests/browser_telemetry_enhancements.js | 230 +++ .../tests/browser_text_tracks_webvtt_1.js | 129 ++ .../tests/browser_text_tracks_webvtt_2.js | 444 ++++++ .../tests/browser_text_tracks_webvtt_3.js | 218 +++ .../tests/browser_thirdPartyIframe.js | 72 + .../tests/browser_toggleAfterTabTearOutIn.js | 58 + .../tests/browser_toggleButtonOnNanDuration.js | 32 + .../tests/browser_toggleButtonOverlay.js | 17 + .../pictureinpicture/tests/browser_toggleMode_2.js | 202 +++ .../tests/browser_toggleOnInsertedVideo.js | 42 + .../tests/browser_toggleOpaqueOverlay.js | 16 + .../tests/browser_togglePointerEventsNone.js | 16 + .../tests/browser_togglePolicies.js | 127 ++ .../tests/browser_togglePositionChange.js | 58 + .../pictureinpicture/tests/browser_toggleSimple.js | 18 + .../tests/browser_toggleTransparentOverlay-1.js | 33 + .../tests/browser_toggleTransparentOverlay-2.js | 33 + .../tests/browser_toggle_enabled.js | 99 ++ .../tests/browser_toggle_videocontrols.js | 76 + .../tests/browser_toggle_without_audio.js | 71 + .../tests/browser_touch_toggle_enablepip.js | 217 +++ .../tests/browser_urlbar_toggle.js | 329 ++++ .../pictureinpicture/tests/browser_videoEmptied.js | 155 ++ .../tests/browser_videoSelection.js | 106 ++ .../pictureinpicture/tests/click-event-helper.js | 26 + toolkit/components/pictureinpicture/tests/head.js | 1110 ++++++++++++++ .../pictureinpicture/tests/no-audio-track.webm | Bin 0 -> 215529 bytes .../components/pictureinpicture/tests/short.mp4 | Bin 0 -> 38713 bytes .../tests/test-button-overlay.html | 81 + .../pictureinpicture/tests/test-media-stream.html | 25 + .../tests/test-opaque-overlay.html | 51 + .../tests/test-page-multiple-contexts.html | 19 + .../tests/test-page-pipDisabled.html | 18 + .../tests/test-page-with-iframe.html | 27 + .../tests/test-page-with-nan-video-duration.html | 14 + .../tests/test-page-with-sound.html | 20 + .../tests/test-page-with-webvtt.html | 66 + .../tests/test-page-without-audio.html | 18 + .../pictureinpicture/tests/test-page.html | 30 + .../tests/test-pointer-events-none.html | 21 + .../pictureinpicture/tests/test-reversed.html | 19 + .../tests/test-transparent-nested-iframes.html | 51 + .../tests/test-transparent-overlay-1.html | 46 + .../tests/test-transparent-overlay-2.html | 46 + .../pictureinpicture/tests/test-video-cropped.mp4 | Bin 0 -> 36502 bytes .../pictureinpicture/tests/test-video-long.mp4 | Bin 0 -> 344085 bytes .../tests/test-video-selection.html | 22 + .../pictureinpicture/tests/test-video-vertical.mp4 | Bin 0 -> 36502 bytes .../pictureinpicture/tests/test-video.mp4 | Bin 0 -> 242969 bytes .../pictureinpicture/tests/test-webvtt-1.vtt | 10 + .../pictureinpicture/tests/test-webvtt-2.vtt | 10 + .../pictureinpicture/tests/test-webvtt-3.vtt | 11 + .../pictureinpicture/tests/test-webvtt-4.vtt | 15 + .../pictureinpicture/tests/test-webvtt-5.vtt | 12 + 115 files changed, 14772 insertions(+) create mode 100644 toolkit/components/pictureinpicture/PictureInPicture.sys.mjs create mode 100644 toolkit/components/pictureinpicture/PictureInPictureControls.sys.mjs create mode 100644 toolkit/components/pictureinpicture/content/pictureInPicturePanel.xhtml create mode 100644 toolkit/components/pictureinpicture/content/player.js create mode 100644 toolkit/components/pictureinpicture/content/player.xhtml create mode 100644 toolkit/components/pictureinpicture/docs/PiP-diagram.svg create mode 100644 toolkit/components/pictureinpicture/docs/index.rst create mode 100644 toolkit/components/pictureinpicture/docs/picture-in-picture-api.rst create mode 100644 toolkit/components/pictureinpicture/docs/player-api.rst create mode 100644 toolkit/components/pictureinpicture/jar.mn create mode 100644 toolkit/components/pictureinpicture/moz.build create mode 100644 toolkit/components/pictureinpicture/tests/browser.ini create mode 100644 toolkit/components/pictureinpicture/tests/browser_aaa_run_first_firstTimePiPToggleEvents.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_aaa_telemetry_togglePiP.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_backgroundTab.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_cannotTriggerFromContent.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_changePiPSrcInFullscreen.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_closePipPause.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_closePip_pageNavigationChanges.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_closePlayer.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_closeTab.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_close_unpip_focus.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_conflictingPips.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_contextMenu.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_controlsHover.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_cornerSnapping.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_dblclickFullscreen.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_durationChange.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_flipIconWithRTL.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_fontSize_change.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_fullscreen.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_improved_controls.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_keyboardClosePIPwithESC.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_keyboardFullScreenPIPShortcut.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_keyboardShortcut.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_keyboardShortcutClosePIP.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_keyboardShortcutWithNanDuration.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_keyboardToggle.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_mediaStreamVideos.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_mouseButtonVariation.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_multiPip.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_nimbusDisplayDuration.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_nimbusFirstTimeStyleVariant.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_nimbusMessageFirstTimePip.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_nimbusShowIconOnly.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_noPlayerControlsOnMiddleRightClick.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_noToggleOnAudio.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_occluded_window.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_playerControls.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_preserveTabPipIconOverlay.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_privateWindow.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_removeVideoElement.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_resizeVideo.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_reversePiP.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_saveLastPiPLoc.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_shortcutsAfterFocus.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_showMessage.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_smallVideoLayout.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_stripVideoStyles.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_subtitles_settings_panel.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_tabIconOverlayPiP.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_telemetry_enhancements.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_1.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_2.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_text_tracks_webvtt_3.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_thirdPartyIframe.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleAfterTabTearOutIn.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleButtonOnNanDuration.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleButtonOverlay.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleMode_2.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleOnInsertedVideo.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleOpaqueOverlay.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_togglePointerEventsNone.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_togglePolicies.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_togglePositionChange.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleSimple.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-1.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggleTransparentOverlay-2.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggle_enabled.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggle_videocontrols.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_toggle_without_audio.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_touch_toggle_enablepip.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_urlbar_toggle.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_videoEmptied.js create mode 100644 toolkit/components/pictureinpicture/tests/browser_videoSelection.js create mode 100644 toolkit/components/pictureinpicture/tests/click-event-helper.js create mode 100644 toolkit/components/pictureinpicture/tests/head.js create mode 100644 toolkit/components/pictureinpicture/tests/no-audio-track.webm create mode 100644 toolkit/components/pictureinpicture/tests/short.mp4 create mode 100644 toolkit/components/pictureinpicture/tests/test-button-overlay.html create mode 100644 toolkit/components/pictureinpicture/tests/test-media-stream.html create mode 100644 toolkit/components/pictureinpicture/tests/test-opaque-overlay.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page-multiple-contexts.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page-pipDisabled.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page-with-iframe.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page-with-nan-video-duration.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page-with-sound.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page-with-webvtt.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page-without-audio.html create mode 100644 toolkit/components/pictureinpicture/tests/test-page.html create mode 100644 toolkit/components/pictureinpicture/tests/test-pointer-events-none.html create mode 100644 toolkit/components/pictureinpicture/tests/test-reversed.html create mode 100644 toolkit/components/pictureinpicture/tests/test-transparent-nested-iframes.html create mode 100644 toolkit/components/pictureinpicture/tests/test-transparent-overlay-1.html create mode 100644 toolkit/components/pictureinpicture/tests/test-transparent-overlay-2.html create mode 100644 toolkit/components/pictureinpicture/tests/test-video-cropped.mp4 create mode 100644 toolkit/components/pictureinpicture/tests/test-video-long.mp4 create mode 100644 toolkit/components/pictureinpicture/tests/test-video-selection.html create mode 100644 toolkit/components/pictureinpicture/tests/test-video-vertical.mp4 create mode 100644 toolkit/components/pictureinpicture/tests/test-video.mp4 create mode 100644 toolkit/components/pictureinpicture/tests/test-webvtt-1.vtt create mode 100644 toolkit/components/pictureinpicture/tests/test-webvtt-2.vtt create mode 100644 toolkit/components/pictureinpicture/tests/test-webvtt-3.vtt create mode 100644 toolkit/components/pictureinpicture/tests/test-webvtt-4.vtt create mode 100644 toolkit/components/pictureinpicture/tests/test-webvtt-5.vtt (limited to 'toolkit/components/pictureinpicture') diff --git a/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs new file mode 100644 index 0000000000..8916d92313 --- /dev/null +++ b/toolkit/components/pictureinpicture/PictureInPicture.sys.mjs @@ -0,0 +1,1603 @@ +/* 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, { + 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; + } + } + } +} + +/** + * 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"); + pipToggle.hidden = !( + totalPipCount === 1 || + (totalPipDisabled > 0 && lazy.RESPECT_PIP_DISABLED) + ); + + 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 actor = windowGlobal.getActor("PictureInPictureToggle"); + actor.sendAsyncMessage("PictureInPicture:UrlbarToggle"); + 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); + + 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.mozInputSource + ); + 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.jsm 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.xhtml b/toolkit/components/pictureinpicture/content/pictureInPicturePanel.xhtml new file mode 100644 index 0000000000..30d6b93d59 --- /dev/null +++ b/toolkit/components/pictureinpicture/content/pictureInPicturePanel.xhtml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/toolkit/components/pictureinpicture/content/player.js b/toolkit/components/pictureinpicture/content/player.js new file mode 100644 index 0000000000..531959d849 --- /dev/null +++ b/toolkit/components/pictureinpicture/content/player.js @@ -0,0 +1,1283 @@ +/* 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"; + +// 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); +} + +/** + * The Player object handles initializing the player, holds state, and handles + * events for updating state. + */ +let Player = { + 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); + }); + + 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; + } + + 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"; + } + }, + + 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 + // is fullscreened, and not a element in a parent-process + // chrome privileged DOM window. + // + // Rather than trying to re-engineer JSWindowActors to be re-usable for + // this edge-case, we do the work of firing fullscreen-painted when + // transitioning in and out of fullscreen ourselves here. + case "MozDOMFullscreen:Entered": + // Intentional fall-through + case "MozDOMFullscreen:Exited": { + let { lastTransactionId } = window.windowUtils; + window.addEventListener("MozAfterPaint", function onPainted(event) { + if (event.transactionId > lastTransactionId) { + window.removeEventListener("MozAfterPaint", onPainted); + Services.obs.notifyObservers(window, "fullscreen-painted"); + } + }); + + // 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 + // handle the keydown event in onKeyDown and we don't want to handle it 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; + }, + + 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; + }, + + 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": { + if (this.isMuted) { + this.actor.sendAsyncMessage("PictureInPicture:Unmute"); + } else { + this.actor.sendAsyncMessage("PictureInPicture:Mute"); + } + 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.mozInputSource == 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.actor.sendAsyncMessage("PictureInPicture:HideVideoControls", { + isFullscreen: this.isFullscreen, + isVideoControlsShowing: false, + playerBottomControlsDOMRect: null, + }); + } 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(); + } + }, + + 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 arrow + // 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 keydown event was one of the arrow keys and the scrubber was + // focused then we will also get an input event that will overwrite the + // keydown event if we dont' prevent the input event. + if ( + 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.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.actor.sendAsyncMessage("PictureInPicture:HideVideoControls", { + isFullscreen: this.isFullscreen, + isVideoControlsShowing: false, + playerBottomControlsDOMRect: null, + }); + } + } + }, + + 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 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); + }, + + _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; + 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 + */ + 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, + }); + }, + + /** + * 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.actor.sendAsyncMessage("PictureInPicture:HideVideoControls", { + isFullscreen: false, + isVideoControlsShowing: false, + playerBottomControlsDOMRect: null, + }); + } + }, 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..82e4c8a0c7 --- /dev/null +++ b/toolkit/components/pictureinpicture/content/player.xhtml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + +#ifndef XP_MACOSX + + +#else + + +#endif + + + +
+ +
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+ + + + + diff --git a/toolkit/components/pictureinpicture/docs/PiP-diagram.svg b/toolkit/components/pictureinpicture/docs/PiP-diagram.svg new file mode 100644 index 0000000000..391cc7728a --- /dev/null +++ b/toolkit/components/pictureinpicture/docs/PiP-diagram.svg @@ -0,0 +1,4 @@ + +
Twitch
Twitch
https://www.twitch.tv
https://www.twitch.tv
Video
Video
Content process hosting the video

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

PictureInPicture.jsm
Parent process
Parent process
player.xhtml / player.js - alwaysontop window
player.xhtml / player.js - alwaysontop wi...
remote <xul:browser> running in the same content process as
original video
remote <xul:browser> running in the same content process...
Player video
Player video
JSWindowActor
Messaging
JSWindowActor...
PictureInPictureParent
PictureInPictureParent
Services.ww.openWindow
Servic...
\ No newline at end of file diff --git a/toolkit/components/pictureinpicture/docs/index.rst b/toolkit/components/pictureinpicture/docs/index.rst new file mode 100644 index 0000000000..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 ``