summaryrefslogtreecommitdiffstats
path: root/toolkit/actors/PictureInPictureChild.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/actors/PictureInPictureChild.sys.mjs
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/actors/PictureInPictureChild.sys.mjs')
-rw-r--r--toolkit/actors/PictureInPictureChild.sys.mjs3152
1 files changed, 3152 insertions, 0 deletions
diff --git a/toolkit/actors/PictureInPictureChild.sys.mjs b/toolkit/actors/PictureInPictureChild.sys.mjs
new file mode 100644
index 0000000000..ffaabbd5da
--- /dev/null
+++ b/toolkit/actors/PictureInPictureChild.sys.mjs
@@ -0,0 +1,3152 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ KEYBOARD_CONTROLS: "resource://gre/modules/PictureInPictureControls.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ Rect: "resource://gre/modules/Geometry.sys.mjs",
+ TOGGLE_POLICIES: "resource://gre/modules/PictureInPictureControls.sys.mjs",
+ TOGGLE_POLICY_STRINGS:
+ "resource://gre/modules/PictureInPictureControls.sys.mjs",
+});
+
+import { WebVTT } from "resource://gre/modules/vtt.sys.mjs";
+import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "DISPLAY_TEXT_TRACKS_PREF",
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "IMPROVED_CONTROLS_ENABLED_PREF",
+ "media.videocontrols.picture-in-picture.improved-video-controls.enabled",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "MIN_VIDEO_LENGTH",
+ "media.videocontrols.picture-in-picture.video-toggle.min-video-secs",
+ 45
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "PIP_TOGGLE_ALWAYS_SHOW",
+ "media.videocontrols.picture-in-picture.video-toggle.always-show",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "PIP_URLBAR_BUTTON",
+ "media.videocontrols.picture-in-picture.urlbar-button.enabled",
+ false
+);
+
+const PIP_ENABLED_PREF = "media.videocontrols.picture-in-picture.enabled";
+const TOGGLE_ENABLED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.enabled";
+const TOGGLE_FIRST_SEEN_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.first-seen-secs";
+const TOGGLE_FIRST_TIME_DURATION_DAYS = 28;
+const TOGGLE_HAS_USED_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.has-used";
+const TOGGLE_TESTING_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.testing";
+const TOGGLE_VISIBILITY_THRESHOLD_PREF =
+ "media.videocontrols.picture-in-picture.video-toggle.visibility-threshold";
+const TEXT_TRACK_FONT_SIZE =
+ "media.videocontrols.picture-in-picture.display-text-tracks.size";
+
+const MOUSEMOVE_PROCESSING_DELAY_MS = 50;
+const TOGGLE_HIDING_TIMEOUT_MS = 3000;
+// If you change this, also change VideoControlsWidget.SEEK_TIME_SECS:
+const SEEK_TIME_SECS = 5;
+const EMPTIED_TIMEOUT_MS = 1000;
+
+// The ToggleChild does not want to capture events from the PiP
+// windows themselves. This set contains all currently open PiP
+// players' content windows
+var gPlayerContents = new WeakSet();
+
+// To make it easier to write tests, we have a process-global
+// WeakSet of all <video> elements that are being tracked for
+// mouseover
+var gWeakIntersectingVideosForTesting = new WeakSet();
+
+// Overrides are expected to stay constant for the lifetime of a
+// content process, so we set this as a lazy process global.
+// See PictureInPictureToggleChild.getSiteOverrides for a
+// sense of what the return types are.
+XPCOMUtils.defineLazyGetter(lazy, "gSiteOverrides", () => {
+ return PictureInPictureToggleChild.getSiteOverrides();
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "PictureInPictureChild",
+ maxLogLevel: Services.prefs.getBoolPref(
+ "media.videocontrols.picture-in-picture.log",
+ false
+ )
+ ? "Debug"
+ : "Error",
+ });
+});
+
+/**
+ * Creates and returns an instance of the PictureInPictureChildVideoWrapper class responsible
+ * for applying site-specific wrapper methods around the original video.
+ *
+ * The Picture-In-Picture add-on can use this to provide site-specific wrappers for
+ * sites that require special massaging to control.
+ * @param {Object} pipChild reference to PictureInPictureChild class calling this function
+ * @param {Element} originatingVideo
+ * The <video> element to wrap.
+ * @returns {PictureInPictureChildVideoWrapper} instance of PictureInPictureChildVideoWrapper
+ */
+function applyWrapper(pipChild, originatingVideo) {
+ let originatingDoc = originatingVideo.ownerDocument;
+ let originatingDocumentURI = originatingDoc.documentURI;
+
+ let overrides = lazy.gSiteOverrides.find(([matcher]) => {
+ return matcher.matches(originatingDocumentURI);
+ });
+
+ // gSiteOverrides is a list of tuples where the first element is the MatchPattern
+ // for a supported site and the second is the actual overrides object for it.
+ let wrapperPath = overrides ? overrides[1].videoWrapperScriptPath : null;
+ return new PictureInPictureChildVideoWrapper(
+ wrapperPath,
+ originatingVideo,
+ pipChild
+ );
+}
+
+export class PictureInPictureLauncherChild extends JSWindowActorChild {
+ handleEvent(event) {
+ switch (event.type) {
+ case "MozTogglePictureInPicture": {
+ if (event.isTrusted) {
+ this.togglePictureInPicture({
+ video: event.target,
+ reason: event.detail?.reason,
+ });
+ }
+ break;
+ }
+ }
+ }
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "PictureInPicture:KeyToggle": {
+ this.keyToggle();
+ break;
+ }
+ }
+ }
+
+ /**
+ * Tells the parent to open a Picture-in-Picture window hosting
+ * a clone of the passed video. If we know about a pre-existing
+ * Picture-in-Picture window existing, this tells the parent to
+ * close it before opening the new one.
+ *
+ * @param {Object} pipObject An object containing the video and reason
+ * for toggling the PiP video
+ *
+ * @return {Promise}
+ * @resolves {undefined} Once the new Picture-in-Picture window
+ * has been requested.
+ */
+ async togglePictureInPicture(pipObject) {
+ let { video, reason } = pipObject;
+ if (video.isCloningElementVisually) {
+ // The only way we could have entered here for the same video is if
+ // we are toggling via the context menu or via the urlbar button,
+ // since we hide the inline Picture-in-Picture toggle when a video
+ // is being displayed in Picture-in-Picture. Turn off PiP in this case
+ const stopPipEvent = new this.contentWindow.CustomEvent(
+ "MozStopPictureInPicture",
+ {
+ bubbles: true,
+ detail: { reason },
+ }
+ );
+ video.dispatchEvent(stopPipEvent);
+ return;
+ }
+
+ if (!PictureInPictureChild.videoWrapper) {
+ PictureInPictureChild.videoWrapper = applyWrapper(
+ PictureInPictureChild,
+ video
+ );
+ }
+
+ let timestamp = undefined;
+ let scrubberPosition = undefined;
+
+ if (lazy.IMPROVED_CONTROLS_ENABLED_PREF) {
+ timestamp = PictureInPictureChild.videoWrapper.formatTimestamp(
+ PictureInPictureChild.videoWrapper.getCurrentTime(video),
+ PictureInPictureChild.videoWrapper.getDuration(video)
+ );
+
+ // Scrubber is hidden if undefined, so only set it to something else
+ // if the timestamp is not undefined.
+ scrubberPosition =
+ timestamp === undefined
+ ? undefined
+ : PictureInPictureChild.videoWrapper.getCurrentTime(video) /
+ PictureInPictureChild.videoWrapper.getDuration(video);
+ }
+
+ // All other requests to toggle PiP should open a new PiP
+ // window
+ const videoRef = lazy.ContentDOMReference.get(video);
+ this.sendAsyncMessage("PictureInPicture:Request", {
+ isMuted: PictureInPictureChild.videoIsMuted(video),
+ playing: PictureInPictureChild.videoIsPlaying(video),
+ videoHeight: video.videoHeight,
+ videoWidth: video.videoWidth,
+ videoRef,
+ ccEnabled: lazy.DISPLAY_TEXT_TRACKS_PREF,
+ webVTTSubtitles: !!video.textTracks?.length,
+ scrubberPosition,
+ timestamp,
+ });
+
+ let args = {
+ firstTimeToggle: (!Services.prefs.getBoolPref(
+ TOGGLE_HAS_USED_PREF
+ )).toString(),
+ };
+
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ "opened_method",
+ reason,
+ null,
+ args
+ );
+ }
+
+ /**
+ * The keyboard was used to attempt to open Picture-in-Picture. If a video is focused,
+ * select that video. Otherwise find the first playing video, or if none, the largest
+ * dimension video. We suspect this heuristic will handle most cases, though we
+ * might refine this later on. Note that we assume that this method will only be
+ * called for the focused document.
+ */
+ keyToggle() {
+ let doc = this.document;
+ if (doc) {
+ let video = doc.activeElement;
+ if (!HTMLVideoElement.isInstance(video)) {
+ let listOfVideos = [...doc.querySelectorAll("video")].filter(
+ video => !isNaN(video.duration)
+ );
+ // Get the first non-paused video, otherwise the longest video. This
+ // fallback is designed to skip over "preview"-style videos on sidebars.
+ video =
+ listOfVideos.filter(v => !v.paused)[0] ||
+ listOfVideos.sort((a, b) => b.duration - a.duration)[0];
+ }
+ if (video) {
+ this.togglePictureInPicture({ video, reason: "shortcut" });
+ }
+ }
+ }
+}
+
+/**
+ * The PictureInPictureToggleChild is responsible for displaying the overlaid
+ * Picture-in-Picture toggle over top of <video> elements that the mouse is
+ * hovering.
+ */
+export class PictureInPictureToggleChild extends JSWindowActorChild {
+ constructor() {
+ super();
+ // We need to maintain some state about various things related to the
+ // Picture-in-Picture toggles - however, for now, the same
+ // PictureInPictureToggleChild might be re-used for different documents.
+ // We keep the state stashed inside of this WeakMap, keyed on the document
+ // itself.
+ this.weakDocStates = new WeakMap();
+ this.toggleEnabled =
+ Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF) &&
+ Services.prefs.getBoolPref(PIP_ENABLED_PREF);
+ this.toggleTesting = Services.prefs.getBoolPref(TOGGLE_TESTING_PREF, false);
+
+ // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's
+ // directly, so we create a new function here instead to act as our
+ // nsIObserver, which forwards the notification to the observe method.
+ this.observerFunction = (subject, topic, data) => {
+ this.observe(subject, topic, data);
+ };
+ Services.prefs.addObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
+ Services.prefs.addObserver(PIP_ENABLED_PREF, this.observerFunction);
+ Services.prefs.addObserver(TOGGLE_FIRST_SEEN_PREF, this.observerFunction);
+ Services.cpmm.sharedData.addEventListener("change", this);
+
+ this.eligiblePipVideos = new WeakSet();
+ this.trackingVideos = new WeakSet();
+ }
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "PictureInPicture:UrlbarToggle": {
+ this.urlbarToggle();
+ break;
+ }
+ }
+ return null;
+ }
+
+ didDestroy() {
+ this.stopTrackingMouseOverVideos();
+ Services.prefs.removeObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
+ Services.prefs.removeObserver(PIP_ENABLED_PREF, this.observerFunction);
+ Services.prefs.removeObserver(
+ TOGGLE_FIRST_SEEN_PREF,
+ this.observerFunction
+ );
+ Services.cpmm.sharedData.removeEventListener("change", this);
+
+ // remove the observer on the <video> element
+ let state = this.docState;
+ if (state?.intersectionObserver) {
+ state.intersectionObserver.disconnect();
+ }
+
+ // ensure the sandbox created by the video is destroyed
+ this.videoWrapper?.destroy();
+ this.videoWrapper = null;
+
+ for (let video of ChromeUtils.nondeterministicGetWeakSetKeys(
+ this.eligiblePipVideos
+ )) {
+ video.removeEventListener("emptied", this);
+ video.removeEventListener("loadedmetadata", this);
+ video.removeEventListener("durationchange", this);
+ }
+
+ for (let video of ChromeUtils.nondeterministicGetWeakSetKeys(
+ this.trackingVideos
+ )) {
+ video.removeEventListener("emptied", this);
+ video.removeEventListener("loadedmetadata", this);
+ video.removeEventListener("durationchange", this);
+ }
+
+ // ensure we don't access the state
+ this.isDestroyed = true;
+ }
+
+ observe(subject, topic, data) {
+ if (topic != "nsPref:changed") {
+ return;
+ }
+
+ this.toggleEnabled =
+ Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF) &&
+ Services.prefs.getBoolPref(PIP_ENABLED_PREF);
+
+ if (this.toggleEnabled) {
+ // We have enabled the Picture-in-Picture toggle, so we need to make
+ // sure we register all of the videos that might already be on the page.
+ this.contentWindow.requestIdleCallback(() => {
+ let videos = this.document.querySelectorAll("video");
+ for (let video of videos) {
+ this.registerVideo(video);
+ }
+ });
+ }
+
+ switch (data) {
+ case TOGGLE_FIRST_SEEN_PREF:
+ const firstSeenSeconds = Services.prefs.getIntPref(
+ TOGGLE_FIRST_SEEN_PREF
+ );
+ if (!firstSeenSeconds || firstSeenSeconds < 0) {
+ return;
+ }
+ this.changeToIconIfDurationEnd(firstSeenSeconds);
+ break;
+ }
+ }
+
+ /**
+ * Returns the state for the current document referred to via
+ * this.document. If no such state exists, creates it, stores it
+ * and returns it.
+ */
+ get docState() {
+ if (this.isDestroyed || !this.document) {
+ return false;
+ }
+
+ let state = this.weakDocStates.get(this.document);
+
+ let visibilityThresholdPref = Services.prefs.getFloatPref(
+ TOGGLE_VISIBILITY_THRESHOLD_PREF,
+ "1.0"
+ );
+
+ if (!state) {
+ state = {
+ // A reference to the IntersectionObserver that's monitoring for videos
+ // to become visible.
+ intersectionObserver: null,
+ // A WeakSet of videos that are supposedly visible, according to the
+ // IntersectionObserver.
+ weakVisibleVideos: new WeakSet(),
+ // The number of videos that are supposedly visible, according to the
+ // IntersectionObserver
+ visibleVideosCount: 0,
+ // The DeferredTask that we'll arm every time a mousemove event occurs
+ // on a page where we have one or more visible videos.
+ mousemoveDeferredTask: null,
+ // A weak reference to the last video we displayed the toggle over.
+ weakOverVideo: null,
+ // True if the user is in the midst of clicking the toggle.
+ isClickingToggle: false,
+ // Set to the original target element on pointerdown if the user is clicking
+ // the toggle - this way, we can determine if a "click" event will need to be
+ // suppressed ("click" events don't fire if a "mouseup" occurs on a different
+ // element from the "pointerdown" / "mousedown" event).
+ clickedElement: null,
+ // This is a DeferredTask to hide the toggle after a period of mouse
+ // inactivity.
+ hideToggleDeferredTask: null,
+ // If we reach a point where we're tracking videos for mouse movements,
+ // then this will be true. If there are no videos worth tracking, then
+ // this is false.
+ isTrackingVideos: false,
+ togglePolicy: lazy.TOGGLE_POLICIES.DEFAULT,
+ toggleVisibilityThreshold: visibilityThresholdPref,
+ // The documentURI that has been checked with toggle policies and
+ // visibility thresholds for this document. Note that the documentURI
+ // might change for a document via the history API, so we remember
+ // the last checked documentURI to determine if we need to check again.
+ checkedPolicyDocumentURI: null,
+ };
+ this.weakDocStates.set(this.document, state);
+ }
+
+ return state;
+ }
+
+ /**
+ * Returns the video that the user was last hovering with the mouse if it
+ * still exists.
+ *
+ * @return {Element} the <video> element that the user was last hovering,
+ * or null if there was no such <video>, or the <video> no longer exists.
+ */
+ getWeakOverVideo() {
+ let { weakOverVideo } = this.docState;
+ if (weakOverVideo) {
+ // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
+ // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
+ try {
+ return weakOverVideo.get();
+ } catch (e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ handleEvent(event) {
+ if (!event.isTrusted) {
+ // We don't care about synthesized events that might be coming from
+ // content JS.
+ return;
+ }
+
+ // Don't capture events from Picture-in-Picture content windows
+ if (gPlayerContents.has(this.contentWindow)) {
+ return;
+ }
+
+ switch (event.type) {
+ case "touchstart": {
+ // Even if this is a touch event, there may be subsequent click events.
+ // Suppress those events after selecting the toggle to prevent playback changes
+ // when opening the Picture-in-Picture window.
+ if (this.docState.isClickingToggle) {
+ event.stopImmediatePropagation();
+ event.preventDefault();
+ }
+ break;
+ }
+ case "change": {
+ const { changedKeys } = event;
+ if (changedKeys.includes("PictureInPicture:SiteOverrides")) {
+ // For now we only update our cache if the site overrides change.
+ // the user will need to refresh the page for changes to apply.
+ try {
+ lazy.gSiteOverrides =
+ PictureInPictureToggleChild.getSiteOverrides();
+ } catch (e) {
+ // Ignore resulting TypeError if gSiteOverrides is still unloaded
+ if (!(e instanceof TypeError)) {
+ throw e;
+ }
+ }
+ }
+ break;
+ }
+ case "UAWidgetSetupOrChange": {
+ if (
+ this.toggleEnabled &&
+ this.contentWindow.HTMLVideoElement.isInstance(event.target) &&
+ event.target.ownerDocument == this.document
+ ) {
+ this.registerVideo(event.target);
+ }
+ break;
+ }
+ case "contextmenu": {
+ if (this.toggleEnabled) {
+ this.checkContextMenu(event);
+ }
+ break;
+ }
+ case "mouseout": {
+ this.onMouseOut(event);
+ break;
+ }
+ case "click":
+ if (event.detail == 0) {
+ let shadowRoot = event.originalTarget.containingShadowRoot;
+ let toggle = this.getToggleElement(shadowRoot);
+ if (event.originalTarget == toggle) {
+ this.startPictureInPicture(event, shadowRoot.host, toggle);
+ return;
+ }
+ }
+ // fall through
+ case "mousedown":
+ case "pointerup":
+ case "mouseup": {
+ this.onMouseButtonEvent(event);
+ break;
+ }
+ case "pointerdown": {
+ this.onPointerDown(event);
+ break;
+ }
+ case "mousemove": {
+ this.onMouseMove(event);
+ break;
+ }
+ case "pageshow": {
+ this.onPageShow(event);
+ break;
+ }
+ case "pagehide": {
+ this.onPageHide(event);
+ break;
+ }
+ case "durationchange":
+ // Intentional fall-through
+ case "emptied":
+ // Intentional fall-through
+ case "loadedmetadata": {
+ this.updatePipVideoEligibility(event.target);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Adds a <video> to the IntersectionObserver so that we know when it becomes
+ * visible.
+ *
+ * @param {Element} video The <video> element to register.
+ */
+ registerVideo(video) {
+ let state = this.docState;
+ if (!state.intersectionObserver) {
+ let fn = this.onIntersection.bind(this);
+ state.intersectionObserver = new this.contentWindow.IntersectionObserver(
+ fn,
+ {
+ threshold: [0.0, 0.5],
+ }
+ );
+ }
+
+ state.intersectionObserver.observe(video);
+
+ if (!lazy.PIP_URLBAR_BUTTON) {
+ return;
+ }
+
+ video.addEventListener("emptied", this);
+ video.addEventListener("loadedmetadata", this);
+ video.addEventListener("durationchange", this);
+
+ this.trackingVideos.add(video);
+
+ this.updatePipVideoEligibility(video);
+ }
+
+ updatePipVideoEligibility(video) {
+ let isEligible = this.isVideoPiPEligible(video);
+ if (isEligible) {
+ if (!this.eligiblePipVideos.has(video)) {
+ this.eligiblePipVideos.add(video);
+
+ let mutationObserver = new this.contentWindow.MutationObserver(
+ mutationList => {
+ this.handleEligiblePipVideoMutation(mutationList);
+ }
+ );
+ mutationObserver.observe(video.parentElement, { childList: true });
+ }
+ } else if (this.eligiblePipVideos.has(video)) {
+ this.eligiblePipVideos.delete(video);
+ }
+
+ let videos = ChromeUtils.nondeterministicGetWeakSetKeys(
+ this.eligiblePipVideos
+ );
+
+ this.sendAsyncMessage("PictureInPicture:UpdateEligiblePipVideoCount", {
+ pipCount: videos.length,
+ pipDisabledCount: videos.reduce(
+ (accumulator, currentVal) =>
+ accumulator +
+ (currentVal.getAttribute("disablePictureInPicture") === "true"
+ ? 1
+ : 0),
+ 0
+ ),
+ });
+ }
+
+ handleEligiblePipVideoMutation(mutationList) {
+ for (let mutationRecord of mutationList) {
+ let video = mutationRecord.removedNodes[0];
+ this.eligiblePipVideos.delete(video);
+ }
+
+ let videos = ChromeUtils.nondeterministicGetWeakSetKeys(
+ this.eligiblePipVideos
+ );
+
+ this.sendAsyncMessage("PictureInPicture:UpdateEligiblePipVideoCount", {
+ pipCount: videos.length,
+ pipDisabledCount: videos.reduce(
+ (accumulator, currentVal) =>
+ accumulator +
+ (currentVal.getAttribute("disablePictureInPicture") === "true"
+ ? 1
+ : 0),
+ 0
+ ),
+ });
+ }
+
+ urlbarToggle() {
+ let video = ChromeUtils.nondeterministicGetWeakSetKeys(
+ this.eligiblePipVideos
+ )[0];
+ if (video) {
+ let pipEvent = new this.contentWindow.CustomEvent(
+ "MozTogglePictureInPicture",
+ {
+ bubbles: true,
+ detail: { reason: "urlBar" },
+ }
+ );
+ video.dispatchEvent(pipEvent);
+ }
+ }
+
+ isVideoPiPEligible(video) {
+ if (lazy.PIP_TOGGLE_ALWAYS_SHOW) {
+ return true;
+ }
+
+ if (isNaN(video.duration) || video.duration < lazy.MIN_VIDEO_LENGTH) {
+ return false;
+ }
+
+ const MIN_VIDEO_DIMENSION = 140; // pixels
+ if (
+ video.clientWidth < MIN_VIDEO_DIMENSION ||
+ video.clientHeight < MIN_VIDEO_DIMENSION
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Changes from the first-time toggle to the icon toggle if the Nimbus variable `displayDuration`'s
+ * end date is reached when hovering over a video. The end date is calculated according to the timestamp
+ * indicating when the PiP toggle was first seen.
+ * @param {Number} firstSeenStartSeconds the timestamp in seconds indicating when the PiP toggle was first seen
+ */
+ changeToIconIfDurationEnd(firstSeenStartSeconds) {
+ const { displayDuration } =
+ lazy.NimbusFeatures.pictureinpicture.getAllVariables({
+ defaultValues: {
+ displayDuration: TOGGLE_FIRST_TIME_DURATION_DAYS,
+ },
+ });
+ if (!displayDuration || displayDuration < 0) {
+ return;
+ }
+
+ let daysInSeconds = displayDuration * 24 * 60 * 60;
+ let firstSeenEndSeconds = daysInSeconds + firstSeenStartSeconds;
+ let currentDateSeconds = Math.round(Date.now() / 1000);
+
+ lazy.logConsole.debug(
+ "Toggle duration experiment - first time toggle seen on:",
+ new Date(firstSeenStartSeconds * 1000).toLocaleDateString()
+ );
+ lazy.logConsole.debug(
+ "Toggle duration experiment - first time toggle will change on:",
+ new Date(firstSeenEndSeconds * 1000).toLocaleDateString()
+ );
+ lazy.logConsole.debug(
+ "Toggle duration experiment - current date:",
+ new Date(currentDateSeconds * 1000).toLocaleDateString()
+ );
+
+ if (currentDateSeconds >= firstSeenEndSeconds) {
+ this.sendAsyncMessage("PictureInPicture:SetHasUsed", {
+ hasUsed: true,
+ });
+ }
+ }
+
+ /**
+ * Called by the IntersectionObserver callback once a video becomes visible.
+ * This adds some fine-grained checking to ensure that a sufficient amount of
+ * the video is visible before we consider showing the toggles on it. For now,
+ * that means that the entirety of the video must be in the viewport.
+ *
+ * @param {IntersectionEntry} intersectionEntry An IntersectionEntry passed to
+ * the IntersectionObserver callback.
+ * @return bool Whether or not we should start tracking mousemove events for
+ * this registered video.
+ */
+ worthTracking(intersectionEntry) {
+ return intersectionEntry.isIntersecting;
+ }
+
+ /**
+ * Called by the IntersectionObserver once a video crosses one of the
+ * thresholds dictated by the IntersectionObserver configuration.
+ *
+ * @param {Array<IntersectionEntry>} A collection of one or more
+ * IntersectionEntry's for <video> elements that might have entered or exited
+ * the viewport.
+ */
+ onIntersection(entries) {
+ // The IntersectionObserver will also fire when a previously intersecting
+ // element is removed from the DOM. We know, however, that the node is
+ // still alive and referrable from the WeakSet because the
+ // IntersectionObserverEntry holds a strong reference to the video.
+ let state = this.docState;
+ if (!state) {
+ return;
+ }
+ let oldVisibleVideosCount = state.visibleVideosCount;
+ for (let entry of entries) {
+ let video = entry.target;
+ if (this.worthTracking(entry)) {
+ if (!state.weakVisibleVideos.has(video)) {
+ state.weakVisibleVideos.add(video);
+ state.visibleVideosCount++;
+ if (this.toggleTesting) {
+ gWeakIntersectingVideosForTesting.add(video);
+ }
+ }
+ } else if (state.weakVisibleVideos.has(video)) {
+ state.weakVisibleVideos.delete(video);
+ state.visibleVideosCount--;
+ if (this.toggleTesting) {
+ gWeakIntersectingVideosForTesting.delete(video);
+ }
+ }
+ }
+
+ // For testing, especially in debug or asan builds, we might not
+ // run this idle callback within an acceptable time. While we're
+ // testing, we'll bypass the idle callback performance optimization
+ // and run our callbacks as soon as possible during the next idle
+ // period.
+ if (!oldVisibleVideosCount && state.visibleVideosCount) {
+ if (this.toggleTesting || !this.contentWindow) {
+ this.beginTrackingMouseOverVideos();
+ } else {
+ this.contentWindow.requestIdleCallback(() => {
+ this.beginTrackingMouseOverVideos();
+ });
+ }
+ } else if (oldVisibleVideosCount && !state.visibleVideosCount) {
+ if (this.toggleTesting || !this.contentWindow) {
+ this.stopTrackingMouseOverVideos();
+ } else {
+ this.contentWindow.requestIdleCallback(() => {
+ this.stopTrackingMouseOverVideos();
+ });
+ }
+ }
+ }
+
+ addMouseButtonListeners() {
+ // We want to try to cancel the mouse events from continuing
+ // on into content if the user has clicked on the toggle, so
+ // we don't use the mozSystemGroup here, and add the listener
+ // to the parent target of the window, which in this case,
+ // is the windowRoot. Since this event listener is attached to
+ // part of the outer window, we need to also remove it in a
+ // pagehide event listener in the event that the page unloads
+ // before stopTrackingMouseOverVideos fires.
+ this.contentWindow.windowRoot.addEventListener("pointerdown", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.addEventListener("mousedown", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.addEventListener("mouseup", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.addEventListener("pointerup", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.addEventListener("click", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.addEventListener("mouseout", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.addEventListener("touchstart", this, {
+ capture: true,
+ });
+ }
+
+ removeMouseButtonListeners() {
+ // This can be null when closing the tab, but the event
+ // listeners should be removed in that case already.
+ if (!this.contentWindow || !this.contentWindow.windowRoot) {
+ return;
+ }
+
+ this.contentWindow.windowRoot.removeEventListener("pointerdown", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.removeEventListener("mousedown", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.removeEventListener("mouseup", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.removeEventListener("pointerup", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.removeEventListener("click", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.removeEventListener("mouseout", this, {
+ capture: true,
+ });
+ this.contentWindow.windowRoot.removeEventListener("touchstart", this, {
+ capture: true,
+ });
+ }
+
+ /**
+ * One of the challenges of displaying this toggle is that many sites put
+ * things over top of <video> elements, like custom controls, or images, or
+ * all manner of things that might intercept mouseevents that would normally
+ * fire directly on the <video>. In order to properly detect when the mouse
+ * is over top of one of the <video> elements in this situation, we currently
+ * add a mousemove event handler to the entire document, and stash the most
+ * recent mousemove that fires. At periodic intervals, that stashed mousemove
+ * event is checked to see if it's hovering over one of our registered
+ * <video> elements.
+ *
+ * This sort of thing will not be necessary once bug 1539652 is fixed.
+ */
+ beginTrackingMouseOverVideos() {
+ let state = this.docState;
+ if (!state.mousemoveDeferredTask) {
+ state.mousemoveDeferredTask = new lazy.DeferredTask(() => {
+ this.checkLastMouseMove();
+ }, MOUSEMOVE_PROCESSING_DELAY_MS);
+ }
+ this.document.addEventListener("mousemove", this, {
+ mozSystemGroup: true,
+ capture: true,
+ });
+ this.contentWindow.addEventListener("pageshow", this, {
+ mozSystemGroup: true,
+ });
+ this.contentWindow.addEventListener("pagehide", this, {
+ mozSystemGroup: true,
+ });
+ this.addMouseButtonListeners();
+ state.isTrackingVideos = true;
+ }
+
+ /**
+ * If we no longer have any interesting videos in the viewport, we deregister
+ * the mousemove and click listeners, and also remove any toggles that might
+ * be on the page still.
+ */
+ stopTrackingMouseOverVideos() {
+ let state = this.docState;
+ // We initialize `mousemoveDeferredTask` in `beginTrackingMouseOverVideos`.
+ // If it doesn't exist, that can't have happened. Nothing else ever sets
+ // this value (though we arm/disarm in various places). So we don't need
+ // to do anything else here and can return early.
+ if (!state.mousemoveDeferredTask) {
+ return;
+ }
+ state.mousemoveDeferredTask.disarm();
+ this.document.removeEventListener("mousemove", this, {
+ mozSystemGroup: true,
+ capture: true,
+ });
+ if (this.contentWindow) {
+ this.contentWindow.removeEventListener("pageshow", this, {
+ mozSystemGroup: true,
+ });
+ this.contentWindow.removeEventListener("pagehide", this, {
+ mozSystemGroup: true,
+ });
+ }
+ this.removeMouseButtonListeners();
+ let oldOverVideo = this.getWeakOverVideo();
+ if (oldOverVideo) {
+ this.onMouseLeaveVideo(oldOverVideo);
+ }
+ state.isTrackingVideos = false;
+ }
+
+ /**
+ * This pageshow event handler will get called if and when we complete a tab
+ * tear out or in. If we happened to be tracking videos before the tear
+ * occurred, we re-add the mouse event listeners so that they're attached to
+ * the right WindowRoot.
+ *
+ * @param {Event} event The pageshow event fired when completing a tab tear
+ * out or in.
+ */
+ onPageShow(event) {
+ let state = this.docState;
+ if (state.isTrackingVideos) {
+ this.addMouseButtonListeners();
+ }
+ }
+
+ /**
+ * This pagehide event handler will get called if and when we start a tab
+ * tear out or in. If we happened to be tracking videos before the tear
+ * occurred, we remove the mouse event listeners. We'll re-add them when the
+ * pageshow event fires.
+ *
+ * @param {Event} event The pagehide event fired when starting a tab tear
+ * out or in.
+ */
+ onPageHide(event) {
+ let state = this.docState;
+ if (state.isTrackingVideos) {
+ this.removeMouseButtonListeners();
+ }
+ }
+
+ /**
+ * If we're tracking <video> elements, this pointerdown event handler is run anytime
+ * a pointerdown occurs on the document. This function is responsible for checking
+ * if the user clicked on the Picture-in-Picture toggle. It does this by first
+ * checking if the video is visible beneath the point that was clicked. Then
+ * it tests whether or not the pointerdown occurred within the rectangle of the
+ * toggle. If so, the event's propagation is stopped, and Picture-in-Picture is
+ * triggered.
+ *
+ * @param {Event} event The mousemove event.
+ */
+ onPointerDown(event) {
+ // The toggle ignores non-primary mouse clicks.
+ if (event.button != 0) {
+ return;
+ }
+
+ let video = this.getWeakOverVideo();
+ if (!video) {
+ return;
+ }
+
+ let shadowRoot = video.openOrClosedShadowRoot;
+ if (!shadowRoot) {
+ return;
+ }
+
+ let state = this.docState;
+
+ let overVideo = (() => {
+ let { clientX, clientY } = event;
+ let winUtils = this.contentWindow.windowUtils;
+ // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
+ // since document.elementsFromPoint always flushes layout. The 1's in that
+ // function call are for the size of the rect that we want, which is 1x1.
+ //
+ // We pass the aOnlyVisible boolean argument to check that the video isn't
+ // occluded by anything visible at the point of mousedown. If it is, we'll
+ // ignore the mousedown.
+ let elements = winUtils.nodesFromRect(
+ clientX,
+ clientY,
+ 1,
+ 1,
+ 1,
+ 1,
+ true,
+ false,
+ /* aOnlyVisible = */ true,
+ state.toggleVisibilityThreshold
+ );
+
+ for (let element of elements) {
+ if (element == video || element.containingShadowRoot == shadowRoot) {
+ return true;
+ }
+ }
+
+ return false;
+ })();
+
+ if (!overVideo) {
+ return;
+ }
+
+ let toggle = this.getToggleElement(shadowRoot);
+ if (this.isMouseOverToggle(toggle, event)) {
+ state.isClickingToggle = true;
+ state.clickedElement = Cu.getWeakReference(event.originalTarget);
+ event.stopImmediatePropagation();
+
+ this.startPictureInPicture(event, video, toggle);
+ }
+ }
+
+ startPictureInPicture(event, video, toggle) {
+ Services.telemetry.keyedScalarAdd(
+ "pictureinpicture.opened_method",
+ "toggle",
+ 1
+ );
+
+ let pipEvent = new this.contentWindow.CustomEvent(
+ "MozTogglePictureInPicture",
+ {
+ bubbles: true,
+ detail: { reason: "toggle" },
+ }
+ );
+ video.dispatchEvent(pipEvent);
+
+ // Since we've initiated Picture-in-Picture, we can go ahead and
+ // hide the toggle now.
+ this.onMouseLeaveVideo(video);
+ }
+
+ /**
+ * Called for mousedown, pointerup, mouseup and click events. If we
+ * detected that the user is clicking on the Picture-in-Picture toggle,
+ * these events are cancelled in the capture-phase before they reach
+ * content. The state for suppressing these events is cleared on the
+ * click event (unless the mouseup occurs on a different element from
+ * the mousedown, in which case, the state is cleared on mouseup).
+ *
+ * @param {Event} event A mousedown, pointerup, mouseup or click event.
+ */
+ onMouseButtonEvent(event) {
+ // The toggle ignores non-primary mouse clicks.
+ if (event.button != 0) {
+ return;
+ }
+
+ let state = this.docState;
+ if (state.isClickingToggle) {
+ event.stopImmediatePropagation();
+
+ // If this is a mouseup event, check to see if we have a record of what
+ // the original target was on pointerdown. If so, and if it doesn't match
+ // the mouseup original target, that means we won't get a click event, and
+ // we can clear the "clicking the toggle" state right away.
+ //
+ // Otherwise, we wait for the click event to do that.
+ let isMouseUpOnOtherElement =
+ event.type == "mouseup" &&
+ (!state.clickedElement ||
+ state.clickedElement.get() != event.originalTarget);
+
+ if (
+ isMouseUpOnOtherElement ||
+ event.type == "click" ||
+ // pointerup event still triggers after a touchstart event. We just need to detect
+ // the pointer type and determine if we got to this part of the code through a touch event.
+ event.pointerType == "touch"
+ ) {
+ // The click is complete, so now we reset the state so that
+ // we stop suppressing these events.
+ state.isClickingToggle = false;
+ state.clickedElement = null;
+ }
+ }
+ }
+
+ /**
+ * Called on mouseout events to determine whether or not the mouse has
+ * exited the window.
+ *
+ * @param {Event} event The mouseout event.
+ */
+ onMouseOut(event) {
+ if (!event.relatedTarget) {
+ // For mouseout events, if there's no relatedTarget (which normally
+ // maps to the element that the mouse entered into) then this means that
+ // we left the window.
+ let video = this.getWeakOverVideo();
+ if (!video) {
+ return;
+ }
+
+ this.onMouseLeaveVideo(video);
+ }
+ }
+
+ /**
+ * Called for each mousemove event when we're tracking those events to
+ * determine if the cursor is hovering over a <video>.
+ *
+ * @param {Event} event The mousemove event.
+ */
+ onMouseMove(event) {
+ let state = this.docState;
+
+ if (state.hideToggleDeferredTask) {
+ state.hideToggleDeferredTask.disarm();
+ state.hideToggleDeferredTask.arm();
+ }
+
+ state.lastMouseMoveEvent = event;
+ state.mousemoveDeferredTask.arm();
+ }
+
+ /**
+ * Called by the DeferredTask after MOUSEMOVE_PROCESSING_DELAY_MS
+ * milliseconds. Checked to see if that mousemove happens to be overtop of
+ * any interesting <video> elements that we want to display the toggle
+ * on. If so, puts the toggle on that video.
+ */
+ checkLastMouseMove() {
+ let state = this.docState;
+ let event = state.lastMouseMoveEvent;
+ let { clientX, clientY } = event;
+ lazy.logConsole.debug("Visible videos count:", state.visibleVideosCount);
+ lazy.logConsole.debug("Tracking videos:", state.isTrackingVideos);
+ let winUtils = this.contentWindow.windowUtils;
+ // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
+ // since document.elementsFromPoint always flushes layout. The 1's in that
+ // function call are for the size of the rect that we want, which is 1x1.
+ let elements = winUtils.nodesFromRect(
+ clientX,
+ clientY,
+ 1,
+ 1,
+ 1,
+ 1,
+ true,
+ false,
+ /* aOnlyVisible = */ true
+ );
+
+ for (let element of elements) {
+ lazy.logConsole.debug("Element id under cursor:", element.id);
+ lazy.logConsole.debug(
+ "Node name of an element under cursor:",
+ element.nodeName
+ );
+ lazy.logConsole.debug(
+ "Supported <video> element:",
+ state.weakVisibleVideos.has(element)
+ );
+ lazy.logConsole.debug(
+ "PiP window is open:",
+ element.isCloningElementVisually
+ );
+
+ // Check for hovering over the video controls or so too, not only
+ // directly over the video.
+ for (let el = element; el; el = el.containingShadowRoot?.host) {
+ if (state.weakVisibleVideos.has(el) && !el.isCloningElementVisually) {
+ lazy.logConsole.debug("Found supported element");
+ this.onMouseOverVideo(el, event);
+ return;
+ }
+ }
+ }
+
+ let oldOverVideo = this.getWeakOverVideo();
+ if (oldOverVideo) {
+ this.onMouseLeaveVideo(oldOverVideo);
+ }
+ }
+
+ /**
+ * Called once it has been determined that the mouse is overtop of a video
+ * that is in the viewport.
+ *
+ * @param {Element} video The video the mouse is over.
+ */
+ onMouseOverVideo(video, event) {
+ let oldOverVideo = this.getWeakOverVideo();
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ if (shadowRoot.firstChild && video != oldOverVideo) {
+ if (video.getTransformToViewport().a == -1) {
+ shadowRoot.firstChild.setAttribute("flipped", true);
+ } else {
+ shadowRoot.firstChild.removeAttribute("flipped");
+ }
+ }
+
+ // It seems from automated testing that if it's still very early on in the
+ // lifecycle of a <video> element, it might not yet have a shadowRoot,
+ // in which case, we can bail out here early.
+ if (!shadowRoot) {
+ if (oldOverVideo) {
+ // We also clear the hover state on the old video we were hovering,
+ // if there was one.
+ this.onMouseLeaveVideo(oldOverVideo);
+ }
+
+ return;
+ }
+
+ let state = this.docState;
+ let toggle = this.getToggleElement(shadowRoot);
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+
+ if (state.checkedPolicyDocumentURI != this.document.documentURI) {
+ state.togglePolicy = lazy.TOGGLE_POLICIES.DEFAULT;
+ // We cache the matchers process-wide. We'll skip this while running tests to make that
+ // easier.
+ let siteOverrides = this.toggleTesting
+ ? PictureInPictureToggleChild.getSiteOverrides()
+ : lazy.gSiteOverrides;
+
+ let visibilityThresholdPref = Services.prefs.getFloatPref(
+ TOGGLE_VISIBILITY_THRESHOLD_PREF,
+ "1.0"
+ );
+
+ if (!this.videoWrapper) {
+ this.videoWrapper = applyWrapper(this, video);
+ }
+
+ // Do we have any toggle overrides? If so, try to apply them.
+ for (let [override, { policy, visibilityThreshold }] of siteOverrides) {
+ if (
+ (policy || visibilityThreshold) &&
+ override.matches(this.document.documentURI)
+ ) {
+ state.togglePolicy = this.videoWrapper?.shouldHideToggle(video)
+ ? lazy.TOGGLE_POLICIES.HIDDEN
+ : policy || lazy.TOGGLE_POLICIES.DEFAULT;
+ state.toggleVisibilityThreshold =
+ visibilityThreshold || visibilityThresholdPref;
+ break;
+ }
+ }
+
+ state.checkedPolicyDocumentURI = this.document.documentURI;
+ }
+
+ // The built-in <video> controls are along the bottom, which would overlap the
+ // toggle if the override is set to BOTTOM, so we ignore overrides that set
+ // a policy of BOTTOM for <video> elements with controls.
+ if (
+ state.togglePolicy != lazy.TOGGLE_POLICIES.DEFAULT &&
+ !(state.togglePolicy == lazy.TOGGLE_POLICIES.BOTTOM && video.controls)
+ ) {
+ toggle.setAttribute(
+ "policy",
+ lazy.TOGGLE_POLICY_STRINGS[state.togglePolicy]
+ );
+ } else {
+ toggle.removeAttribute("policy");
+ }
+
+ // nimbusExperimentVariables will be defaultValues when the experiment is disabled
+ const nimbusExperimentVariables =
+ lazy.NimbusFeatures.pictureinpicture.getAllVariables({
+ defaultValues: {
+ oldToggle: true,
+ title: null,
+ message: false,
+ showIconOnly: false,
+ displayDuration: TOGGLE_FIRST_TIME_DURATION_DAYS,
+ },
+ });
+
+ /**
+ * If a Nimbus variable exists for the first-time PiP toggle design,
+ * override the old design via a classname "experiment".
+ */
+ if (!nimbusExperimentVariables.oldToggle) {
+ let controlsContainer = shadowRoot.querySelector(".controlsContainer");
+ let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
+
+ controlsContainer.classList.add("experiment");
+ pipWrapper.classList.add("experiment");
+ } else {
+ let controlsContainer = shadowRoot.querySelector(".controlsContainer");
+ let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
+
+ controlsContainer.classList.remove("experiment");
+ pipWrapper.classList.remove("experiment");
+ }
+
+ if (nimbusExperimentVariables.title) {
+ let pipExplainer = shadowRoot.querySelector(".pip-explainer");
+ let pipLabel = shadowRoot.querySelector(".pip-label");
+
+ if (pipExplainer && nimbusExperimentVariables.message) {
+ pipExplainer.innerText = nimbusExperimentVariables.message;
+ }
+ pipLabel.innerText = nimbusExperimentVariables.title;
+ } else if (nimbusExperimentVariables.showIconOnly) {
+ // We only want to show the PiP icon in this experiment scenario
+ let pipExpanded = shadowRoot.querySelector(".pip-expanded");
+ pipExpanded.style.display = "none";
+ let pipSmall = shadowRoot.querySelector(".pip-small");
+ pipSmall.style.opacity = "1";
+
+ let pipIcon = shadowRoot.querySelectorAll(".pip-icon")[1];
+ pipIcon.style.display = "block";
+ }
+
+ controlsOverlay.removeAttribute("hidetoggle");
+
+ // The hideToggleDeferredTask we create here is for automatically hiding
+ // the toggle after a period of no mousemove activity for
+ // TOGGLE_HIDING_TIMEOUT_MS. If the mouse moves, then the DeferredTask
+ // timer is reset.
+ //
+ // We disable the toggle hiding timeout during testing to reduce
+ // non-determinism from timers when testing the toggle.
+ if (!state.hideToggleDeferredTask && !this.toggleTesting) {
+ state.hideToggleDeferredTask = new lazy.DeferredTask(() => {
+ controlsOverlay.setAttribute("hidetoggle", true);
+ }, TOGGLE_HIDING_TIMEOUT_MS);
+ }
+
+ if (oldOverVideo) {
+ if (oldOverVideo == video) {
+ // If we're still hovering the old video, we might have entered or
+ // exited the toggle region.
+ this.checkHoverToggle(toggle, event);
+ return;
+ }
+
+ // We had an old video that we were hovering, and we're not hovering
+ // it anymore. Let's leave it.
+ this.onMouseLeaveVideo(oldOverVideo);
+ }
+
+ state.weakOverVideo = Cu.getWeakReference(video);
+ controlsOverlay.classList.add("hovering");
+
+ if (
+ state.togglePolicy != lazy.TOGGLE_POLICIES.HIDDEN &&
+ !toggle.hasAttribute("hidden")
+ ) {
+ Services.telemetry.scalarAdd("pictureinpicture.saw_toggle", 1);
+ const hasUsedPiP = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
+ let args = {
+ firstTime: (!hasUsedPiP).toString(),
+ };
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ "saw_toggle",
+ "toggle",
+ null,
+ args
+ );
+ // only record if this is the first time seeing the toggle
+ if (!hasUsedPiP) {
+ lazy.NimbusFeatures.pictureinpicture.recordExposureEvent();
+
+ const firstSeenSeconds = Services.prefs.getIntPref(
+ TOGGLE_FIRST_SEEN_PREF,
+ 0
+ );
+
+ if (!firstSeenSeconds || firstSeenSeconds < 0) {
+ let firstTimePiPStartDate = Math.round(Date.now() / 1000);
+ this.sendAsyncMessage("PictureInPicture:SetFirstSeen", {
+ dateSeconds: firstTimePiPStartDate,
+ });
+ } else if (nimbusExperimentVariables.displayDuration) {
+ this.changeToIconIfDurationEnd(firstSeenSeconds);
+ }
+ }
+ }
+
+ // Now that we're hovering the video, we'll check to see if we're
+ // hovering the toggle too.
+ this.checkHoverToggle(toggle, event);
+ }
+
+ /**
+ * Checks if a mouse event is happening over a toggle element. If it is,
+ * sets the hovering class on it. Otherwise, it clears the hovering
+ * class.
+ *
+ * @param {Element} toggle The Picture-in-Picture toggle to check.
+ * @param {MouseEvent} event A MouseEvent to test.
+ */
+ checkHoverToggle(toggle, event) {
+ toggle.classList.toggle("hovering", this.isMouseOverToggle(toggle, event));
+ }
+
+ /**
+ * Called once it has been determined that the mouse is no longer overlapping
+ * a video that we'd previously called onMouseOverVideo with.
+ *
+ * @param {Element} video The video that the mouse left.
+ */
+ onMouseLeaveVideo(video) {
+ let state = this.docState;
+ let shadowRoot = video.openOrClosedShadowRoot;
+
+ if (shadowRoot) {
+ let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
+ let toggle = this.getToggleElement(shadowRoot);
+ controlsOverlay.classList.remove("hovering");
+ toggle.classList.remove("hovering");
+ }
+
+ state.weakOverVideo = null;
+
+ if (!this.toggleTesting) {
+ state.hideToggleDeferredTask.disarm();
+ state.mousemoveDeferredTask.disarm();
+ }
+
+ state.hideToggleDeferredTask = null;
+ }
+
+ /**
+ * Given a reference to a Picture-in-Picture toggle element, determines
+ * if a MouseEvent event is occurring within its bounds.
+ *
+ * @param {Element} toggle The Picture-in-Picture toggle.
+ * @param {MouseEvent} event A MouseEvent to test.
+ *
+ * @return {Boolean}
+ */
+ isMouseOverToggle(toggle, event) {
+ let toggleRect =
+ toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing(toggle);
+
+ // The way the toggle is currently implemented with
+ // absolute positioning, the root toggle element bounds don't actually
+ // contain all of the toggle child element bounds. Until we find a way to
+ // sort that out, we workaround the issue by having each clickable child
+ // elements of the toggle have a clicklable class, and then compute the
+ // smallest rect that contains all of their bounding rects and use that
+ // as the hitbox.
+ toggleRect = lazy.Rect.fromRect(toggleRect);
+ let clickableChildren = toggle.querySelectorAll(".clickable");
+ for (let child of clickableChildren) {
+ let childRect = lazy.Rect.fromRect(
+ child.ownerGlobal.windowUtils.getBoundsWithoutFlushing(child)
+ );
+ toggleRect.expandToContain(childRect);
+ }
+
+ // If the toggle has no dimensions, we're definitely not over it.
+ if (!toggleRect.width || !toggleRect.height) {
+ return false;
+ }
+
+ let { clientX, clientY } = event;
+
+ return (
+ clientX >= toggleRect.left &&
+ clientX <= toggleRect.right &&
+ clientY >= toggleRect.top &&
+ clientY <= toggleRect.bottom
+ );
+ }
+
+ /**
+ * Checks a contextmenu event to see if the mouse is currently over the
+ * Picture-in-Picture toggle. If so, sends a message to the parent process
+ * to open up the Picture-in-Picture toggle context menu.
+ *
+ * @param {MouseEvent} event A contextmenu event.
+ */
+ checkContextMenu(event) {
+ let video = this.getWeakOverVideo();
+ if (!video) {
+ return;
+ }
+
+ let shadowRoot = video.openOrClosedShadowRoot;
+ if (!shadowRoot) {
+ return;
+ }
+
+ let toggle = this.getToggleElement(shadowRoot);
+ if (this.isMouseOverToggle(toggle, event)) {
+ let devicePixelRatio = toggle.ownerGlobal.devicePixelRatio;
+ this.sendAsyncMessage("PictureInPicture:OpenToggleContextMenu", {
+ screenXDevPx: event.screenX * devicePixelRatio,
+ screenYDevPx: event.screenY * devicePixelRatio,
+ mozInputSource: event.mozInputSource,
+ });
+ event.stopImmediatePropagation();
+ event.preventDefault();
+ }
+ }
+
+ /**
+ * Returns the appropriate root element for the Picture-in-Picture toggle,
+ * depending on whether or not we're using the experimental toggle preference.
+ *
+ * @param {Element} shadowRoot The shadowRoot of the video element.
+ * @returns {Element} The toggle element.
+ */
+ getToggleElement(shadowRoot) {
+ return shadowRoot.getElementById("pictureInPictureToggle");
+ }
+
+ /**
+ * This is a test-only function that returns true if a video is being tracked
+ * for mouseover events after having intersected the viewport.
+ */
+ static isTracking(video) {
+ return gWeakIntersectingVideosForTesting.has(video);
+ }
+
+ /**
+ * Gets any Picture-in-Picture site-specific overrides stored in the
+ * sharedData struct, and returns them as an Array of two-element Arrays,
+ * where the first element is a MatchPattern and the second element is an
+ * object of the form { policy, disabledKeyboardControls } (where each property
+ * may be missing or undefined).
+ *
+ * @returns {Array<Array<2>>} Array of 2-element Arrays where the first element
+ * is a MatchPattern and the second element is an object with optional policy
+ * and/or disabledKeyboardControls properties.
+ */
+ static getSiteOverrides() {
+ let result = [];
+ let patterns = Services.cpmm.sharedData.get(
+ "PictureInPicture:SiteOverrides"
+ );
+ for (let pattern in patterns) {
+ let matcher = new MatchPattern(pattern);
+ result.push([matcher, patterns[pattern]]);
+ }
+ return result;
+ }
+}
+
+export class PictureInPictureChild extends JSWindowActorChild {
+ #subtitlesEnabled = false;
+ // A weak reference to this PiP window's video element
+ weakVideo = null;
+
+ // A weak reference to this PiP window's content window
+ weakPlayerContent = null;
+
+ // A reference to current WebVTT track currently displayed on the content window
+ _currentWebVTTTrack = null;
+
+ observerFunction = null;
+
+ observe(subject, topic, data) {
+ if (topic != "nsPref:changed") {
+ return;
+ }
+
+ switch (data) {
+ case "media.videocontrols.picture-in-picture.display-text-tracks.enabled": {
+ const originatingVideo = this.getWeakVideo();
+ let isTextTrackPrefEnabled = Services.prefs.getBoolPref(
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled"
+ );
+
+ // Enable or disable text track support
+ if (isTextTrackPrefEnabled) {
+ this.setupTextTracks(originatingVideo);
+ } else {
+ this.removeTextTracks(originatingVideo);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Creates a link element with a reference to the css stylesheet needed
+ * for text tracks responsive styling.
+ * @returns {Element} the link element containing text tracks stylesheet.
+ */
+ createTextTracksStyleSheet() {
+ let headStyleElement = this.document.createElement("link");
+ headStyleElement.setAttribute("rel", "stylesheet");
+ headStyleElement.setAttribute(
+ "href",
+ "chrome://global/skin/pictureinpicture/texttracks.css"
+ );
+ headStyleElement.setAttribute("type", "text/css");
+ return headStyleElement;
+ }
+
+ /**
+ * Sets up Picture-in-Picture to support displaying text tracks from WebVTT
+ * or if WebVTT isn't supported we will register the caption change mutation observer if
+ * the site wrapper exists.
+ *
+ * If the originating video supports WebVTT, try to read the
+ * active track and cues. Display any active cues on the pip window
+ * right away if applicable.
+ *
+ * @param originatingVideo {Element|null}
+ * The <video> being displayed in Picture-in-Picture mode, or null if that <video> no longer exists.
+ */
+ setupTextTracks(originatingVideo) {
+ const isWebVTTSupported = !!originatingVideo.textTracks?.length;
+
+ if (!isWebVTTSupported) {
+ this.setUpCaptionChangeListener(originatingVideo);
+ return;
+ }
+
+ // Verify active track for originating video
+ this.setActiveTextTrack(originatingVideo.textTracks);
+
+ if (!this._currentWebVTTTrack) {
+ // If WebVTT track is invalid, try using a video wrapper
+ this.setUpCaptionChangeListener(originatingVideo);
+ return;
+ }
+
+ // Listen for changes in tracks and active cues
+ originatingVideo.textTracks.addEventListener("change", this);
+ this._currentWebVTTTrack.addEventListener("cuechange", this.onCueChange);
+
+ const cues = this._currentWebVTTTrack.activeCues;
+ this.updateWebVTTTextTracksDisplay(cues);
+ }
+
+ /**
+ * Toggle the visibility of the subtitles in the PiP window
+ */
+ toggleTextTracks() {
+ let textTracks = this.document.getElementById("texttracks");
+ textTracks.style.display =
+ textTracks.style.display === "none" ? "" : "none";
+ }
+
+ /**
+ * Removes existing text tracks on the Picture in Picture window.
+ *
+ * If the originating video supports WebVTT, clear references to active
+ * tracks and cues. No longer listen for any track or cue changes.
+ *
+ * @param originatingVideo {Element|null}
+ * The <video> being displayed in Picture-in-Picture mode, or null if that <video> no longer exists.
+ */
+ removeTextTracks(originatingVideo) {
+ const isWebVTTSupported = !!originatingVideo.textTracks;
+
+ if (!isWebVTTSupported) {
+ return;
+ }
+
+ // No longer listen for changes to tracks and active cues
+ originatingVideo.textTracks.removeEventListener("change", this);
+ this._currentWebVTTTrack?.removeEventListener(
+ "cuechange",
+ this.onCueChange
+ );
+ this._currentWebVTTTrack = null;
+ this.updateWebVTTTextTracksDisplay(null);
+ }
+
+ /**
+ * Moves the text tracks container position above the pip window's video controls
+ * if their positions visually overlap. Since pip controls are within the parent
+ * process, we determine if pip video controls and text tracks visually overlap by
+ * comparing their relative positions with DOMRect.
+ *
+ * If overlap is found, set attribute "overlap-video-controls" to move text tracks
+ * and define a new relative bottom position according to pip window size and the
+ * position of video controls.
+ * @param {Object} data args needed to determine if text tracks must be moved
+ */
+ moveTextTracks(data) {
+ const {
+ isFullscreen,
+ isVideoControlsShowing,
+ playerBottomControlsDOMRect,
+ isScrubberShowing,
+ } = data;
+ let textTracks = this.document.getElementById("texttracks");
+ const originatingWindow = this.getWeakVideo().ownerGlobal;
+ const isReducedMotionEnabled = originatingWindow.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches;
+ const textTracksFontScale = this.document
+ .querySelector(":root")
+ .style.getPropertyValue("--font-scale");
+
+ if (isFullscreen || isReducedMotionEnabled) {
+ textTracks.removeAttribute("overlap-video-controls");
+ return;
+ }
+
+ if (isVideoControlsShowing) {
+ let playerVideoRect = textTracks.parentElement.getBoundingClientRect();
+ let isOverlap =
+ playerVideoRect.bottom - textTracksFontScale * playerVideoRect.height >
+ playerBottomControlsDOMRect.top;
+
+ if (isOverlap) {
+ const root = this.document.querySelector(":root");
+ if (isScrubberShowing) {
+ root.style.setProperty("--player-controls-scrubber-height", "30px");
+ } else {
+ root.style.setProperty("--player-controls-scrubber-height", "0px");
+ }
+ textTracks.setAttribute("overlap-video-controls", true);
+ } else {
+ textTracks.removeAttribute("overlap-video-controls");
+ }
+ } else {
+ textTracks.removeAttribute("overlap-video-controls");
+ }
+ }
+
+ /**
+ * Updates the text content for the container that holds and displays text tracks
+ * on the pip window.
+ * @param textTrackCues {TextTrackCueList|null}
+ * Collection of TextTrackCue objects containing text displayed, or null if there is no cue to display.
+ */
+ updateWebVTTTextTracksDisplay(textTrackCues) {
+ let pipWindowTracksContainer = this.document.getElementById("texttracks");
+ let playerVideo = this.document.getElementById("playervideo");
+ let playerVideoWindow = playerVideo.ownerGlobal;
+
+ // To prevent overlap with previous cues, clear all text from the pip window
+ pipWindowTracksContainer.replaceChildren();
+
+ if (!textTrackCues) {
+ return;
+ }
+
+ if (!this.isSubtitlesEnabled) {
+ this.isSubtitlesEnabled = true;
+ this.sendAsyncMessage("PictureInPicture:EnableSubtitlesButton");
+ }
+
+ let allCuesArray = [...textTrackCues];
+ // Re-order cues
+ this.getOrderedWebVTTCues(allCuesArray);
+ // Parse through WebVTT cue using vtt.js to ensure
+ // semantic markup like <b> and <i> tags are rendered.
+ allCuesArray.forEach(cue => {
+ let text = cue.text;
+ // Trim extra newlines and whitespaces
+ const re = /(\s*\n{2,}\s*)/g;
+ text = text.trim();
+ text = text.replace(re, "\n");
+ let cueTextNode = WebVTT.convertCueToDOMTree(playerVideoWindow, text);
+ let cueDiv = this.document.createElement("div");
+ cueDiv.appendChild(cueTextNode);
+ pipWindowTracksContainer.appendChild(cueDiv);
+ });
+ }
+
+ /**
+ * Re-orders list of multiple active cues to ensure cues are rendered in the correct order.
+ * How cues are ordered depends on the VTTCue.line value of the cue.
+ *
+ * If line is string "auto", we want to reverse the order of cues.
+ * Cues are read from top to bottom in a vtt file, but are inserted into a video from bottom to top.
+ * Ensure this order is followed.
+ *
+ * If line is an integer or percentage, we want to order cues according to numeric value.
+ * Assumptions:
+ * 1) all active cues are numeric
+ * 2) all active cues are in range 0..100
+ * 3) all actives cue are horizontal (no VTTCue.vertical)
+ * 4) all active cues with VTTCue.line integer have VTTCue.snapToLines = true
+ * 5) all active cues with VTTCue.line percentage have VTTCue.snapToLines = false
+ *
+ * vtt.jsm currently sets snapToLines to false if line is a percentage value, but
+ * cues are still ordered by line. In most cases, snapToLines is set to true by default,
+ * unless intentionally overridden.
+ * @param allCuesArray {Array<VTTCue>} array of active cues
+ */
+ getOrderedWebVTTCues(allCuesArray) {
+ if (!allCuesArray || allCuesArray.length <= 1) {
+ return;
+ }
+
+ let allCuesHaveNumericLines = allCuesArray.find(cue => cue.line !== "auto");
+
+ if (allCuesHaveNumericLines) {
+ allCuesArray.sort((cue1, cue2) => cue1.line - cue2.line);
+ } else if (allCuesArray.length >= 2) {
+ allCuesArray.reverse();
+ }
+ }
+
+ /**
+ * Returns a reference to the PiP's <video> element being displayed in Picture-in-Picture
+ * mode.
+ *
+ * @return {Element} The <video> being displayed in Picture-in-Picture mode, or null
+ * if that <video> no longer exists.
+ */
+ getWeakVideo() {
+ if (this.weakVideo) {
+ // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
+ // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
+ try {
+ return this.weakVideo.get();
+ } catch (e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns a reference to the inner window of the about:blank document that is
+ * cloning the originating <video> in the always-on-top player <xul:browser>.
+ *
+ * @return {Window} The inner window of the about:blank player <xul:browser>, or
+ * null if that window has been closed.
+ */
+ getWeakPlayerContent() {
+ if (this.weakPlayerContent) {
+ // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
+ // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
+ try {
+ return this.weakPlayerContent.get();
+ } catch (e) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if the passed video happens to be the one that this
+ * content process is running in a Picture-in-Picture window.
+ *
+ * @param {Element} video The <video> element to check.
+ *
+ * @return {Boolean}
+ */
+ inPictureInPicture(video) {
+ return this.getWeakVideo() === video;
+ }
+
+ static videoIsPlaying(video) {
+ return !!(!video.paused && !video.ended && video.readyState > 2);
+ }
+
+ static videoIsMuted(video) {
+ return this.videoWrapper.isMuted(video);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "MozStopPictureInPicture": {
+ if (event.isTrusted && event.target === this.getWeakVideo()) {
+ const reason = event.detail?.reason || "videoElRemove";
+ this.closePictureInPicture({ reason });
+ }
+ break;
+ }
+ case "pagehide": {
+ // The originating video's content document has unloaded,
+ // so close Picture-in-Picture.
+ this.closePictureInPicture({ reason: "pagehide" });
+ break;
+ }
+ case "MozDOMFullscreen:Request": {
+ this.closePictureInPicture({ reason: "fullscreen" });
+ break;
+ }
+ case "play": {
+ this.sendAsyncMessage("PictureInPicture:Playing");
+ break;
+ }
+ case "pause": {
+ this.sendAsyncMessage("PictureInPicture:Paused");
+ break;
+ }
+ case "volumechange": {
+ let video = this.getWeakVideo();
+
+ // Just double-checking that we received the event for the right
+ // video element.
+ if (video !== event.target) {
+ lazy.logConsole.error(
+ "PictureInPictureChild received volumechange for " +
+ "the wrong video!"
+ );
+ return;
+ }
+
+ if (this.constructor.videoIsMuted(video)) {
+ this.sendAsyncMessage("PictureInPicture:Muting");
+ } else {
+ this.sendAsyncMessage("PictureInPicture:Unmuting");
+ }
+ break;
+ }
+ case "resize": {
+ let video = event.target;
+ if (this.inPictureInPicture(video)) {
+ this.sendAsyncMessage("PictureInPicture:Resize", {
+ videoHeight: video.videoHeight,
+ videoWidth: video.videoWidth,
+ });
+ }
+ this.setupTextTracks(video);
+ break;
+ }
+ case "emptied": {
+ this.isSubtitlesEnabled = false;
+ if (this.emptiedTimeout) {
+ clearTimeout(this.emptiedTimeout);
+ this.emptiedTimeout = null;
+ }
+ let video = this.getWeakVideo();
+ // We may want to keep the pip window open if the video
+ // is still in DOM. But if video src is no longer defined,
+ // close Picture-in-Picture.
+ this.emptiedTimeout = setTimeout(() => {
+ if (!video || !video.src) {
+ this.closePictureInPicture({ reason: "videoElEmptied" });
+ }
+ }, EMPTIED_TIMEOUT_MS);
+ break;
+ }
+ case "change": {
+ // Clear currently stored track data (webvtt support) before reading
+ // a new track.
+ if (this._currentWebVTTTrack) {
+ this._currentWebVTTTrack.removeEventListener(
+ "cuechange",
+ this.onCueChange
+ );
+ this._currentWebVTTTrack = null;
+ }
+
+ const tracks = event.target;
+ this.setActiveTextTrack(tracks);
+ const isCurrentTrackAvailable = this._currentWebVTTTrack;
+
+ // If tracks are disabled or invalid while change occurs,
+ // remove text tracks from the pip window and stop here.
+ if (!isCurrentTrackAvailable || !tracks.length) {
+ this.updateWebVTTTextTracksDisplay(null);
+ return;
+ }
+
+ this._currentWebVTTTrack.addEventListener(
+ "cuechange",
+ this.onCueChange
+ );
+ const cues = this._currentWebVTTTrack.activeCues;
+ this.updateWebVTTTextTracksDisplay(cues);
+ break;
+ }
+ case "timeupdate":
+ case "durationchange": {
+ let video = this.getWeakVideo();
+ let currentTime = this.videoWrapper.getCurrentTime(video);
+ let duration = this.videoWrapper.getDuration(video);
+ let scrubberPosition = currentTime === 0 ? 0 : currentTime / duration;
+ let timestamp = this.videoWrapper.formatTimestamp(
+ currentTime,
+ duration
+ );
+ // There's no point in sending this message unless we have a
+ // reasonable timestamp.
+ if (timestamp !== undefined && lazy.IMPROVED_CONTROLS_ENABLED_PREF) {
+ this.sendAsyncMessage(
+ "PictureInPicture:SetTimestampAndScrubberPosition",
+ {
+ scrubberPosition,
+ timestamp,
+ }
+ );
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Tells the parent to close a pre-existing Picture-in-Picture
+ * window.
+ *
+ * @return {Promise}
+ *
+ * @resolves {undefined} Once the pre-existing Picture-in-Picture
+ * window has unloaded.
+ */
+ async closePictureInPicture({ reason }) {
+ let video = this.getWeakVideo();
+ if (video) {
+ this.untrackOriginatingVideo(video);
+ }
+ this.sendAsyncMessage("PictureInPicture:Close", {
+ reason,
+ });
+
+ let playerContent = this.getWeakPlayerContent();
+ if (playerContent) {
+ if (!playerContent.closed) {
+ await new Promise(resolve => {
+ playerContent.addEventListener("unload", resolve, {
+ once: true,
+ });
+ });
+ }
+ // Nothing should be holding a reference to the Picture-in-Picture
+ // player window content at this point, but just in case, we'll
+ // clear the weak reference directly so nothing else can get a hold
+ // of it from this angle.
+ this.weakPlayerContent = null;
+ }
+ }
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "PictureInPicture:SetupPlayer": {
+ const { videoRef } = message.data;
+ this.setupPlayer(videoRef);
+ break;
+ }
+ case "PictureInPicture:Play": {
+ this.play();
+ break;
+ }
+ case "PictureInPicture:Pause": {
+ if (message.data && message.data.reason == "pip-closed") {
+ let video = this.getWeakVideo();
+
+ // Currently in Firefox srcObjects are MediaStreams. However, by spec a srcObject
+ // can be either a MediaStream, MediaSource or Blob. In case of future changes
+ // we do not want to pause MediaStream srcObjects and we want to maintain current
+ // behavior for non-MediaStream srcObjects.
+ if (video && MediaStream.isInstance(video.srcObject)) {
+ break;
+ }
+ }
+ this.pause();
+ break;
+ }
+ case "PictureInPicture:Mute": {
+ this.mute();
+ break;
+ }
+ case "PictureInPicture:Unmute": {
+ this.unmute();
+ break;
+ }
+ case "PictureInPicture:SeekForward":
+ case "PictureInPicture:SeekBackward": {
+ let selectedTime;
+ let video = this.getWeakVideo();
+ let currentTime = this.videoWrapper.getCurrentTime(video);
+ if (message.name == "PictureInPicture:SeekBackward") {
+ selectedTime = currentTime - SEEK_TIME_SECS;
+ selectedTime = selectedTime >= 0 ? selectedTime : 0;
+ } else {
+ const maxtime = this.videoWrapper.getDuration(video);
+ selectedTime = currentTime + SEEK_TIME_SECS;
+ selectedTime = selectedTime <= maxtime ? selectedTime : maxtime;
+ }
+ this.videoWrapper.setCurrentTime(video, selectedTime);
+ break;
+ }
+ case "PictureInPicture:KeyDown": {
+ this.keyDown(message.data);
+ break;
+ }
+ case "PictureInPicture:EnterFullscreen":
+ case "PictureInPicture:ExitFullscreen": {
+ let textTracks = this.document.getElementById("texttracks");
+ if (textTracks) {
+ this.moveTextTracks(message.data);
+ }
+ break;
+ }
+ case "PictureInPicture:ShowVideoControls":
+ case "PictureInPicture:HideVideoControls": {
+ let textTracks = this.document.getElementById("texttracks");
+ if (textTracks) {
+ this.moveTextTracks(message.data);
+ }
+ break;
+ }
+ case "PictureInPicture:ToggleTextTracks": {
+ this.toggleTextTracks();
+ break;
+ }
+ case "PictureInPicture:ChangeFontSizeTextTracks": {
+ this.setTextTrackFontSize();
+ break;
+ }
+ case "PictureInPicture:SetVideoTime": {
+ const { scrubberPosition, wasPlaying } = message.data;
+ this.setVideoTime(scrubberPosition, wasPlaying);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Set the current time of the video based of the position of the scrubber
+ * @param {Number} scrubberPosition A number between 0 and 1 representing the position of the scrubber
+ */
+ setVideoTime(scrubberPosition, wasPlaying) {
+ const video = this.getWeakVideo();
+ let duration = this.videoWrapper.getDuration(video);
+ let currentTime = scrubberPosition * duration;
+ this.videoWrapper.setCurrentTime(video, currentTime, wasPlaying);
+ }
+
+ /**
+ * @returns {boolean} true if a textTrack with mode "hidden" should be treated as "showing"
+ */
+ shouldShowHiddenTextTracks() {
+ const video = this.getWeakVideo();
+ if (!video) {
+ return false;
+ }
+ const { documentURI } = video.ownerDocument;
+ if (!documentURI) {
+ return false;
+ }
+ for (let [override, { showHiddenTextTracks }] of lazy.gSiteOverrides) {
+ if (override.matches(documentURI) && showHiddenTextTracks) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Updates this._currentWebVTTTrack if an active track is found
+ * for the originating video.
+ * @param {TextTrackList} textTrackList list of text tracks
+ */
+ setActiveTextTrack(textTrackList) {
+ this._currentWebVTTTrack = null;
+
+ for (let i = 0; i < textTrackList.length; i++) {
+ let track = textTrackList[i];
+ let isCCText = track.kind === "subtitles" || track.kind === "captions";
+ let shouldShowTrack =
+ track.mode === "showing" ||
+ (track.mode === "hidden" && this.shouldShowHiddenTextTracks());
+ if (isCCText && shouldShowTrack && track.cues) {
+ this._currentWebVTTTrack = track;
+ break;
+ }
+ }
+ }
+
+ /**
+ * Set the font size on the PiP window using the current font size value from
+ * the "media.videocontrols.picture-in-picture.display-text-tracks.size" pref
+ */
+ setTextTrackFontSize() {
+ const fontSize = Services.prefs.getStringPref(
+ TEXT_TRACK_FONT_SIZE,
+ "medium"
+ );
+ const root = this.document.querySelector(":root");
+ if (fontSize === "small") {
+ root.style.setProperty("--font-scale", "0.03");
+ } else if (fontSize === "large") {
+ root.style.setProperty("--font-scale", "0.09");
+ } else {
+ root.style.setProperty("--font-scale", "0.06");
+ }
+ }
+
+ /**
+ * Keeps an eye on the originating video's document. If it ever
+ * goes away, this will cause the Picture-in-Picture window for any
+ * of its content to go away as well.
+ */
+ trackOriginatingVideo(originatingVideo) {
+ this.observerFunction = (subject, topic, data) => {
+ this.observe(subject, topic, data);
+ };
+ Services.prefs.addObserver(
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ this.observerFunction
+ );
+
+ let originatingWindow = originatingVideo.ownerGlobal;
+ if (originatingWindow) {
+ originatingWindow.addEventListener("pagehide", this);
+ originatingVideo.addEventListener("play", this);
+ originatingVideo.addEventListener("pause", this);
+ originatingVideo.addEventListener("volumechange", this);
+ originatingVideo.addEventListener("resize", this);
+ originatingVideo.addEventListener("emptied", this);
+ originatingVideo.addEventListener("timeupdate", this);
+
+ if (lazy.DISPLAY_TEXT_TRACKS_PREF) {
+ this.setupTextTracks(originatingVideo);
+ }
+
+ let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
+ chromeEventHandler.addEventListener(
+ "MozDOMFullscreen:Request",
+ this,
+ true
+ );
+ chromeEventHandler.addEventListener(
+ "MozStopPictureInPicture",
+ this,
+ true
+ );
+ }
+ }
+
+ setUpCaptionChangeListener(originatingVideo) {
+ if (this.videoWrapper) {
+ this.videoWrapper.setCaptionContainerObserver(originatingVideo, this);
+ }
+ }
+
+ /**
+ * Stops tracking the originating video's document. This should
+ * happen once the Picture-in-Picture window goes away (or is about
+ * to go away), and we no longer care about hearing when the originating
+ * window's document unloads.
+ */
+ untrackOriginatingVideo(originatingVideo) {
+ Services.prefs.removeObserver(
+ "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
+ this.observerFunction
+ );
+
+ let originatingWindow = originatingVideo.ownerGlobal;
+ if (originatingWindow) {
+ originatingWindow.removeEventListener("pagehide", this);
+ originatingVideo.removeEventListener("play", this);
+ originatingVideo.removeEventListener("pause", this);
+ originatingVideo.removeEventListener("volumechange", this);
+ originatingVideo.removeEventListener("resize", this);
+ originatingVideo.removeEventListener("emptied", this);
+ originatingVideo.removeEventListener("timeupdate", this);
+
+ if (lazy.DISPLAY_TEXT_TRACKS_PREF) {
+ this.removeTextTracks(originatingVideo);
+ }
+
+ let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
+ chromeEventHandler.removeEventListener(
+ "MozDOMFullscreen:Request",
+ this,
+ true
+ );
+ chromeEventHandler.removeEventListener(
+ "MozStopPictureInPicture",
+ this,
+ true
+ );
+ }
+ }
+
+ /**
+ * Runs in an instance of PictureInPictureChild for the
+ * player window's content, and not the originating video
+ * content. Sets up the player so that it clones the originating
+ * video. If anything goes wrong during set up, a message is
+ * sent to the parent to close the Picture-in-Picture window.
+ *
+ * @param videoRef {ContentDOMReference}
+ * A reference to the video element that a Picture-in-Picture window
+ * is being created for
+ * @return {Promise}
+ * @resolves {undefined} Once the player window has been set up
+ * properly, or a pre-existing Picture-in-Picture window has gone
+ * away due to an unexpected error.
+ */
+ async setupPlayer(videoRef) {
+ const video = await lazy.ContentDOMReference.resolve(videoRef);
+
+ this.weakVideo = Cu.getWeakReference(video);
+ let originatingVideo = this.getWeakVideo();
+ if (!originatingVideo) {
+ // If the video element has gone away before we've had a chance to set up
+ // Picture-in-Picture for it, tell the parent to close the Picture-in-Picture
+ // window.
+ await this.closePictureInPicture({ reason: "setupFailure" });
+ return;
+ }
+
+ this.videoWrapper = applyWrapper(this, originatingVideo);
+
+ let loadPromise = new Promise(resolve => {
+ this.contentWindow.addEventListener("load", resolve, {
+ once: true,
+ mozSystemGroup: true,
+ capture: true,
+ });
+ });
+ this.contentWindow.location.reload();
+ await loadPromise;
+
+ // We're committed to adding the video to this window now. Ensure we track
+ // the content window before we do so, so that the toggle actor can
+ // distinguish this new video we're creating from web-controlled ones.
+ this.weakPlayerContent = Cu.getWeakReference(this.contentWindow);
+ gPlayerContents.add(this.contentWindow);
+
+ let doc = this.document;
+ let playerVideo = doc.createElement("video");
+ playerVideo.id = "playervideo";
+ let textTracks = doc.createElement("div");
+
+ doc.body.style.overflow = "hidden";
+ doc.body.style.margin = "0";
+
+ // Force the player video to assume maximum height and width of the
+ // containing window
+ playerVideo.style.height = "100vh";
+ playerVideo.style.width = "100vw";
+ playerVideo.style.backgroundColor = "#000";
+
+ // Load text tracks container in the content process so that
+ // we can load text tracks without having to constantly
+ // access the parent process.
+ textTracks.id = "texttracks";
+ // When starting pip, player controls are expected to appear.
+ textTracks.setAttribute("overlap-video-controls", true);
+ doc.body.appendChild(playerVideo);
+ doc.body.appendChild(textTracks);
+ // Load text tracks stylesheet
+ let textTracksStyleSheet = this.createTextTracksStyleSheet();
+ doc.head.appendChild(textTracksStyleSheet);
+
+ this.setTextTrackFontSize();
+
+ originatingVideo.cloneElementVisually(playerVideo);
+
+ let shadowRoot = originatingVideo.openOrClosedShadowRoot;
+ if (originatingVideo.getTransformToViewport().a == -1) {
+ shadowRoot.firstChild.setAttribute("flipped", true);
+ playerVideo.style.transform = "scaleX(-1)";
+ }
+
+ this.onCueChange = this.onCueChange.bind(this);
+ this.trackOriginatingVideo(originatingVideo);
+
+ // A request to open PIP implies that the user intends to be interacting
+ // with the page, even if they open PIP by some means outside of the page
+ // itself (e.g., the keyboard shortcut or the page action button). So we
+ // manually record that the document has been activated via user gesture
+ // to make sure the video can be played regardless of autoplay permissions.
+ originatingVideo.ownerDocument.notifyUserGestureActivation();
+
+ this.contentWindow.addEventListener(
+ "unload",
+ () => {
+ let video = this.getWeakVideo();
+ if (video) {
+ this.untrackOriginatingVideo(video);
+ video.stopCloningElementVisually();
+ }
+ this.weakVideo = null;
+ },
+ { once: true }
+ );
+ }
+
+ play() {
+ let video = this.getWeakVideo();
+ if (video && this.videoWrapper) {
+ this.videoWrapper.play(video);
+ }
+ }
+
+ pause() {
+ let video = this.getWeakVideo();
+ if (video && this.videoWrapper) {
+ this.videoWrapper.pause(video);
+ }
+ }
+
+ mute() {
+ let video = this.getWeakVideo();
+ if (video && this.videoWrapper) {
+ this.videoWrapper.setMuted(video, true);
+ }
+ }
+
+ unmute() {
+ let video = this.getWeakVideo();
+ if (video && this.videoWrapper) {
+ this.videoWrapper.setMuted(video, false);
+ }
+ }
+
+ onCueChange(e) {
+ if (!lazy.DISPLAY_TEXT_TRACKS_PREF) {
+ this.updateWebVTTTextTracksDisplay(null);
+ } else {
+ const cues = this._currentWebVTTTrack.activeCues;
+ this.updateWebVTTTextTracksDisplay(cues);
+ }
+ }
+
+ /**
+ * This checks if a given keybinding has been disabled for the specific site
+ * currently being viewed.
+ */
+ isKeyDisabled(key) {
+ const video = this.getWeakVideo();
+ if (!video) {
+ return false;
+ }
+ const { documentURI } = video.ownerDocument;
+ if (!documentURI) {
+ return true;
+ }
+ for (let [override, { disabledKeyboardControls }] of lazy.gSiteOverrides) {
+ if (
+ disabledKeyboardControls !== undefined &&
+ override.matches(documentURI)
+ ) {
+ if (disabledKeyboardControls === lazy.KEYBOARD_CONTROLS.ALL) {
+ return true;
+ }
+ return !!(disabledKeyboardControls & key);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * This reuses the keyHandler logic in the VideoControlsWidget
+ * https://searchfox.org/mozilla-central/rev/cfd1cc461f1efe0d66c2fdc17c024a203d5a2fd8/toolkit/content/widgets/videocontrols.js#1687-1810.
+ * There are future plans to eventually combine the two implementations.
+ */
+ /* eslint-disable complexity */
+ keyDown({ altKey, shiftKey, metaKey, ctrlKey, keyCode }) {
+ let video = this.getWeakVideo();
+ if (!video) {
+ return;
+ }
+
+ var keystroke = "";
+ if (altKey) {
+ keystroke += "alt-";
+ }
+ if (shiftKey) {
+ keystroke += "shift-";
+ }
+ if (this.contentWindow.navigator.platform.startsWith("Mac")) {
+ if (metaKey) {
+ keystroke += "accel-";
+ }
+ if (ctrlKey) {
+ keystroke += "control-";
+ }
+ } else {
+ if (metaKey) {
+ keystroke += "meta-";
+ }
+ if (ctrlKey) {
+ keystroke += "accel-";
+ }
+ }
+
+ switch (keyCode) {
+ case this.contentWindow.KeyEvent.DOM_VK_UP:
+ keystroke += "upArrow";
+ break;
+ case this.contentWindow.KeyEvent.DOM_VK_DOWN:
+ keystroke += "downArrow";
+ break;
+ case this.contentWindow.KeyEvent.DOM_VK_LEFT:
+ keystroke += "leftArrow";
+ break;
+ case this.contentWindow.KeyEvent.DOM_VK_RIGHT:
+ keystroke += "rightArrow";
+ break;
+ case this.contentWindow.KeyEvent.DOM_VK_HOME:
+ keystroke += "home";
+ break;
+ case this.contentWindow.KeyEvent.DOM_VK_END:
+ keystroke += "end";
+ break;
+ case this.contentWindow.KeyEvent.DOM_VK_SPACE:
+ keystroke += "space";
+ break;
+ case this.contentWindow.KeyEvent.DOM_VK_W:
+ keystroke += "w";
+ break;
+ }
+
+ const isVideoStreaming = this.videoWrapper.isLive(video);
+ var oldval, newval;
+
+ try {
+ switch (keystroke) {
+ case "space" /* Toggle Play / Pause */:
+ if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.PLAY_PAUSE)) {
+ return;
+ }
+
+ if (
+ this.videoWrapper.getPaused(video) ||
+ this.videoWrapper.getEnded(video)
+ ) {
+ this.videoWrapper.play(video);
+ } else {
+ this.videoWrapper.pause(video);
+ }
+
+ break;
+ case "accel-w" /* Close video */:
+ if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.CLOSE)) {
+ return;
+ }
+ this.pause();
+ this.closePictureInPicture({ reason: "closePlayerShortcut" });
+ break;
+ case "downArrow" /* Volume decrease */:
+ if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME)) {
+ return;
+ }
+ oldval = this.videoWrapper.getVolume(video);
+ this.videoWrapper.setVolume(video, oldval < 0.1 ? 0 : oldval - 0.1);
+ this.videoWrapper.setMuted(video, false);
+ break;
+ case "upArrow" /* Volume increase */:
+ if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME)) {
+ return;
+ }
+ oldval = this.videoWrapper.getVolume(video);
+ this.videoWrapper.setVolume(video, oldval > 0.9 ? 1 : oldval + 0.1);
+ this.videoWrapper.setMuted(video, false);
+ break;
+ case "accel-downArrow" /* Mute */:
+ if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
+ return;
+ }
+ this.videoWrapper.setMuted(video, true);
+ break;
+ case "accel-upArrow" /* Unmute */:
+ if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
+ return;
+ }
+ this.videoWrapper.setMuted(video, false);
+ break;
+ case "leftArrow": /* Seek back 5 seconds */
+ case "accel-leftArrow" /* Seek back 10% */:
+ if (
+ this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) ||
+ (isVideoStreaming &&
+ this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK))
+ ) {
+ return;
+ }
+
+ oldval = this.videoWrapper.getCurrentTime(video);
+ if (keystroke == "leftArrow") {
+ newval = oldval - SEEK_TIME_SECS;
+ } else {
+ newval = oldval - this.videoWrapper.getDuration(video) / 10;
+ }
+ this.videoWrapper.setCurrentTime(video, newval >= 0 ? newval : 0);
+ break;
+ case "rightArrow": /* Seek forward 5 seconds */
+ case "accel-rightArrow" /* Seek forward 10% */:
+ if (
+ this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) ||
+ (isVideoStreaming &&
+ this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK))
+ ) {
+ return;
+ }
+
+ oldval = this.videoWrapper.getCurrentTime(video);
+ var maxtime = this.videoWrapper.getDuration(video);
+ if (keystroke == "rightArrow") {
+ newval = oldval + SEEK_TIME_SECS;
+ } else {
+ newval = oldval + maxtime / 10;
+ }
+ let selectedTime = newval <= maxtime ? newval : maxtime;
+ this.videoWrapper.setCurrentTime(video, selectedTime);
+ break;
+ case "home" /* Seek to beginning */:
+ if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) {
+ return;
+ }
+ if (!isVideoStreaming) {
+ this.videoWrapper.setCurrentTime(video, 0);
+ }
+ break;
+ case "end" /* Seek to end */:
+ if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) {
+ return;
+ }
+
+ let duration = this.videoWrapper.getDuration(video);
+ if (
+ !isVideoStreaming &&
+ this.videoWrapper.getCurrentTime(video) != duration
+ ) {
+ this.videoWrapper.setCurrentTime(video, duration);
+ }
+ break;
+ default:
+ }
+ } catch (e) {
+ /* ignore any exception from setting video.currentTime */
+ }
+ }
+
+ get isSubtitlesEnabled() {
+ return this.#subtitlesEnabled;
+ }
+
+ set isSubtitlesEnabled(val) {
+ if (val) {
+ Services.telemetry.recordEvent(
+ "pictureinpicture",
+ "subtitles_shown",
+ "subtitles",
+ null,
+ {
+ webVTTSubtitles: (!!this.getWeakVideo().textTracks
+ ?.length).toString(),
+ }
+ );
+ } else {
+ this.sendAsyncMessage("PictureInPicture:DisableSubtitlesButton");
+ }
+ this.#subtitlesEnabled = val;
+ }
+}
+
+/**
+ * The PictureInPictureChildVideoWrapper class handles providing a path to a script that
+ * defines a "site wrapper" for the original <video> (or other controls API provided
+ * by the site) to command it.
+ *
+ * This "site wrapper" provided to PictureInPictureChildVideoWrapper is a script file that
+ * defines a class called `PictureInPictureVideoWrapper` and exports it. These scripts can
+ * be found under "browser/extensions/pictureinpicture/video-wrappers" as part of the
+ * Picture-In-Picture addon.
+ *
+ * Site wrappers need to adhere to a specific interface to work properly with
+ * PictureInPictureChildVideoWrapper:
+ *
+ * - The "site wrapper" script must export a class called "PictureInPictureVideoWrapper"
+ * - Method names on a site wrapper class should match its caller's name
+ * (i.e: PictureInPictureChildVideoWrapper.play will only call `play` on a site-wrapper, if available)
+ */
+class PictureInPictureChildVideoWrapper {
+ #sandbox;
+ #siteWrapper;
+ #PictureInPictureChild;
+
+ /**
+ * Create a wrapper for the original <video>
+ *
+ * @param {String|null} videoWrapperScriptPath
+ * Path to a wrapper script from the Picture-in-Picture addon. If a wrapper isn't
+ * provided to the class, then we fallback on a default implementation for
+ * commanding the original <video>.
+ * @param {HTMLVideoElement} video
+ * The original <video> we want to create a wrapper class for.
+ * @param {Object} pipChild
+ * Reference to PictureInPictureChild class calling this function.
+ */
+ constructor(videoWrapperScriptPath, video, pipChild) {
+ this.#sandbox = videoWrapperScriptPath
+ ? this.#createSandbox(videoWrapperScriptPath, video)
+ : null;
+ this.#PictureInPictureChild = pipChild;
+ }
+
+ /**
+ * Handles calling methods defined on the site wrapper class to perform video
+ * controls operations on the source video. If the method doesn't exist,
+ * or if an error is thrown while calling it, use a fallback implementation.
+ *
+ * @param {String} methodInfo.name
+ * The method name to call.
+ * @param {Array} methodInfo.args
+ * Arguments to pass to the site wrapper method being called.
+ * @param {Function} methodInfo.fallback
+ * A fallback function that's invoked when a method doesn't exist on the site
+ * wrapper class or an error is thrown while calling a method
+ * @param {Function} methodInfo.validateReturnVal
+ * Validates whether or not the return value of the wrapper method is correct.
+ * If this isn't provided or if it evaluates false for a return value, then
+ * return null.
+ *
+ * @returns The expected output of the wrapper function.
+ */
+ #callWrapperMethod({ name, args = [], fallback = () => {}, validateRetVal }) {
+ try {
+ const wrappedMethod = this.#siteWrapper?.[name];
+ if (typeof wrappedMethod === "function") {
+ let retVal = wrappedMethod.call(this.#siteWrapper, ...args);
+
+ if (!validateRetVal) {
+ lazy.logConsole.error(
+ `No return value validator was provided for method ${name}(). Returning null.`
+ );
+ return null;
+ }
+
+ if (!validateRetVal(retVal)) {
+ lazy.logConsole.error(
+ `Calling method ${name}() returned an unexpected value: ${retVal}. Returning null.`
+ );
+ return null;
+ }
+
+ return retVal;
+ }
+ } catch (e) {
+ lazy.logConsole.error(
+ `There was an error while calling ${name}(): `,
+ e.message
+ );
+ }
+
+ return fallback();
+ }
+
+ /**
+ * Creates a sandbox with Xray vision to execute content code in an unprivileged
+ * context. This way, privileged code (PictureInPictureChild) can call into the
+ * sandbox to perform video controls operations on the originating video
+ * (content code) and still be protected from direct access by it.
+ *
+ * @param {String} videoWrapperScriptPath
+ * Path to a wrapper script from the Picture-in-Picture addon.
+ * @param {HTMLVideoElement} video
+ * The source video element whose window to create a sandbox for.
+ */
+ #createSandbox(videoWrapperScriptPath, video) {
+ const addonPolicy = WebExtensionPolicy.getByID(
+ "pictureinpicture@mozilla.org"
+ );
+ let wrapperScriptUrl = addonPolicy.getURL(videoWrapperScriptPath);
+ let originatingWin = video.ownerGlobal;
+ let originatingDoc = video.ownerDocument;
+
+ let sandbox = Cu.Sandbox([originatingDoc.nodePrincipal], {
+ sandboxName: "Picture-in-Picture video wrapper sandbox",
+ sandboxPrototype: originatingWin,
+ sameZoneAs: originatingWin,
+ wantXrays: false,
+ });
+
+ try {
+ Services.scriptloader.loadSubScript(wrapperScriptUrl, sandbox);
+ } catch (e) {
+ Cu.nukeSandbox(sandbox);
+ lazy.logConsole.error(
+ "Error loading wrapper script for Picture-in-Picture",
+ e
+ );
+ return null;
+ }
+
+ // The prototype of the wrapper class instantiated from the sandbox with Xray
+ // vision is `Object` and not actually `PictureInPictureVideoWrapper`. But we
+ // need to be able to access methods defined on this class to perform site-specific
+ // video control operations otherwise we fallback to a default implementation.
+ // Because of this, we need to "waive Xray vision" by adding `.wrappedObject` to the
+ // end.
+ this.#siteWrapper = new sandbox.PictureInPictureVideoWrapper(
+ video
+ ).wrappedJSObject;
+
+ return sandbox;
+ }
+
+ #isBoolean(val) {
+ return typeof val === "boolean";
+ }
+
+ #isNumber(val) {
+ return typeof val === "number";
+ }
+
+ /**
+ * Destroys the sandbox for the site wrapper class
+ */
+ destroy() {
+ if (this.#sandbox) {
+ Cu.nukeSandbox(this.#sandbox);
+ }
+ }
+
+ /**
+ * Function to display the captions on the PiP window
+ * @param text The captions to be shown on the PiP window
+ */
+ updatePiPTextTracks(text) {
+ if (!this.#PictureInPictureChild.isSubtitlesEnabled && text) {
+ this.#PictureInPictureChild.isSubtitlesEnabled = true;
+ this.#PictureInPictureChild.sendAsyncMessage(
+ "PictureInPicture:EnableSubtitlesButton"
+ );
+ }
+ let pipWindowTracksContainer =
+ this.#PictureInPictureChild.document.getElementById("texttracks");
+ pipWindowTracksContainer.textContent = text;
+ }
+
+ /* Video methods to be used for video controls from the PiP window. */
+
+ /**
+ * OVERRIDABLE - calls the play() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to handle video
+ * behaviour when a video is played.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ */
+ play(video) {
+ return this.#callWrapperMethod({
+ name: "play",
+ args: [video],
+ fallback: () => video.play(),
+ validateRetVal: retVal => retVal == null,
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the pause() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to handle video
+ * behaviour when a video is paused.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ */
+ pause(video) {
+ return this.#callWrapperMethod({
+ name: "pause",
+ args: [video],
+ fallback: () => video.pause(),
+ validateRetVal: retVal => retVal == null,
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the getPaused() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if
+ * a video is paused or not.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @returns {Boolean} Boolean value true if paused, or false if video is still playing
+ */
+ getPaused(video) {
+ return this.#callWrapperMethod({
+ name: "getPaused",
+ args: [video],
+ fallback: () => video.paused,
+ validateRetVal: retVal => this.#isBoolean(retVal),
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the getEnded() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if
+ * video playback or streaming has stopped.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @returns {Boolean} Boolean value true if the video has ended, or false if still playing
+ */
+ getEnded(video) {
+ return this.#callWrapperMethod({
+ name: "getEnded",
+ args: [video],
+ fallback: () => video.ended,
+ validateRetVal: retVal => this.#isBoolean(retVal),
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the getDuration() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to get the current
+ * duration of a video in seconds.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @returns {Number} Duration of the video in seconds
+ */
+ getDuration(video) {
+ return this.#callWrapperMethod({
+ name: "getDuration",
+ args: [video],
+ fallback: () => video.duration,
+ validateRetVal: retVal => this.#isNumber(retVal),
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the getCurrentTime() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to get the current
+ * time of a video in seconds.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @returns {Number} Current time of the video in seconds
+ */
+ getCurrentTime(video) {
+ return this.#callWrapperMethod({
+ name: "getCurrentTime",
+ args: [video],
+ fallback: () => video.currentTime,
+ validateRetVal: retVal => this.#isNumber(retVal),
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the setCurrentTime() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to set the current
+ * time of a video.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @param {Number} position
+ * The current playback time of the video
+ * @param {Boolean} wasPlaying
+ * True if the video was playing before seeking else false
+ */
+ setCurrentTime(video, position, wasPlaying) {
+ return this.#callWrapperMethod({
+ name: "setCurrentTime",
+ args: [video, position, wasPlaying],
+ fallback: () => {
+ video.currentTime = position;
+ },
+ validateRetVal: retVal => retVal == null,
+ });
+ }
+
+ /**
+ * Return hours, minutes, and seconds from seconds
+ * @param {Number} aSeconds
+ * The time in seconds
+ * @returns {String} Timestamp string
+ **/
+ timeFromSeconds(aSeconds) {
+ aSeconds = isNaN(aSeconds) ? 0 : Math.round(aSeconds);
+ let seconds = Math.floor(aSeconds % 60),
+ minutes = Math.floor((aSeconds / 60) % 60),
+ hours = Math.floor(aSeconds / 3600);
+ seconds = seconds < 10 ? "0" + seconds : seconds;
+ minutes = hours > 0 && minutes < 10 ? "0" + minutes : minutes;
+ return aSeconds < 3600
+ ? `${minutes}:${seconds}`
+ : `${hours}:${minutes}:${seconds}`;
+ }
+
+ /**
+ * Format a timestamp from current time and total duration,
+ * output as a string in the form '0:00 / 0:00'
+ * @param {Number} aCurrentTime
+ * The current time in seconds
+ * @param {Number} aDuration
+ * The total duration in seconds
+ * @returns {String} Formatted timestamp
+ **/
+ formatTimestamp(aCurrentTime, aDuration) {
+ // We can't format numbers that can't be represented as decimal digits.
+ if (!Number.isFinite(aCurrentTime) || !Number.isFinite(aDuration)) {
+ return undefined;
+ }
+
+ return `${this.timeFromSeconds(aCurrentTime)} / ${this.timeFromSeconds(
+ aDuration
+ )}`;
+ }
+
+ /**
+ * OVERRIDABLE - calls the getVolume() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to get the volume
+ * value of a video.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @returns {Number} Volume of the video between 0 (muted) and 1 (loudest)
+ */
+ getVolume(video) {
+ return this.#callWrapperMethod({
+ name: "getVolume",
+ args: [video],
+ fallback: () => video.volume,
+ validateRetVal: retVal => this.#isNumber(retVal),
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the setVolume() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to set the volume
+ * value of a video.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @param {Number} volume
+ * Value between 0 (muted) and 1 (loudest)
+ */
+ setVolume(video, volume) {
+ return this.#callWrapperMethod({
+ name: "setVolume",
+ args: [video, volume],
+ fallback: () => {
+ video.volume = volume;
+ },
+ validateRetVal: retVal => retVal == null,
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the isMuted() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to get the mute
+ * state a video.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @param {Boolean} shouldMute
+ * Boolean value true to mute the video, or false to unmute the video
+ */
+ isMuted(video) {
+ return this.#callWrapperMethod({
+ name: "isMuted",
+ args: [video],
+ fallback: () => video.muted,
+ validateRetVal: retVal => this.#isBoolean(retVal),
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the setMuted() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to mute or unmute
+ * a video.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @param {Boolean} shouldMute
+ * Boolean value true to mute the video, or false to unmute the video
+ */
+ setMuted(video, shouldMute) {
+ return this.#callWrapperMethod({
+ name: "setMuted",
+ args: [video, shouldMute],
+ fallback: () => {
+ video.muted = shouldMute;
+ },
+ validateRetVal: retVal => retVal == null,
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the setCaptionContainerObserver() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to listen for any cue changes in a
+ * video's caption container and execute a callback function responsible for updating the pip window's text tracks container whenever
+ * a cue change is triggered {@see updatePiPTextTracks()}.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @param {Function} callback
+ * The callback function to be executed when cue changes are detected
+ */
+ setCaptionContainerObserver(video, callback) {
+ return this.#callWrapperMethod({
+ name: "setCaptionContainerObserver",
+ args: [
+ video,
+ text => {
+ this.updatePiPTextTracks(text);
+ },
+ ],
+ fallback: () => {},
+ validateRetVal: retVal => retVal == null,
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the shouldHideToggle() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if the pip toggle
+ * for a video should be hidden by the site wrapper.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ * @returns {Boolean} Boolean value true if the pip toggle should be hidden by the site wrapper, or false if it should not
+ */
+ shouldHideToggle(video) {
+ return this.#callWrapperMethod({
+ name: "shouldHideToggle",
+ args: [video],
+ fallback: () => false,
+ validateRetVal: retVal => this.#isBoolean(retVal),
+ });
+ }
+
+ /**
+ * OVERRIDABLE - calls the isLive() method defined in the site wrapper script. Runs a fallback implementation
+ * if the method does not exist or if an error is thrown while calling it. This method is meant to get if the
+ * video is a live stream.
+ * @param {HTMLVideoElement} video
+ * The originating video source element
+ */
+ isLive(video) {
+ return this.#callWrapperMethod({
+ name: "isLive",
+ args: [video],
+ fallback: () => video.duration === Infinity,
+ validateRetVal: retVal => this.#isBoolean(retVal),
+ });
+ }
+}