summaryrefslogtreecommitdiffstats
path: root/browser/extensions/pictureinpicture
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js230
-rw-r--r--browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.js65
-rw-r--r--browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.json59
-rw-r--r--browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.js84
-rw-r--r--browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.json51
-rw-r--r--browser/extensions/pictureinpicture/lib/picture_in_picture_overrides.js98
-rw-r--r--browser/extensions/pictureinpicture/manifest.json48
-rw-r--r--browser/extensions/pictureinpicture/moz.build52
-rw-r--r--browser/extensions/pictureinpicture/run.js9
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/.eslintrc.js14
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/browser.ini19
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/browser_mock_wrapper.js207
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html22
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.js31
-rw-r--r--browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html22
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/airmozilla.js38
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/bbc.js31
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/dailymotion.js42
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/disneyplus.js40
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/hbomax.js46
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/hotstar.js41
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/hulu.js51
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/mock-wrapper.js34
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/netflix.js78
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/piped.js41
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/primeVideo.js101
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/tubi.js35
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/videojsWrapper.js38
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/voot.js37
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/washingtonpost.js42
-rw-r--r--browser/extensions/pictureinpicture/video-wrappers/youtube.js56
31 files changed, 1762 insertions, 0 deletions
diff --git a/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js b/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js
new file mode 100644
index 0000000000..7aa35cff1e
--- /dev/null
+++ b/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js
@@ -0,0 +1,230 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser */
+
+let AVAILABLE_PIP_OVERRIDES;
+
+{
+ // See PictureInPictureControls.sys.mjs for these values.
+ // eslint-disable-next-line no-unused-vars
+ const TOGGLE_POLICIES = browser.pictureInPictureChild.getPolicies();
+
+ AVAILABLE_PIP_OVERRIDES = {
+ // The keys of this object are match patterns for URLs, as documented in
+ // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns
+ //
+ // Example:
+ // const KEYBOARD_CONTROLS = browser.pictureInPictureChild.getKeyboardControls();
+ //
+ //
+ // "https://*.youtube.com/*": {
+ // policy: TOGGLE_POLICIES.THREE_QUARTERS,
+ // keyboardControls: KEYBOARD_CONTROLS.PLAY_PAUSE | KEYBOARD_CONTROLS.VOLUME,
+ // },
+ // "https://*.twitch.tv/mikeconley_dot_ca/*": {
+ // policy: TOGGLE_POLICIES.TOP,
+ // keyboardControls: KEYBOARD_CONTROLS.NONE,
+ // },
+
+ tests: {
+ // FOR TESTS ONLY!
+ "https://mochitest.youtube.com/*browser/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html": {
+ videoWrapperScriptPath: "video-wrappers/mock-wrapper.js",
+ },
+ "https://mochitest.youtube.com/*browser/browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html": {
+ videoWrapperScriptPath: "video-wrappers/mock-wrapper.js",
+ },
+ },
+
+ abcnews: {
+ "https://*.abcnews.go.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ airmozilla: {
+ "https://*.mozilla.hosted.panopto.com/*": {
+ videoWrapperScriptPath: "video-wrappers/airmozilla.js",
+ },
+ },
+
+ bbc: {
+ "https://*.bbc.com/*": {
+ videoWrapperScriptPath: "video-wrappers/bbc.js",
+ },
+ "https://*.bbc.co.uk/*": {
+ videoWrapperScriptPath: "video-wrappers/bbc.js",
+ },
+ },
+
+ brightcove: {
+ "https://*.brightcove.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ dailymotion: {
+ "https://*.dailymotion.com/*": {
+ videoWrapperScriptPath: "video-wrappers/dailymotion.js",
+ },
+ },
+
+ disneyplus: {
+ "https://*.disneyplus.com/*": {
+ videoWrapperScriptPath: "video-wrappers/disneyplus.js",
+ },
+ },
+
+ frontendMasters: {
+ "https://*.frontendmasters.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ funimation: {
+ "https://*.funimation.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ hbomax: {
+ "https://play.hbomax.com/page/*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://play.hbomax.com/player/*": {
+ videoWrapperScriptPath: "video-wrappers/hbomax.js",
+ },
+ },
+
+ hotstar: {
+ "https://*.hotstar.com/*": {
+ videoWrapperScriptPath: "video-wrappers/hotstar.js",
+ },
+ },
+
+ hulu: {
+ "https://www.hulu.com/watch/*": {
+ videoWrapperScriptPath: "video-wrappers/hulu.js",
+ },
+ },
+
+ instagram: {
+ "https://www.instagram.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER },
+ },
+
+ laracasts: {
+ "https://*.laracasts.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER },
+ },
+
+ mxplayer: {
+ "https://*.mxplayer.in/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ nebula: {
+ "https://*.nebula.app/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ netflix: {
+ "https://*.netflix.com/*": {
+ videoWrapperScriptPath: "video-wrappers/netflix.js",
+ },
+ "https://*.netflix.com/browse*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/latest*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/Kids*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/title*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/notification*": { policy: TOGGLE_POLICIES.HIDDEN },
+ "https://*.netflix.com/search*": { policy: TOGGLE_POLICIES.HIDDEN },
+ },
+
+ pbs: {
+ "https://*.pbs.org/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ "https://*.pbskids.org/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ piped: {
+ "https://*.piped.kavin.rocks/*": {
+ videoWrapperScriptPath: "video-wrappers/piped.js",
+ },
+ "https://*.piped.silkky.cloud/*": {
+ videoWrapperScriptPath: "video-wrappers/piped.js",
+ },
+ },
+
+ sonyliv: {
+ "https://*.sonyliv.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ tubi: {
+ "https://*.tubitv.com/*": {
+ videoWrapperScriptPath: "video-wrappers/tubi.js",
+ },
+ },
+
+ twitch: {
+ "https://*.twitch.tv/*": { policy: TOGGLE_POLICIES.ONE_QUARTER },
+ "https://*.twitch.tech/*": { policy: TOGGLE_POLICIES.ONE_QUARTER },
+ "https://*.twitch.a2z.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER },
+ },
+
+ udemy: {
+ "https://*.udemy.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER },
+ },
+
+ voot: {
+ "https://*.voot.com/*": {
+ videoWrapperScriptPath: "video-wrappers/voot.js",
+ },
+ },
+
+ wired: {
+ "https://*.wired.com/*": {
+ videoWrapperScriptPath: "video-wrappers/videojsWrapper.js",
+ },
+ },
+
+ youtube: {
+ /**
+ * The threshold of 0.7 is so that users can click on the "Skip Ads"
+ * button on the YouTube site player without accidentally triggering
+ * PiP.
+ */
+ "https://*.youtube.com/*": {
+ visibilityThreshold: 0.7,
+ videoWrapperScriptPath: "video-wrappers/youtube.js",
+ },
+ "https://*.youtube-nocookie.com/*": {
+ visibilityThreshold: 0.9,
+ videoWrapperScriptPath: "video-wrappers/youtube.js",
+ },
+ },
+
+ washingtonpost: {
+ "https://*.washingtonpost.com/*": {
+ videoWrapperScriptPath: "video-wrappers/washingtonpost.js",
+ },
+ },
+
+ primeVideo: {
+ "https://*.primevideo.com/*": {
+ visibilityThreshold: 0.9,
+ videoWrapperScriptPath: "video-wrappers/primeVideo.js",
+ },
+ "https://*.amazon.com/*": {
+ visibilityThreshold: 0.9,
+ videoWrapperScriptPath: "video-wrappers/primeVideo.js",
+ },
+ },
+ };
+}
diff --git a/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.js b/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.js
new file mode 100644
index 0000000000..0d7e15de05
--- /dev/null
+++ b/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.js
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global ExtensionAPI, ExtensionCommon, Services, XPCOMUtils */
+
+/**
+ * Class extending the ExtensionAPI, ensures we can set/get preferences
+ */
+this.aboutConfigPipPrefs = class extends ExtensionAPI {
+ /**
+ * Override ExtensionAPI with PiP override's specific preference API, prefixed by `disabled_picture_in_picture_overrides`
+ * @param {ExtensionContext} context the context of an extension
+ * @returns {Object} returns the necessary API structure required to manage prefs within this extension
+ */
+ getAPI(context) {
+ const EventManager = ExtensionCommon.EventManager;
+ const extensionIDBase = context.extension.id.split("@")[0];
+ const extensionPrefNameBase = `extensions.${extensionIDBase}.`;
+
+ return {
+ aboutConfigPipPrefs: {
+ onPrefChange: new EventManager({
+ context,
+ name: "aboutConfigPipPrefs.onSiteOverridesPrefChange",
+ register: (fire, name) => {
+ const prefName = `${extensionPrefNameBase}${name}`;
+ const callback = () => {
+ fire.async(name).catch(() => {}); // ignore Message Manager disconnects
+ };
+ Services.prefs.addObserver(prefName, callback);
+ return () => {
+ Services.prefs.removeObserver(prefName, callback);
+ };
+ },
+ }).api(),
+ /**
+ * Calls `Services.prefs.getBoolPref` to get a preference
+ * @param {String} name The name of the preference to get; will be prefixed with this extension's branch
+ * @returns the preference, or undefined
+ */
+ async getPref(name) {
+ try {
+ return Services.prefs.getBoolPref(
+ `${extensionPrefNameBase}${name}`
+ );
+ } catch (_) {
+ return undefined;
+ }
+ },
+
+ /**
+ * Calls `Services.prefs.setBoolPref` to set a preference
+ * @param {String} name the name of the preference to set; will be prefixed with this extension's branch
+ * @param {String} value the bool value to save in the pref
+ */
+ async setPref(name, value) {
+ Services.prefs.setBoolPref(`${extensionPrefNameBase}${name}`, value);
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.json b/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.json
new file mode 100644
index 0000000000..8b2b352667
--- /dev/null
+++ b/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.json
@@ -0,0 +1,59 @@
+[
+ {
+ "namespace": "aboutConfigPipPrefs",
+ "description": "experimental API extension to allow access to about:config preferences",
+ "events": [
+ {
+ "name": "onPrefChange",
+ "type": "function",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference which changed"
+ }
+ ],
+ "extraParameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference to monitor"
+ }
+ ]
+ }
+ ],
+ "functions": [
+ {
+ "name": "getPref",
+ "type": "function",
+ "description": "Get a preference's value",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference name"
+ }
+ ],
+ "async": true
+ },
+ {
+ "name": "setPref",
+ "type": "function",
+ "description": "Set a preference's value",
+ "parameters": [
+ {
+ "name": "name",
+ "type": "string",
+ "description": "The preference name"
+ },
+ {
+ "name": "value",
+ "type": "boolean",
+ "description": "The new value"
+ }
+ ],
+ "async": true
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.js b/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.js
new file mode 100644
index 0000000000..45323f5280
--- /dev/null
+++ b/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.js
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* global AppConstants, ChromeUtils, ExtensionAPI, Services */
+
+ChromeUtils.defineESModuleGetters(this, {
+ KEYBOARD_CONTROLS: "resource://gre/modules/PictureInPictureControls.sys.mjs",
+ TOGGLE_POLICIES: "resource://gre/modules/PictureInPictureControls.sys.mjs",
+});
+
+const TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+
+/**
+ * This API is expected to be running in the parent process.
+ */
+this.pictureInPictureParent = class extends ExtensionAPI {
+ /**
+ * Override ExtensionAPI with PiP override's specific API
+ * Relays the site overrides to this extension's child process
+ * @param {ExtensionContext} context the context of our extension
+ * @returns {Object} returns the necessary API structure required to manage sharedData in PictureInPictureParent
+ */
+ getAPI(context) {
+ return {
+ pictureInPictureParent: {
+ setOverrides(overrides) {
+ // The Picture-in-Picture toggle is only implemented for Desktop, so make
+ // this a no-op for non-Desktop builds.
+ if (AppConstants.platform == "android") {
+ return;
+ }
+
+ Services.ppmm.sharedData.set(
+ "PictureInPicture:SiteOverrides",
+ overrides
+ );
+ },
+ },
+ };
+ }
+};
+
+/**
+ * This API is expected to be running in a content process - specifically,
+ * the WebExtension content process that the background scripts run in. We
+ * split these out so that they can return values synchronously to the
+ * background scripts.
+ */
+this.pictureInPictureChild = class extends ExtensionAPI {
+ /**
+ * Override ExtensionAPI with PiP override's specific API
+ * Clone constants into the Picture-in-Picture child process
+ * @param {ExtensionContext} context the context of our extension
+ * @returns returns the necessary API structure required to get data from PictureInPictureChild
+ */
+ getAPI(context) {
+ return {
+ pictureInPictureChild: {
+ getKeyboardControls() {
+ // The Picture-in-Picture toggle is only implemented for Desktop, so make
+ // this return nothing for non-Desktop builds.
+ if (AppConstants.platform == "android") {
+ return Cu.cloneInto({}, context.cloneScope);
+ }
+
+ return Cu.cloneInto(KEYBOARD_CONTROLS, context.cloneScope);
+ },
+ getPolicies() {
+ // The Picture-in-Picture toggle is only implemented for Desktop, so make
+ // this return nothing for non-Desktop builds.
+ if (AppConstants.platform == "android") {
+ return Cu.cloneInto({}, context.cloneScope);
+ }
+
+ return Cu.cloneInto(TOGGLE_POLICIES, context.cloneScope);
+ },
+ },
+ };
+ }
+};
diff --git a/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.json b/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.json
new file mode 100644
index 0000000000..5f34616b6e
--- /dev/null
+++ b/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.json
@@ -0,0 +1,51 @@
+[
+ {
+ "namespace": "pictureInPictureParent",
+ "description": "Parent process methods for controlling the Picture-in-Picture feature.",
+ "functions": [
+ {
+ "name": "setOverrides",
+ "type": "function",
+ "description": "Set Picture-in-Picture toggle position overrides",
+ "parameters": [
+ {
+ "name": "overrides",
+ "type": "object",
+ "additionalProperties": { "type": "any" },
+ "description": "The Picture-in-Picture toggle position overrides to set"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "namespace": "pictureInPictureChild",
+ "description": "WebExtension process methods for querying the Picture-in-Picture feature.",
+ "functions": [
+ {
+ "name": "getKeyboardControls",
+ "type": "function",
+ "description": "Get the Picture-in-Picture keyboard control override constants",
+ "parameters": [],
+ "returns": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": { "type": "any" },
+ "description": "The Picture-in-Picture keyboard control override constants"
+ }
+ },
+ {
+ "name": "getPolicies",
+ "type": "function",
+ "description": "Get the Picture-in-Picture toggle position override constants",
+ "parameters": [],
+ "returns": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": { "type": "any" },
+ "description": "The Picture-in-Picture toggle position override constants"
+ }
+ }
+ ]
+ }
+]
diff --git a/browser/extensions/pictureinpicture/lib/picture_in_picture_overrides.js b/browser/extensions/pictureinpicture/lib/picture_in_picture_overrides.js
new file mode 100644
index 0000000000..829f33e15b
--- /dev/null
+++ b/browser/extensions/pictureinpicture/lib/picture_in_picture_overrides.js
@@ -0,0 +1,98 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals browser, module */
+
+/**
+ * Picture-in-Picture Overrides
+ */
+class PictureInPictureOverrides {
+ /**
+ * Class constructor
+ * @param {Object} availableOverrides Contains all overrides provided in data/picture_in_picture_overrides.js
+ */
+ constructor(availableOverrides) {
+ this.pref = "enable_picture_in_picture_overrides";
+ this._prefEnabledOverrides = new Set();
+ this._availableOverrides = availableOverrides;
+ this.policies = browser.pictureInPictureChild.getPolicies();
+ }
+
+ /**
+ * Ensures the "enable_picture_in_picture_overrides" pref is set; if it is undefined, sets the pref to true
+ */
+ async _checkGlobalPref() {
+ await browser.aboutConfigPipPrefs.getPref(this.pref).then(value => {
+ if (value === false) {
+ this._enabled = false;
+ } else {
+ if (value === undefined) {
+ browser.aboutConfigPipPrefs.setPref(this.pref, true);
+ }
+ this._enabled = true;
+ }
+ });
+ }
+
+ /**
+ * Checks the status of a specified override, and updates the set, `this._prefEnabledOverrides`, accordingly
+ * @param {String} id the id of the specific override contained in `this._availableOverrides`
+ * @param {String} pref the specific preference to check, in the form `disabled_picture_in_picture_overrides.${id}`
+ */
+ async _checkSpecificOverridePref(id, pref) {
+ const isDisabled = await browser.aboutConfigPipPrefs.getPref(pref);
+ if (isDisabled === true) {
+ this._prefEnabledOverrides.delete(id);
+ } else {
+ this._prefEnabledOverrides.add(id);
+ }
+ }
+
+ /**
+ * The function that `run.js` calls to begin checking for changes to the PiP overrides
+ */
+ bootup() {
+ const checkGlobal = async () => {
+ await this._checkGlobalPref();
+ this._onAvailableOverridesChanged();
+ };
+ browser.aboutConfigPipPrefs.onPrefChange.addListener(
+ checkGlobal,
+ this.pref
+ );
+
+ const bootupPrefCheckPromises = [this._checkGlobalPref()];
+
+ for (const id of Object.keys(this._availableOverrides)) {
+ const pref = `disabled_picture_in_picture_overrides.${id}`;
+ const checkSingle = async () => {
+ await this._checkSpecificOverridePref(id, pref);
+ this._onAvailableOverridesChanged();
+ };
+ browser.aboutConfigPipPrefs.onPrefChange.addListener(checkSingle, pref);
+ bootupPrefCheckPromises.push(this._checkSpecificOverridePref(id, pref));
+ }
+
+ Promise.all(bootupPrefCheckPromises).then(() => {
+ this._onAvailableOverridesChanged();
+ });
+ }
+
+ /**
+ * Sets pictureInPictureParent's overrides
+ */
+ async _onAvailableOverridesChanged() {
+ const policies = await this.policies;
+ let enabledOverrides = {};
+ for (const [id, override] of Object.entries(this._availableOverrides)) {
+ const enabled = this._enabled && this._prefEnabledOverrides.has(id);
+ for (const [url, policy] of Object.entries(override)) {
+ enabledOverrides[url] = enabled ? policy : policies.DEFAULT;
+ }
+ }
+ browser.pictureInPictureParent.setOverrides(enabledOverrides);
+ }
+}
diff --git a/browser/extensions/pictureinpicture/manifest.json b/browser/extensions/pictureinpicture/manifest.json
new file mode 100644
index 0000000000..971815dedf
--- /dev/null
+++ b/browser/extensions/pictureinpicture/manifest.json
@@ -0,0 +1,48 @@
+{
+ "manifest_version": 2,
+ "name": "Picture-In-Picture",
+ "description": "Fixes for web compatibility with Picture-in-Picture",
+ "version": "1.0.0",
+
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "pictureinpicture@mozilla.org",
+ "strict_min_version": "88.0a1"
+ }
+ },
+
+ "experiment_apis": {
+ "aboutConfigPipPrefs": {
+ "schema": "experiment-apis/aboutConfigPipPrefs.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experiment-apis/aboutConfigPipPrefs.js",
+ "paths": [["aboutConfigPipPrefs"]]
+ }
+ },
+ "pictureInPictureChild": {
+ "schema": "experiment-apis/pictureInPicture.json",
+ "child": {
+ "scopes": ["addon_child"],
+ "script": "experiment-apis/pictureInPicture.js",
+ "paths": [["pictureInPictureChild"]]
+ }
+ },
+ "pictureInPictureParent": {
+ "schema": "experiment-apis/pictureInPicture.json",
+ "parent": {
+ "scopes": ["addon_parent"],
+ "script": "experiment-apis/pictureInPicture.js",
+ "paths": [["pictureInPictureParent"]]
+ }
+ }
+ },
+
+ "background": {
+ "scripts": [
+ "data/picture_in_picture_overrides.js",
+ "lib/picture_in_picture_overrides.js",
+ "run.js"
+ ]
+ }
+ }
diff --git a/browser/extensions/pictureinpicture/moz.build b/browser/extensions/pictureinpicture/moz.build
new file mode 100644
index 0000000000..ab4bd3a140
--- /dev/null
+++ b/browser/extensions/pictureinpicture/moz.build
@@ -0,0 +1,52 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+DEFINES["MOZ_APP_MAXVERSION"] = CONFIG["MOZ_APP_MAXVERSION"]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"] += [
+ "manifest.json",
+ "run.js",
+]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["data"] += [
+ "data/picture_in_picture_overrides.js",
+]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["experiment-apis"] += [
+ "experiment-apis/aboutConfigPipPrefs.js",
+ "experiment-apis/aboutConfigPipPrefs.json",
+ "experiment-apis/pictureInPicture.js",
+ "experiment-apis/pictureInPicture.json",
+]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["lib"] += [
+ "lib/picture_in_picture_overrides.js",
+]
+
+FINAL_TARGET_FILES.features["pictureinpicture@mozilla.org"]["video-wrappers"] += [
+ "video-wrappers/airmozilla.js",
+ "video-wrappers/bbc.js",
+ "video-wrappers/dailymotion.js",
+ "video-wrappers/disneyplus.js",
+ "video-wrappers/hbomax.js",
+ "video-wrappers/hotstar.js",
+ "video-wrappers/hulu.js",
+ "video-wrappers/mock-wrapper.js",
+ "video-wrappers/netflix.js",
+ "video-wrappers/piped.js",
+ "video-wrappers/primeVideo.js",
+ "video-wrappers/tubi.js",
+ "video-wrappers/videojsWrapper.js",
+ "video-wrappers/voot.js",
+ "video-wrappers/washingtonpost.js",
+ "video-wrappers/youtube.js",
+]
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Toolkit", "Picture-in-Picture")
diff --git a/browser/extensions/pictureinpicture/run.js b/browser/extensions/pictureinpicture/run.js
new file mode 100644
index 0000000000..cb2982f83f
--- /dev/null
+++ b/browser/extensions/pictureinpicture/run.js
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* globals AVAILABLE_PIP_OVERRIDES, PictureInPictureOverrides */
+const pipOverrides = new PictureInPictureOverrides(AVAILABLE_PIP_OVERRIDES);
+pipOverrides.bootup();
diff --git a/browser/extensions/pictureinpicture/tests/browser/.eslintrc.js b/browser/extensions/pictureinpicture/tests/browser/.eslintrc.js
new file mode 100644
index 0000000000..9bf153d21a
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/.eslintrc.js
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ globals: {
+ ensureVideosReady: "readonly",
+ triggerPictureInPicture: "readonly",
+ isVideoMuted: "readonly",
+ isVideoPaused: "readonly",
+ },
+};
diff --git a/browser/extensions/pictureinpicture/tests/browser/browser.ini b/browser/extensions/pictureinpicture/tests/browser/browser.ini
new file mode 100644
index 0000000000..3194c87ca5
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/browser.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+support-files =
+ test-mock-wrapper.html
+ test-mock-wrapper.js
+ test-toggle-visibility.html
+ ../../../../../toolkit/components/pictureinpicture/tests/click-event-helper.js
+ ../../../../../toolkit/components/pictureinpicture/tests/head.js
+ ../../../../../toolkit/components/pictureinpicture/tests/test-video.mp4
+
+prefs =
+ media.videocontrols.picture-in-picture.enabled=true
+ media.videocontrols.picture-in-picture.video-toggle.enabled=true
+ media.videocontrols.picture-in-picture.video-toggle.testing=true
+ media.videocontrols.picture-in-picture.video-toggle.always-show=true
+ media.videocontrols.picture-in-picture.video-toggle.has-used=true
+ media.videocontrols.picture-in-picture.video-toggle.position="right"
+
+[browser_mock_wrapper.js]
+skip-if = !nightly_build # Bug 1751793
diff --git a/browser/extensions/pictureinpicture/tests/browser/browser_mock_wrapper.js b/browser/extensions/pictureinpicture/tests/browser/browser_mock_wrapper.js
new file mode 100644
index 0000000000..8bb0ae46f3
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/browser_mock_wrapper.js
@@ -0,0 +1,207 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from ../../../../../toolkit/components/pictureinpicture/tests/head.js */
+
+ChromeUtils.defineESModuleGetters(this, {
+ TOGGLE_POLICIES: "resource://gre/modules/PictureInPictureControls.sys.mjs",
+});
+
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://mochitest.youtube.com:443"
+ ) + "test-mock-wrapper.html";
+const TEST_URL_TOGGLE_VISIBILITY =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://mochitest.youtube.com:443"
+ ) + "test-toggle-visibility.html";
+
+/**
+ * Tests the mock-wrapper.js video wrapper script selects the expected element
+ * responsible for toggling the video player's mute status.
+ */
+add_task(async function test_mock_mute_button() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
+ await ensureVideosReady(browser);
+
+ // Open the video in PiP
+ let videoID = "mock-video-controls";
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Mute audio
+ await toggleMute(browser, pipWin);
+ ok(await isVideoMuted(browser, videoID), "The audio is muted.");
+
+ await SpecialPowers.spawn(browser, [], async function() {
+ let muteButton = content.document.querySelector(".mute-button");
+ ok(
+ muteButton.getAttribute("isMuted"),
+ "muteButton has isMuted attribute."
+ );
+ });
+
+ // Unmute audio
+ await toggleMute(browser, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is playing.");
+
+ await SpecialPowers.spawn(browser, [], async function() {
+ let muteButton = content.document.querySelector(".mute-button");
+ ok(
+ !muteButton.getAttribute("isMuted"),
+ "muteButton does not have isMuted attribute"
+ );
+ });
+
+ // Close PiP window
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ });
+});
+
+/**
+ * Tests the mock-wrapper.js video wrapper script selects the expected element
+ * responsible for toggling the video player's play/pause status.
+ */
+add_task(async function test_mock_play_pause_button() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
+ await ensureVideosReady(browser);
+ await setupVideoListeners(browser);
+
+ // Open the video in PiP
+ let videoID = "mock-video-controls";
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ info("Test a wrapper method with a correct selector");
+ // Play video
+ let playbackPromise = waitForVideoEvent(browser, "playing");
+ let playPause = pipWin.document.getElementById("playpause");
+ EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin);
+ await playbackPromise;
+ ok(!(await isVideoPaused(browser, videoID)), "The video is playing.");
+
+ info("Test a wrapper method with an incorrect selector");
+ // Pause the video.
+ let pausePromise = waitForVideoEvent(browser, "pause");
+ EventUtils.synthesizeMouseAtCenter(playPause, {}, pipWin);
+ await pausePromise;
+ ok(await isVideoPaused(browser, videoID), "The video is paused.");
+
+ // Close PiP window
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ });
+});
+
+/**
+ * Tests the mock-wrapper.js video wrapper script does not toggle mute/umute
+ * state when increasing/decreasing the volume using the arrow keys.
+ */
+add_task(async function test_volume_change_with_keyboard() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
+ await ensureVideosReady(browser);
+ await setupVideoListeners(browser);
+
+ // Open the video in PiP
+ let videoID = "mock-video-controls";
+ let pipWin = await triggerPictureInPicture(browser, videoID);
+ ok(pipWin, "Got Picture-in-Picture window.");
+
+ // Initially set video to be muted
+ await toggleMute(browser, pipWin);
+ ok(await isVideoMuted(browser, videoID), "The audio is not playing.");
+
+ // Decrease volume with arrow down
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is playing.");
+
+ // Increase volume with arrow up
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, pipWin);
+ ok(!(await isVideoMuted(browser, videoID)), "The audio is still playing.");
+
+ await SpecialPowers.spawn(browser, [], async function() {
+ let video = content.document.querySelector("video");
+ ok(!video.muted, "Video should be unmuted.");
+ });
+
+ // Close PiP window
+ let pipClosed = BrowserTestUtils.domWindowClosed(pipWin);
+ let closeButton = pipWin.document.getElementById("close");
+ EventUtils.synthesizeMouseAtCenter(closeButton, {}, pipWin);
+ await pipClosed;
+ });
+});
+
+function waitForVideoEvent(browser, eventType) {
+ return BrowserTestUtils.waitForContentEvent(browser, eventType, true);
+}
+
+async function toggleMute(browser, pipWin) {
+ let mutedPromise = waitForVideoEvent(browser, "volumechange");
+ let audioButton = pipWin.document.getElementById("audio");
+ EventUtils.synthesizeMouseAtCenter(audioButton, {}, pipWin);
+ await mutedPromise;
+}
+
+async function setupVideoListeners(browser) {
+ await SpecialPowers.spawn(browser, [], async function() {
+ let video = content.document.querySelector("video");
+
+ // Set a listener for "playing" event
+ video.addEventListener("playing", async () => {
+ info("Got playing event!");
+ let playPauseButton = content.document.querySelector(
+ ".play-pause-button"
+ );
+ ok(
+ !playPauseButton.getAttribute("isPaused"),
+ "playPauseButton does not have isPaused attribute."
+ );
+ });
+
+ // Set a listener for "pause" event
+ video.addEventListener("pause", async () => {
+ info("Got pause event!");
+ let playPauseButton = content.document.querySelector(
+ ".play-pause-button"
+ );
+ // mock-wrapper's pause() method uses an invalid selector and should throw
+ // an error. Test that the PiP wrapper uses the fallback pause() method.
+ // This is to ensure PiP can handle cases where a site wrapper script is
+ // incorrect, but does not break functionality altogether.
+ ok(
+ !playPauseButton.getAttribute("isPaused"),
+ "playPauseButton still doesn't have isPaused attribute."
+ );
+ });
+ });
+}
+
+/**
+ * Tests that the mock-wrapper.js video wrapper hides the pip toggle when shouldHideToggle()
+ * returns true.
+ */
+add_task(async function test_mock_should_hide_toggle() {
+ await testToggle(TEST_URL_TOGGLE_VISIBILITY, {
+ "mock-video-controls": { canToggle: false, policy: TOGGLE_POLICIES.HIDDEN },
+ });
+});
+
+/**
+ * Tests that the mock-wrapper.js video wrapper does not hide the pip toggle when shouldHideToggle()
+ * returns false.
+ */
+add_task(async function test_mock_should_not_hide_toggle() {
+ await testToggle(TEST_URL, {
+ "mock-video-controls": { canToggle: true },
+ });
+});
diff --git a/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html b/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html
new file mode 100644
index 0000000000..f53e7a1b28
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Mock Wrapper Test</title>
+ <script type="text/javascript" src="test-mock-wrapper.js"></script>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <div id="player">
+ <video id="mock-video-controls" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <button class="play-pause-button" onclick="playPause()">play/pause button</button>
+ <button class="mute-button" onclick="toggleMute()">mute/unmute button</button>
+ </div>
+</body>
+</html>
diff --git a/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.js b/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.js
new file mode 100644
index 0000000000..2119122428
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/test-mock-wrapper.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+function toggleMute() {
+ let video = document.getElementById("mock-video-controls");
+ let muteButton = document.querySelector(".mute-button");
+ let isMuted = video.muted;
+ video.muted = !isMuted;
+
+ if (video.muted) {
+ muteButton.setAttribute("isMuted", true);
+ } else {
+ muteButton.removeAttribute("isMuted");
+ }
+}
+
+function playPause() {
+ let video = document.getElementById("mock-video-controls");
+ let playPauseButton = document.querySelector(".play-pause-button");
+
+ if (video.paused) {
+ video.play();
+ playPauseButton.removeAttribute("isPaused");
+ } else {
+ video.setAttribute("isPaused", true);
+ video.pause();
+ }
+}
diff --git a/browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html b/browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html
new file mode 100644
index 0000000000..622ea6c568
--- /dev/null
+++ b/browser/extensions/pictureinpicture/tests/browser/test-toggle-visibility.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Mock Wrapper Test</title>
+ <script type="text/javascript" src="test-mock-wrapper.js"></script>
+ <script type="text/javascript" src="click-event-helper.js"></script>
+</head>
+<style>
+ video {
+ display: block;
+ border: 1px solid black;
+ }
+</style>
+<body>
+ <div id="player">
+ <video id="mock-video-controls" class="mock-preview-video" src="test-video.mp4" controls loop="true" width="400" height="225"></video>
+ <button class="play-pause-button" onclick="playPause()">play/pause button</button>
+ <button class="mute-button" onclick="toggleMute()">mute/unmute button</button>
+ </div>
+</body>
+</html>
diff --git a/browser/extensions/pictureinpicture/video-wrappers/airmozilla.js b/browser/extensions/pictureinpicture/video-wrappers/airmozilla.js
new file mode 100644
index 0000000000..db4f97a829
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/airmozilla.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector("#absoluteControls");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let text = container?.querySelector("#overlayCaption").innerText;
+
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/bbc.js b/browser/extensions/pictureinpicture/video-wrappers/bbc.js
new file mode 100644
index 0000000000..9bbb551389
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/bbc.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".p_subtitlesContainer");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let text = container.querySelector(".p_cueDirUniWrapper")?.innerText;
+ updateCaptionsFunction(text);
+ };
+
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/dailymotion.js b/browser/extensions/pictureinpicture/video-wrappers/dailymotion.js
new file mode 100644
index 0000000000..3cdec6399d
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/dailymotion.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".dmp_VideoView");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let textNodeList = container
+ ?.querySelector(".dmp_SubtitlesView")
+ ?.querySelectorAll("div");
+
+ if (!textNodeList) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.innerText).join("\n")
+ );
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/disneyplus.js b/browser/extensions/pictureinpicture/video-wrappers/disneyplus.js
new file mode 100644
index 0000000000..816dc0d5e0
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/disneyplus.js
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".dss-hls-subtitle-overlay");
+
+ if (container) {
+ const callback = () => {
+ let textNodeList = container.querySelectorAll(
+ ".dss-subtitle-renderer-line"
+ );
+
+ if (!textNodeList.length) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.textContent).join("\n")
+ );
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback();
+
+ let captionsObserver = new MutationObserver(callback);
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/hbomax.js b/browser/extensions/pictureinpicture/video-wrappers/hbomax.js
new file mode 100644
index 0000000000..36110d26b5
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/hbomax.js
@@ -0,0 +1,46 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setVolume(video, volume) {
+ video.volume = volume;
+ }
+ isMuted(video) {
+ return video.volume === 0;
+ }
+ setMuted(video, shouldMute) {
+ if (shouldMute) {
+ this.setVolume(video, 0);
+ } else {
+ this.setVolume(video, 1);
+ }
+ }
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector('[data-testid="CueBoxContainer"]')
+ .parentElement;
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let text = container.querySelector('[data-testid="CueBoxContainer"]')
+ ?.innerText;
+ updateCaptionsFunction(text);
+ };
+
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/hotstar.js b/browser/extensions/pictureinpicture/video-wrappers/hotstar.js
new file mode 100644
index 0000000000..acb6c8228e
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/hotstar.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".subtitle-container");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let textNodeList = container
+ .querySelector(".shaka-text-container")
+ ?.querySelectorAll("span");
+ if (!textNodeList) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.textContent).join("\n")
+ );
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/hulu.js b/browser/extensions/pictureinpicture/video-wrappers/hulu.js
new file mode 100644
index 0000000000..bc78cac232
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/hulu.js
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ constructor(video) {
+ this.player = video.wrappedJSObject.__HuluDashPlayer__;
+ }
+ play() {
+ this.player.play();
+ }
+ pause() {
+ this.player.pause();
+ }
+ isMuted(video) {
+ return video.volume === 0;
+ }
+ setMuted(video, shouldMute) {
+ let muteButton = document.querySelector(".VolumeControl > div");
+
+ if (this.isMuted(video) !== shouldMute) {
+ muteButton.click();
+ }
+ }
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".ClosedCaption");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let text = container.querySelector(".CaptionBox").innerText;
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/mock-wrapper.js b/browser/extensions/pictureinpicture/video-wrappers/mock-wrapper.js
new file mode 100644
index 0000000000..b68ce3fa9b
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/mock-wrapper.js
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ play(video) {
+ let playPauseButton = document.querySelector("#player .play-pause-button");
+ playPauseButton.click();
+ }
+
+ pause(video) {
+ let invalidSelector = "#player .pause-button";
+ let playPauseButton = document.querySelector(invalidSelector);
+ playPauseButton.click();
+ }
+
+ setMuted(video, shouldMute) {
+ let muteButton = document.querySelector("#player .mute-button");
+ if (video.muted !== shouldMute && muteButton) {
+ muteButton.click();
+ } else {
+ video.muted = shouldMute;
+ }
+ }
+
+ shouldHideToggle() {
+ let video = document.getElementById("mock-video-controls");
+ return !!video.classList.contains("mock-preview-video");
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/netflix.js b/browser/extensions/pictureinpicture/video-wrappers/netflix.js
new file mode 100644
index 0000000000..bc3d14949d
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/netflix.js
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ constructor() {
+ let netflixPlayerAPI = window.wrappedJSObject.netflix.appContext.state.playerApp.getAPI()
+ .videoPlayer;
+ let sessionId = null;
+ for (let id of netflixPlayerAPI.getAllPlayerSessionIds()) {
+ if (id.startsWith("watch-")) {
+ sessionId = id;
+ break;
+ }
+ }
+ this.player = netflixPlayerAPI.getVideoPlayerBySessionId(sessionId);
+ }
+ /**
+ * The Netflix player returns the current time in milliseconds so we convert
+ * to seconds before returning.
+ * @param {HTMLVideoElement} video The original video element
+ * @returns {Number} The current time in seconds
+ */
+ getCurrentTime(video) {
+ return this.player.getCurrentTime() / 1000;
+ }
+ /**
+ * The Netflix player returns the duration in milliseconds so we convert to
+ * seconds before returning.
+ * @param {HTMLVideoElement} video The original video element
+ * @returns {Number} The duration in seconds
+ */
+ getDuration(video) {
+ return this.player.getDuration() / 1000;
+ }
+ play() {
+ this.player.play();
+ }
+ pause() {
+ this.player.pause();
+ }
+
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".watch-video");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let text = container.querySelector(".player-timedtext").innerText;
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+
+ /**
+ * Set the current time of the video in milliseconds.
+ * @param {HTMLVideoElement} video The original video element
+ * @param {Number} position The new time in seconds
+ */
+ setCurrentTime(video, position) {
+ this.player.seek(position * 1000);
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/piped.js b/browser/extensions/pictureinpicture/video-wrappers/piped.js
new file mode 100644
index 0000000000..ab5c2bc603
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/piped.js
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".player-container");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let textNodeList = container
+ .querySelector(".shaka-text-wrapper")
+ ?.querySelectorAll('span[style="background-color: black;"]');
+ if (!textNodeList) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.textContent).join("\n")
+ );
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/primeVideo.js b/browser/extensions/pictureinpicture/video-wrappers/primeVideo.js
new file mode 100644
index 0000000000..db3c0e5a3b
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/primeVideo.js
@@ -0,0 +1,101 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ /**
+ * Playing the video when the readyState is HAVE_METADATA (1) can cause play
+ * to fail but it will load the video and trying to play again allows enough
+ * time for the second play to successfully play the video.
+ * @param {HTMLVideoElement} video
+ * The original video element
+ */
+ play(video) {
+ video.play().catch(() => {
+ video.play();
+ });
+ }
+ /**
+ * Seeking large amounts of time can cause the video readyState to
+ * HAVE_METADATA (1) and it will throw an error when trying to play the video.
+ * To combat this, after seeking we check if the readyState changed and if so,
+ * we will play to video to "load" the video at the new time and then play or
+ * pause the video depending on if the video was playing before we seeked.
+ * @param {HTMLVideoElement} video
+ * The original video element
+ * @param {Number} position
+ * The new time to set the video to
+ * @param {Boolean} wasPlaying
+ * True if the video was playing before seeking else false
+ */
+ setCurrentTime(video, position, wasPlaying) {
+ if (wasPlaying === undefined) {
+ this.wasPlaying = !video.paused;
+ }
+ video.currentTime = position;
+ if (video.readyState < video.HAVE_CURRENT_DATA) {
+ video
+ .play()
+ .then(() => {
+ if (!wasPlaying) {
+ video.pause();
+ }
+ })
+ .catch(() => {
+ if (wasPlaying) {
+ this.play(video);
+ }
+ });
+ }
+ }
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document?.querySelector("#dv-web-player");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ // eslint-disable-next-line no-unused-vars
+ for (const mutation of mutationsList) {
+ let text;
+ // windows, mac
+ if (container?.querySelector(".atvwebplayersdk-player-container")) {
+ text = container
+ ?.querySelector(".f35bt6a")
+ ?.querySelector(".atvwebplayersdk-captions-text")?.innerText;
+ } else {
+ // linux
+ text = container
+ ?.querySelector(".persistentPanel")
+ ?.querySelector("span")?.innerText;
+ }
+
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ }
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+
+ shouldHideToggle(video) {
+ return !!video.classList.contains("tst-video-overlay-player-html5");
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/tubi.js b/browser/extensions/pictureinpicture/video-wrappers/tubi.js
new file mode 100644
index 0000000000..7271381a04
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/tubi.js
@@ -0,0 +1,35 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(`[data-id="hls"]`);
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let text = container?.querySelector(
+ `[data-id="captionsComponent"]:not([style="display: none;"])`
+ )?.innerText;
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/videojsWrapper.js b/browser/extensions/pictureinpicture/video-wrappers/videojsWrapper.js
new file mode 100644
index 0000000000..f767285eed
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/videojsWrapper.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// This wrapper supports multiple sites that use video.js player
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".vjs-text-track-display");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let text = container.querySelector("div").innerText;
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/voot.js b/browser/extensions/pictureinpicture/video-wrappers/voot.js
new file mode 100644
index 0000000000..d007794d43
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/voot.js
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".playkit-container");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let text = container.querySelector(".playkit-subtitles").innerText;
+ if (!text) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(text);
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/washingtonpost.js b/browser/extensions/pictureinpicture/video-wrappers/washingtonpost.js
new file mode 100644
index 0000000000..30bd28922b
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/washingtonpost.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.querySelector(".powa");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ let subtitleElement = container.querySelector(".powa-sub-torpedo");
+ if (!subtitleElement?.innerText) {
+ updateCaptionsFunction("");
+ return;
+ }
+ let subtitleElementClone = subtitleElement.cloneNode(true);
+ let breaks = subtitleElementClone.getElementsByTagName("br");
+ for (const element of breaks) {
+ element.replaceWith("\n");
+ }
+ let text = subtitleElementClone.innerText;
+
+ updateCaptionsFunction(text);
+ };
+
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;
diff --git a/browser/extensions/pictureinpicture/video-wrappers/youtube.js b/browser/extensions/pictureinpicture/video-wrappers/youtube.js
new file mode 100644
index 0000000000..cfd47762ec
--- /dev/null
+++ b/browser/extensions/pictureinpicture/video-wrappers/youtube.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+class PictureInPictureVideoWrapper {
+ setMuted(video, shouldMute) {
+ let muteButton = document.querySelector("#player .ytp-mute-button");
+
+ if (video.muted !== shouldMute && muteButton) {
+ muteButton.click();
+ } else {
+ video.muted = shouldMute;
+ }
+ }
+ setCaptionContainerObserver(video, updateCaptionsFunction) {
+ let container = document.getElementById("ytp-caption-window-container");
+
+ if (container) {
+ updateCaptionsFunction("");
+ const callback = function(mutationsList, observer) {
+ // eslint-disable-next-line no-unused-vars
+ for (const mutation of mutationsList) {
+ let textNodeList = container
+ .querySelector(".captions-text")
+ ?.querySelectorAll(".caption-visual-line");
+ if (!textNodeList) {
+ updateCaptionsFunction("");
+ return;
+ }
+
+ updateCaptionsFunction(
+ Array.from(textNodeList, x => x.textContent).join("\n")
+ );
+ }
+ };
+
+ // immediately invoke the callback function to add subtitles to the PiP window
+ callback([1], null);
+
+ let captionsObserver = new MutationObserver(callback);
+
+ captionsObserver.observe(container, {
+ attributes: false,
+ childList: true,
+ subtree: true,
+ });
+ }
+ }
+ shouldHideToggle(video) {
+ return !!video.closest(".ytd-video-preview");
+ }
+}
+
+this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper;