diff options
Diffstat (limited to 'browser/extensions/pictureinpicture')
41 files changed, 2359 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..ecc4945135 --- /dev/null +++ b/browser/extensions/pictureinpicture/data/picture_in_picture_overrides.js @@ -0,0 +1,316 @@ +/* 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(); + const KEYBOARD_CONTROLS = browser.pictureInPictureChild.getKeyboardControls(); + + 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, + // disabledKeyboardControls: KEYBOARD_CONTROLS.PLAY_PAUSE | KEYBOARD_CONTROLS.VOLUME, + // }, + // "https://*.twitch.tv/mikeconley_dot_ca/*": { + // policy: TOGGLE_POLICIES.TOP, + // disabledKeyboardControls: KEYBOARD_CONTROLS.ALL, + // }, + + 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", + }, + }, + + aol: { + "https://*.aol.com/*": { + videoWrapperScriptPath: "video-wrappers/yahoo.js", + }, + }, + + arte: { + "https://*.arte.tv/*": { + videoWrapperScriptPath: "video-wrappers/arte.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", + }, + }, + cbc: { + "https://*.cbc.ca/*": { + videoWrapperScriptPath: "video-wrappers/cbc.js", + }, + }, + + dailymotion: { + "https://*.dailymotion.com/*": { + videoWrapperScriptPath: "video-wrappers/dailymotion.js", + }, + }, + + disneyplus: { + "https://*.disneyplus.com/*": { + videoWrapperScriptPath: "video-wrappers/disneyplus.js", + }, + }, + + edx: { + "https://*.edx.org/*": { + videoWrapperScriptPath: "video-wrappers/edx.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 }, + }, + + msn: { + "https://*.msn.com/*": { + visibilityThreshold: 0.7, + }, + }, + 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 }, + }, + + nytimes: { + "https://*.nytimes.com/*": { + videoWrapperScriptPath: "video-wrappers/nytimes.js", + }, + }, + + 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", + }, + }, + + radiocanada: { + "https://*.ici.radio-canada.ca/*": { + videoWrapperScriptPath: "video-wrappers/radiocanada.js", + }, + }, + + reddit: { + "https://*.reddit.com/*": { policy: TOGGLE_POLICIES.ONE_QUARTER }, + }, + + sonyliv: { + "https://*.sonyliv.com/*": { + videoWrapperScriptPath: "video-wrappers/sonyliv.js", + }, + }, + + ted: { + "https://*.ted.com/*": { + showHiddenTextTracks: true, + }, + }, + + tubi: { + "https://*.tubitv.com/live*": { + videoWrapperScriptPath: "video-wrappers/tubilive.js", + }, + "https://*.tubitv.com/movies*": { + videoWrapperScriptPath: "video-wrappers/tubi.js", + }, + "https://*.tubitv.com/tv-shows*": { + videoWrapperScriptPath: "video-wrappers/tubi.js", + }, + }, + + twitch: { + "https://*.twitch.tv/*": { + videoWrapperScriptPath: "video-wrappers/twitch.js", + policy: TOGGLE_POLICIES.ONE_QUARTER, + disabledKeyboardControls: KEYBOARD_CONTROLS.LIVE_SEEK, + }, + "https://*.twitch.tech/*": { + videoWrapperScriptPath: "video-wrappers/twitch.js", + policy: TOGGLE_POLICIES.ONE_QUARTER, + disabledKeyboardControls: KEYBOARD_CONTROLS.LIVE_SEEK, + }, + "https://*.twitch.a2z.com/*": { + videoWrapperScriptPath: "video-wrappers/twitch.js", + policy: TOGGLE_POLICIES.ONE_QUARTER, + disabledKeyboardControls: KEYBOARD_CONTROLS.LIVE_SEEK, + }, + }, + + udemy: { + "https://*.udemy.com/*": { + videoWrapperScriptPath: "video-wrappers/udemy.js", + policy: TOGGLE_POLICIES.ONE_QUARTER, + }, + }, + + viki: { + "https://*.viki.com/*": { + videoWrapperScriptPath: "video-wrappers/videojsWrapper.js", + }, + }, + + voot: { + "https://*.voot.com/*": { + videoWrapperScriptPath: "video-wrappers/voot.js", + }, + }, + + wired: { + "https://*.wired.com/*": { + videoWrapperScriptPath: "video-wrappers/videojsWrapper.js", + }, + }, + + yahoofinance: { + "https://*.finance.yahoo.com/*": { + videoWrapperScriptPath: "video-wrappers/yahoo.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..4660affd8f --- /dev/null +++ b/browser/extensions/pictureinpicture/experiment-apis/aboutConfigPipPrefs.js @@ -0,0 +1,68 @@ +/* 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 {boolean|undefined} 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 {boolean} 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..9a2a3e14b8 --- /dev/null +++ b/browser/extensions/pictureinpicture/experiment-apis/pictureInPicture.js @@ -0,0 +1,86 @@ +/* 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 {object} 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..9328ce240b --- /dev/null +++ b/browser/extensions/pictureinpicture/lib/picture_in_picture_overrides.js @@ -0,0 +1,100 @@ +/* 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..c193c446be --- /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..7cc77f9594 --- /dev/null +++ b/browser/extensions/pictureinpicture/moz.build @@ -0,0 +1,62 @@ +# -*- 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/arte.js",
+ "video-wrappers/bbc.js",
+ "video-wrappers/cbc.js",
+ "video-wrappers/dailymotion.js",
+ "video-wrappers/disneyplus.js",
+ "video-wrappers/edx.js",
+ "video-wrappers/hbomax.js",
+ "video-wrappers/hotstar.js",
+ "video-wrappers/hulu.js",
+ "video-wrappers/mock-wrapper.js",
+ "video-wrappers/netflix.js",
+ "video-wrappers/nytimes.js",
+ "video-wrappers/piped.js",
+ "video-wrappers/primeVideo.js",
+ "video-wrappers/radiocanada.js",
+ "video-wrappers/sonyliv.js",
+ "video-wrappers/tubi.js",
+ "video-wrappers/tubilive.js",
+ "video-wrappers/twitch.js",
+ "video-wrappers/udemy.js",
+ "video-wrappers/videojsWrapper.js",
+ "video-wrappers/voot.js",
+ "video-wrappers/washingtonpost.js",
+ "video-wrappers/yahoo.js",
+ "video-wrappers/youtube.js",
+]
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"]
+
+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.toml b/browser/extensions/pictureinpicture/tests/browser/browser.toml new file mode 100644 index 0000000000..94bb7c5cbc --- /dev/null +++ b/browser/extensions/pictureinpicture/tests/browser/browser.toml @@ -0,0 +1,21 @@ +[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..33a7ecc601 --- /dev/null +++ b/browser/extensions/pictureinpicture/tests/browser/browser_mock_wrapper.js @@ -0,0 +1,205 @@ +/* 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 not 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..d2e98cbe48 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/airmozilla.js @@ -0,0 +1,63 @@ +/* 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( + "#transportControls #playButton" + ); + if (video.paused) { + playPauseButton?.click(); + } + } + + pause(video) { + let playPauseButton = document.querySelector( + "#transportControls #playButton" + ); + if (!video.paused) { + playPauseButton?.click(); + } + } + + setMuted(video, shouldMute) { + let muteButton = document.querySelector("#transportControls #muteButton"); + if (video.muted !== shouldMute && muteButton) { + muteButton.click(); + } + } + + 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/arte.js b/browser/extensions/pictureinpicture/video-wrappers/arte.js new file mode 100644 index 0000000000..3d1df1f65f --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/arte.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(".avp-captions"); + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let textNodeList = container.querySelectorAll(".avp-captions-line"); + if (!textNodeList.length) { + updateCaptionsFunction(""); + return; + } + updateCaptionsFunction( + Array.from(textNodeList, x => x.textContent).join("\n") + ); + }; + 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..a5adcbc534 --- /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/cbc.js b/browser/extensions/pictureinpicture/video-wrappers/cbc.js new file mode 100644 index 0000000000..595d23594b --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/cbc.js @@ -0,0 +1,30 @@ +/* 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(".video-ui .play-button"); + if (video.paused) { + playPauseButton?.click(); + } + } + + pause(video) { + let playPauseButton = document.querySelector(".video-ui .pause-button"); + if (!video.paused) { + playPauseButton?.click(); + } + } + + setMuted(video, shouldMute) { + let muteButton = document.querySelector(".video-ui .muted-btn"); + if (video.muted !== shouldMute && muteButton) { + muteButton.click(); + } + } +} + +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..161f1ec516 --- /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("#player"); + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let textNodeList = container + ?.querySelector(".subtitles") + ?.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..bb5c55e0cb --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/disneyplus.js @@ -0,0 +1,70 @@ +/* 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) { + // Handle Disney+ (US) + 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, + }); + return; + } + + // Handle Disney+ (non US version) + container = document.querySelector(".shaka-text-container"); + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let textNodeList = 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/edx.js b/browser/extensions/pictureinpicture/video-wrappers/edx.js new file mode 100644 index 0000000000..07a3d9f302 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/edx.js @@ -0,0 +1,33 @@ +/* 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(".video-wrapper"); + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let text = container.querySelector( + ".closed-captions.is-visible" + )?.innerText; + updateCaptionsFunction(text); + }; + + 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/hbomax.js b/browser/extensions/pictureinpicture/video-wrappers/hbomax.js new file mode 100644 index 0000000000..8aff3e0077 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/hbomax.js @@ -0,0 +1,48 @@ +/* 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..c6b45b1a0a --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/hotstar.js @@ -0,0 +1,39 @@ +/* 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(".shaka-text-container"); + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let textNodeList = 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..fdaf6d7c18 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/hulu.js @@ -0,0 +1,71 @@ +/* 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(); + } + } + setCurrentTime(video, position) { + this.player.currentTime = position; + } + setCaptionContainerObserver(video, updateCaptionsFunction) { + let container = document.querySelector(".ClosedCaption"); + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + // This will get the subtitles for both live and regular playback videos + // and combine them to display. liveVideoText should be an empty string + // when the video is regular playback and vice versa. If both + // liveVideoText and regularVideoText are non empty strings, which + // doesn't seem to be the case, they will both show. + let liveVideoText = Array.from( + container.querySelectorAll( + "#inband-closed-caption > div > div > div" + ), + x => x.textContent.trim() + ) + .filter(String) + .join("\n"); + let regularVideoText = container.querySelector(".CaptionBox").innerText; + + updateCaptionsFunction(liveVideoText + regularVideoText); + }; + + // 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, + }); + } + } + getDuration(video) { + return this.player.duration; + } +} + +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..e33fe23a24 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/netflix.js @@ -0,0 +1,94 @@ +/* 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); + } + setVolume(video, volume) { + this.player.setVolume(volume); + } + getVolume() { + return this.player.getVolume(); + } + setMuted(video, shouldMute) { + this.player.setMuted(shouldMute); + } + isMuted() { + return this.player.isMuted(); + } +} + +this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper; diff --git a/browser/extensions/pictureinpicture/video-wrappers/nytimes.js b/browser/extensions/pictureinpicture/video-wrappers/nytimes.js new file mode 100644 index 0000000000..4f6d8cbe44 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/nytimes.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(".react-vhs-player"); + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let text = container.querySelector(".cueWrap-2P4Ue4VQ")?.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/piped.js b/browser/extensions/pictureinpicture/video-wrappers/piped.js new file mode 100644 index 0000000000..1cc1c32eb2 --- /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..28a2bd1575 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/primeVideo.js @@ -0,0 +1,103 @@ +/* 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/radiocanada.js b/browser/extensions/pictureinpicture/video-wrappers/radiocanada.js new file mode 100644 index 0000000000..1a55377493 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/radiocanada.js @@ -0,0 +1,36 @@ +/* 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( + ".rcplayer-btn.rcplayer-smallPlayPauseBtn" + ); + if (video.paused) { + playPauseButton.click(); + } + } + + pause(video) { + let playPauseButton = document.querySelector( + ".rcplayer-btn.rcplayer-smallPlayPauseBtn" + ); + if (!video.paused) { + playPauseButton.click(); + } + } + + setMuted(video, shouldMute) { + let muteButton = document.querySelector( + ".rcplayer-bouton-with-panel--volume > button" + ); + if (video.muted !== shouldMute && muteButton) { + muteButton.click(); + } + } +} + +this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper; diff --git a/browser/extensions/pictureinpicture/video-wrappers/sonyliv.js b/browser/extensions/pictureinpicture/video-wrappers/sonyliv.js new file mode 100644 index 0000000000..b703aaec2c --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/sonyliv.js @@ -0,0 +1,39 @@ +/* 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-ui-main-wrapper"); + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let text = container.querySelector( + `.text-track-wrapper:not([style*="display: none"])` + )?.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/tubi.js b/browser/extensions/pictureinpicture/video-wrappers/tubi.js new file mode 100644 index 0000000000..291dbfddeb --- /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/tubilive.js b/browser/extensions/pictureinpicture/video-wrappers/tubilive.js new file mode 100644 index 0000000000..0de748e717 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/tubilive.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 = video.parentElement; + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let text = + container.querySelector(`.tubi-text-track-container`)?.innerText || + container.querySelector(`.subtitleWindow`)?.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/twitch.js b/browser/extensions/pictureinpicture/video-wrappers/twitch.js new file mode 100644 index 0000000000..1dd7567c24 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/twitch.js @@ -0,0 +1,19 @@ +/* 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 { + isLive(video) { + return !document.querySelector(".seekbar-bar"); + } + getDuration(video) { + if (this.isLive(video)) { + return Infinity; + } + return video.duration; + } +} + +this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper; diff --git a/browser/extensions/pictureinpicture/video-wrappers/udemy.js b/browser/extensions/pictureinpicture/video-wrappers/udemy.js new file mode 100644 index 0000000000..09e3b989dc --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/udemy.js @@ -0,0 +1,66 @@ +/* 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( + `[data-purpose="play-button"]` + ); + if (video.paused) { + playPauseButton?.click(); + } + } + + pause(video) { + let playPauseButton = document.querySelector( + `[data-purpose="pause-button"]` + ); + if (!video.paused) { + playPauseButton?.click(); + } + } + + setMuted(video, shouldMute) { + let muteButton = document.querySelector( + `[data-purpose="volume-control-button"]` + ); + if (video.muted !== shouldMute && muteButton) { + muteButton.click(); + } + } + + setCaptionContainerObserver(video, updateCaptionsFunction) { + let container = video.parentElement; + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let text = container.querySelector( + `[data-purpose="captions-cue-text"]` + )?.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, + }); + } + } +} + +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..ca3145af4a --- /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..57d903a2e8 --- /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..6d0e57c96a --- /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/yahoo.js b/browser/extensions/pictureinpicture/video-wrappers/yahoo.js new file mode 100644 index 0000000000..b7d8d3160f --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/yahoo.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(".vp-main"); + + if (container) { + updateCaptionsFunction(""); + const callback = function (mutationsList, observer) { + let text = container.querySelector(".vp-cc-element.vp-show")?.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/youtube.js b/browser/extensions/pictureinpicture/video-wrappers/youtube.js new file mode 100644 index 0000000000..8b39e469f9 --- /dev/null +++ b/browser/extensions/pictureinpicture/video-wrappers/youtube.js @@ -0,0 +1,89 @@ +/* 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) { + // Use shorts player only if video is from YouTube Shorts. + let shortsPlayer = video.closest("#shorts-player")?.wrappedJSObject; + let isYTShorts = !!(video.baseURI.includes("shorts") && shortsPlayer); + + this.player = isYTShorts + ? shortsPlayer + : video.closest("#movie_player")?.wrappedJSObject; + } + isLive(video) { + return !!document.querySelector(".ytp-live"); + } + setMuted(video, shouldMute) { + if (this.player) { + if (shouldMute) { + this.player.mute(); + } else { + this.player.unMute(); + } + } else { + video.muted = shouldMute; + } + } + getDuration(video) { + if (this.isLive(video)) { + return Infinity; + } + return video.duration; + } + 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"); + } + setVolume(video, volume) { + if (this.player) { + this.player.setVolume(volume * 100); + } else { + video.volume = volume; + } + } + getVolume(video) { + if (this.player) { + return this.player.getVolume() / 100; + } + return video.volume; + } +} + +this.PictureInPictureVideoWrapper = PictureInPictureVideoWrapper; |