diff options
Diffstat (limited to '')
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; |