Bug 1826682 - Re-Configure() webgpu canvases on resize. r=webgpu-reviewers,nical...
[gecko.git] / toolkit / actors / PictureInPictureChild.sys.mjs
blob6657c14e9034a6d926ac7d9afbca107ea505da33
1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 const lazy = {};
8 ChromeUtils.defineESModuleGetters(lazy, {
9   ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs",
10   DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
11   KEYBOARD_CONTROLS: "resource://gre/modules/PictureInPictureControls.sys.mjs",
12   NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
13   Rect: "resource://gre/modules/Geometry.sys.mjs",
14   TOGGLE_POLICIES: "resource://gre/modules/PictureInPictureControls.sys.mjs",
15   TOGGLE_POLICY_STRINGS:
16     "resource://gre/modules/PictureInPictureControls.sys.mjs",
17 });
19 import { WebVTT } from "resource://gre/modules/vtt.sys.mjs";
20 import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";
21 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
23 XPCOMUtils.defineLazyPreferenceGetter(
24   lazy,
25   "DISPLAY_TEXT_TRACKS_PREF",
26   "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
27   false
29 XPCOMUtils.defineLazyPreferenceGetter(
30   lazy,
31   "IMPROVED_CONTROLS_ENABLED_PREF",
32   "media.videocontrols.picture-in-picture.improved-video-controls.enabled",
33   false
35 XPCOMUtils.defineLazyPreferenceGetter(
36   lazy,
37   "MIN_VIDEO_LENGTH",
38   "media.videocontrols.picture-in-picture.video-toggle.min-video-secs",
39   45
41 XPCOMUtils.defineLazyPreferenceGetter(
42   lazy,
43   "PIP_TOGGLE_ALWAYS_SHOW",
44   "media.videocontrols.picture-in-picture.video-toggle.always-show",
45   false
47 XPCOMUtils.defineLazyPreferenceGetter(
48   lazy,
49   "PIP_URLBAR_BUTTON",
50   "media.videocontrols.picture-in-picture.urlbar-button.enabled",
51   false
54 const PIP_ENABLED_PREF = "media.videocontrols.picture-in-picture.enabled";
55 const TOGGLE_ENABLED_PREF =
56   "media.videocontrols.picture-in-picture.video-toggle.enabled";
57 const TOGGLE_FIRST_SEEN_PREF =
58   "media.videocontrols.picture-in-picture.video-toggle.first-seen-secs";
59 const TOGGLE_FIRST_TIME_DURATION_DAYS = 28;
60 const TOGGLE_HAS_USED_PREF =
61   "media.videocontrols.picture-in-picture.video-toggle.has-used";
62 const TOGGLE_TESTING_PREF =
63   "media.videocontrols.picture-in-picture.video-toggle.testing";
64 const TOGGLE_VISIBILITY_THRESHOLD_PREF =
65   "media.videocontrols.picture-in-picture.video-toggle.visibility-threshold";
66 const TEXT_TRACK_FONT_SIZE =
67   "media.videocontrols.picture-in-picture.display-text-tracks.size";
69 const MOUSEMOVE_PROCESSING_DELAY_MS = 50;
70 const TOGGLE_HIDING_TIMEOUT_MS = 3000;
71 // If you change this, also change VideoControlsWidget.SEEK_TIME_SECS:
72 const SEEK_TIME_SECS = 5;
73 const EMPTIED_TIMEOUT_MS = 1000;
75 // The ToggleChild does not want to capture events from the PiP
76 // windows themselves. This set contains all currently open PiP
77 // players' content windows
78 var gPlayerContents = new WeakSet();
80 // To make it easier to write tests, we have a process-global
81 // WeakSet of all <video> elements that are being tracked for
82 // mouseover
83 var gWeakIntersectingVideosForTesting = new WeakSet();
85 // Overrides are expected to stay constant for the lifetime of a
86 // content process, so we set this as a lazy process global.
87 // See PictureInPictureToggleChild.getSiteOverrides for a
88 // sense of what the return types are.
89 XPCOMUtils.defineLazyGetter(lazy, "gSiteOverrides", () => {
90   return PictureInPictureToggleChild.getSiteOverrides();
91 });
93 XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
94   return console.createInstance({
95     prefix: "PictureInPictureChild",
96     maxLogLevel: Services.prefs.getBoolPref(
97       "media.videocontrols.picture-in-picture.log",
98       false
99     )
100       ? "Debug"
101       : "Error",
102   });
106  * Creates and returns an instance of the PictureInPictureChildVideoWrapper class responsible
107  * for applying site-specific wrapper methods around the original video.
109  * The Picture-In-Picture add-on can use this to provide site-specific wrappers for
110  * sites that require special massaging to control.
111  * @param {Object} pipChild reference to PictureInPictureChild class calling this function
112  * @param {Element} originatingVideo
113  *   The <video> element to wrap.
114  * @returns {PictureInPictureChildVideoWrapper} instance of PictureInPictureChildVideoWrapper
115  */
116 function applyWrapper(pipChild, originatingVideo) {
117   let originatingDoc = originatingVideo.ownerDocument;
118   let originatingDocumentURI = originatingDoc.documentURI;
120   let overrides = lazy.gSiteOverrides.find(([matcher]) => {
121     return matcher.matches(originatingDocumentURI);
122   });
124   // gSiteOverrides is a list of tuples where the first element is the MatchPattern
125   // for a supported site and the second is the actual overrides object for it.
126   let wrapperPath = overrides ? overrides[1].videoWrapperScriptPath : null;
127   return new PictureInPictureChildVideoWrapper(
128     wrapperPath,
129     originatingVideo,
130     pipChild
131   );
134 export class PictureInPictureLauncherChild extends JSWindowActorChild {
135   handleEvent(event) {
136     switch (event.type) {
137       case "MozTogglePictureInPicture": {
138         if (event.isTrusted) {
139           this.togglePictureInPicture({
140             video: event.target,
141             reason: event.detail,
142           });
143         }
144         break;
145       }
146     }
147   }
149   receiveMessage(message) {
150     switch (message.name) {
151       case "PictureInPicture:KeyToggle": {
152         this.keyToggle();
153         break;
154       }
155     }
156   }
158   /**
159    * Tells the parent to open a Picture-in-Picture window hosting
160    * a clone of the passed video. If we know about a pre-existing
161    * Picture-in-Picture window existing, this tells the parent to
162    * close it before opening the new one.
163    *
164    * @param {Object} pipObject An object containing the video and reason
165    * for toggling the PiP video
166    *
167    * @return {Promise}
168    * @resolves {undefined} Once the new Picture-in-Picture window
169    * has been requested.
170    */
171   async togglePictureInPicture(pipObject) {
172     let { video, reason } = pipObject;
173     if (video.isCloningElementVisually) {
174       // The only way we could have entered here for the same video is if
175       // we are toggling via the context menu or via the urlbar button,
176       // since we hide the inline Picture-in-Picture toggle when a video
177       // is being displayed in Picture-in-Picture. Turn off PiP in this case
178       const stopPipEvent = new this.contentWindow.CustomEvent(
179         "MozStopPictureInPicture",
180         {
181           bubbles: true,
182           detail: reason,
183         }
184       );
185       video.dispatchEvent(stopPipEvent);
186       return;
187     }
189     if (!PictureInPictureChild.videoWrapper) {
190       PictureInPictureChild.videoWrapper = applyWrapper(
191         PictureInPictureChild,
192         video
193       );
194     }
196     let timestamp = undefined;
197     let scrubberPosition = undefined;
199     if (lazy.IMPROVED_CONTROLS_ENABLED_PREF) {
200       timestamp = PictureInPictureChild.videoWrapper.formatTimestamp(
201         PictureInPictureChild.videoWrapper.getCurrentTime(video),
202         PictureInPictureChild.videoWrapper.getDuration(video)
203       );
205       // Scrubber is hidden if undefined, so only set it to something else
206       // if the timestamp is not undefined.
207       scrubberPosition =
208         timestamp === undefined
209           ? undefined
210           : PictureInPictureChild.videoWrapper.getCurrentTime(video) /
211             PictureInPictureChild.videoWrapper.getDuration(video);
212     }
214     // All other requests to toggle PiP should open a new PiP
215     // window
216     const videoRef = lazy.ContentDOMReference.get(video);
217     this.sendAsyncMessage("PictureInPicture:Request", {
218       isMuted: PictureInPictureChild.videoIsMuted(video),
219       playing: PictureInPictureChild.videoIsPlaying(video),
220       videoHeight: video.videoHeight,
221       videoWidth: video.videoWidth,
222       videoRef,
223       ccEnabled: lazy.DISPLAY_TEXT_TRACKS_PREF,
224       webVTTSubtitles: !!video.textTracks?.length,
225       scrubberPosition,
226       timestamp,
227     });
228   }
230   /**
231    * The keyboard was used to attempt to open Picture-in-Picture. If a video is focused,
232    * select that video. Otherwise find the first playing video, or if none, the largest
233    * dimension video. We suspect this heuristic will handle most cases, though we
234    * might refine this later on. Note that we assume that this method will only be
235    * called for the focused document.
236    */
237   keyToggle() {
238     let doc = this.document;
239     if (doc) {
240       let video = doc.activeElement;
241       if (!HTMLVideoElement.isInstance(video)) {
242         let listOfVideos = [...doc.querySelectorAll("video")].filter(
243           video => !isNaN(video.duration)
244         );
245         // Get the first non-paused video, otherwise the longest video. This
246         // fallback is designed to skip over "preview"-style videos on sidebars.
247         video =
248           listOfVideos.filter(v => !v.paused)[0] ||
249           listOfVideos.sort((a, b) => b.duration - a.duration)[0];
250       }
251       if (video) {
252         this.togglePictureInPicture({ video });
253       }
254     }
255   }
259  * The PictureInPictureToggleChild is responsible for displaying the overlaid
260  * Picture-in-Picture toggle over top of <video> elements that the mouse is
261  * hovering.
262  */
263 export class PictureInPictureToggleChild extends JSWindowActorChild {
264   constructor() {
265     super();
266     // We need to maintain some state about various things related to the
267     // Picture-in-Picture toggles - however, for now, the same
268     // PictureInPictureToggleChild might be re-used for different documents.
269     // We keep the state stashed inside of this WeakMap, keyed on the document
270     // itself.
271     this.weakDocStates = new WeakMap();
272     this.toggleEnabled =
273       Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF) &&
274       Services.prefs.getBoolPref(PIP_ENABLED_PREF);
275     this.toggleTesting = Services.prefs.getBoolPref(TOGGLE_TESTING_PREF, false);
277     // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's
278     // directly, so we create a new function here instead to act as our
279     // nsIObserver, which forwards the notification to the observe method.
280     this.observerFunction = (subject, topic, data) => {
281       this.observe(subject, topic, data);
282     };
283     Services.prefs.addObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
284     Services.prefs.addObserver(PIP_ENABLED_PREF, this.observerFunction);
285     Services.prefs.addObserver(TOGGLE_FIRST_SEEN_PREF, this.observerFunction);
286     Services.cpmm.sharedData.addEventListener("change", this);
288     this.eligiblePipVideos = new WeakSet();
289     this.trackingVideos = new WeakSet();
290   }
292   receiveMessage(message) {
293     switch (message.name) {
294       case "PictureInPicture:UrlbarToggle": {
295         this.urlbarToggle();
296         break;
297       }
298     }
299     return null;
300   }
302   didDestroy() {
303     this.stopTrackingMouseOverVideos();
304     Services.prefs.removeObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
305     Services.prefs.removeObserver(PIP_ENABLED_PREF, this.observerFunction);
306     Services.prefs.removeObserver(
307       TOGGLE_FIRST_SEEN_PREF,
308       this.observerFunction
309     );
310     Services.cpmm.sharedData.removeEventListener("change", this);
312     // remove the observer on the <video> element
313     let state = this.docState;
314     if (state?.intersectionObserver) {
315       state.intersectionObserver.disconnect();
316     }
318     // ensure the sandbox created by the video is destroyed
319     this.videoWrapper?.destroy();
320     this.videoWrapper = null;
322     for (let video of ChromeUtils.nondeterministicGetWeakSetKeys(
323       this.eligiblePipVideos
324     )) {
325       video.removeEventListener("emptied", this);
326       video.removeEventListener("loadedmetadata", this);
327       video.removeEventListener("durationchange", this);
328     }
330     for (let video of ChromeUtils.nondeterministicGetWeakSetKeys(
331       this.trackingVideos
332     )) {
333       video.removeEventListener("emptied", this);
334       video.removeEventListener("loadedmetadata", this);
335       video.removeEventListener("durationchange", this);
336     }
338     // ensure we don't access the state
339     this.isDestroyed = true;
340   }
342   observe(subject, topic, data) {
343     if (topic != "nsPref:changed") {
344       return;
345     }
347     this.toggleEnabled =
348       Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF) &&
349       Services.prefs.getBoolPref(PIP_ENABLED_PREF);
351     if (this.toggleEnabled) {
352       // We have enabled the Picture-in-Picture toggle, so we need to make
353       // sure we register all of the videos that might already be on the page.
354       this.contentWindow.requestIdleCallback(() => {
355         let videos = this.document.querySelectorAll("video");
356         for (let video of videos) {
357           this.registerVideo(video);
358         }
359       });
360     }
362     switch (data) {
363       case TOGGLE_FIRST_SEEN_PREF:
364         const firstSeenSeconds = Services.prefs.getIntPref(
365           TOGGLE_FIRST_SEEN_PREF
366         );
367         if (!firstSeenSeconds || firstSeenSeconds < 0) {
368           return;
369         }
370         this.changeToIconIfDurationEnd(firstSeenSeconds);
371         break;
372     }
373   }
375   /**
376    * Returns the state for the current document referred to via
377    * this.document. If no such state exists, creates it, stores it
378    * and returns it.
379    */
380   get docState() {
381     if (this.isDestroyed || !this.document) {
382       return false;
383     }
385     let state = this.weakDocStates.get(this.document);
387     let visibilityThresholdPref = Services.prefs.getFloatPref(
388       TOGGLE_VISIBILITY_THRESHOLD_PREF,
389       "1.0"
390     );
392     if (!state) {
393       state = {
394         // A reference to the IntersectionObserver that's monitoring for videos
395         // to become visible.
396         intersectionObserver: null,
397         // A WeakSet of videos that are supposedly visible, according to the
398         // IntersectionObserver.
399         weakVisibleVideos: new WeakSet(),
400         // The number of videos that are supposedly visible, according to the
401         // IntersectionObserver
402         visibleVideosCount: 0,
403         // The DeferredTask that we'll arm every time a mousemove event occurs
404         // on a page where we have one or more visible videos.
405         mousemoveDeferredTask: null,
406         // A weak reference to the last video we displayed the toggle over.
407         weakOverVideo: null,
408         // True if the user is in the midst of clicking the toggle.
409         isClickingToggle: false,
410         // Set to the original target element on pointerdown if the user is clicking
411         // the toggle - this way, we can determine if a "click" event will need to be
412         // suppressed ("click" events don't fire if a "mouseup" occurs on a different
413         // element from the "pointerdown" / "mousedown" event).
414         clickedElement: null,
415         // This is a DeferredTask to hide the toggle after a period of mouse
416         // inactivity.
417         hideToggleDeferredTask: null,
418         // If we reach a point where we're tracking videos for mouse movements,
419         // then this will be true. If there are no videos worth tracking, then
420         // this is false.
421         isTrackingVideos: false,
422         togglePolicy: lazy.TOGGLE_POLICIES.DEFAULT,
423         toggleVisibilityThreshold: visibilityThresholdPref,
424         // The documentURI that has been checked with toggle policies and
425         // visibility thresholds for this document. Note that the documentURI
426         // might change for a document via the history API, so we remember
427         // the last checked documentURI to determine if we need to check again.
428         checkedPolicyDocumentURI: null,
429       };
430       this.weakDocStates.set(this.document, state);
431     }
433     return state;
434   }
436   /**
437    * Returns the video that the user was last hovering with the mouse if it
438    * still exists.
439    *
440    * @return {Element} the <video> element that the user was last hovering,
441    * or null if there was no such <video>, or the <video> no longer exists.
442    */
443   getWeakOverVideo() {
444     let { weakOverVideo } = this.docState;
445     if (weakOverVideo) {
446       // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
447       // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
448       try {
449         return weakOverVideo.get();
450       } catch (e) {
451         return null;
452       }
453     }
454     return null;
455   }
457   handleEvent(event) {
458     if (!event.isTrusted) {
459       // We don't care about synthesized events that might be coming from
460       // content JS.
461       return;
462     }
464     // Don't capture events from Picture-in-Picture content windows
465     if (gPlayerContents.has(this.contentWindow)) {
466       return;
467     }
469     switch (event.type) {
470       case "touchstart": {
471         // Even if this is a touch event, there may be subsequent click events.
472         // Suppress those events after selecting the toggle to prevent playback changes
473         // when opening the Picture-in-Picture window.
474         if (this.docState.isClickingToggle) {
475           event.stopImmediatePropagation();
476           event.preventDefault();
477         }
478         break;
479       }
480       case "change": {
481         const { changedKeys } = event;
482         if (changedKeys.includes("PictureInPicture:SiteOverrides")) {
483           // For now we only update our cache if the site overrides change.
484           // the user will need to refresh the page for changes to apply.
485           try {
486             lazy.gSiteOverrides = PictureInPictureToggleChild.getSiteOverrides();
487           } catch (e) {
488             // Ignore resulting TypeError if gSiteOverrides is still unloaded
489             if (!(e instanceof TypeError)) {
490               throw e;
491             }
492           }
493         }
494         break;
495       }
496       case "UAWidgetSetupOrChange": {
497         if (
498           this.toggleEnabled &&
499           this.contentWindow.HTMLVideoElement.isInstance(event.target) &&
500           event.target.ownerDocument == this.document
501         ) {
502           this.registerVideo(event.target);
503         }
504         break;
505       }
506       case "contextmenu": {
507         if (this.toggleEnabled) {
508           this.checkContextMenu(event);
509         }
510         break;
511       }
512       case "mouseout": {
513         this.onMouseOut(event);
514         break;
515       }
516       case "click":
517         if (event.detail == 0) {
518           let shadowRoot = event.originalTarget.containingShadowRoot;
519           let toggle = this.getToggleElement(shadowRoot);
520           if (event.originalTarget == toggle) {
521             this.startPictureInPicture(event, shadowRoot.host, toggle);
522             return;
523           }
524         }
525       // fall through
526       case "mousedown":
527       case "pointerup":
528       case "mouseup": {
529         this.onMouseButtonEvent(event);
530         break;
531       }
532       case "pointerdown": {
533         this.onPointerDown(event);
534         break;
535       }
536       case "mousemove": {
537         this.onMouseMove(event);
538         break;
539       }
540       case "pageshow": {
541         this.onPageShow(event);
542         break;
543       }
544       case "pagehide": {
545         this.onPageHide(event);
546         break;
547       }
548       case "durationchange":
549       // Intentional fall-through
550       case "emptied":
551       // Intentional fall-through
552       case "loadedmetadata": {
553         this.updatePipVideoEligibility(event.target);
554         break;
555       }
556     }
557   }
559   /**
560    * Adds a <video> to the IntersectionObserver so that we know when it becomes
561    * visible.
562    *
563    * @param {Element} video The <video> element to register.
564    */
565   registerVideo(video) {
566     let state = this.docState;
567     if (!state.intersectionObserver) {
568       let fn = this.onIntersection.bind(this);
569       state.intersectionObserver = new this.contentWindow.IntersectionObserver(
570         fn,
571         {
572           threshold: [0.0, 0.5],
573         }
574       );
575     }
577     state.intersectionObserver.observe(video);
579     if (!lazy.PIP_URLBAR_BUTTON) {
580       return;
581     }
583     video.addEventListener("emptied", this);
584     video.addEventListener("loadedmetadata", this);
585     video.addEventListener("durationchange", this);
587     this.trackingVideos.add(video);
589     this.updatePipVideoEligibility(video);
590   }
592   updatePipVideoEligibility(video) {
593     let isEligible = this.isVideoPiPEligible(video);
594     if (isEligible) {
595       if (!this.eligiblePipVideos.has(video)) {
596         this.eligiblePipVideos.add(video);
598         let mutationObserver = new this.contentWindow.MutationObserver(
599           mutationList => {
600             this.handleEligiblePipVideoMutation(mutationList);
601           }
602         );
603         mutationObserver.observe(video.parentElement, { childList: true });
604       }
605     } else if (this.eligiblePipVideos.has(video)) {
606       this.eligiblePipVideos.delete(video);
607     }
609     this.sendAsyncMessage("PictureInPicture:UpdateEligiblePipVideoCount", {
610       count: ChromeUtils.nondeterministicGetWeakSetKeys(this.eligiblePipVideos)
611         .length,
612     });
613   }
615   handleEligiblePipVideoMutation(mutationList) {
616     for (let mutationRecord of mutationList) {
617       let video = mutationRecord.removedNodes[0];
618       this.eligiblePipVideos.delete(video);
619     }
621     this.sendAsyncMessage("PictureInPicture:UpdateEligiblePipVideoCount", {
622       count: ChromeUtils.nondeterministicGetWeakSetKeys(this.eligiblePipVideos)
623         .length,
624     });
625   }
627   urlbarToggle() {
628     let video = ChromeUtils.nondeterministicGetWeakSetKeys(
629       this.eligiblePipVideos
630     )[0];
631     if (video) {
632       if (!video.isCloningElementVisually) {
633         let args = {
634           firstTimeToggle: (!Services.prefs.getBoolPref(
635             "media.videocontrols.picture-in-picture.video-toggle.has-used"
636           )).toString(),
637         };
638         Services.telemetry.recordEvent(
639           "pictureinpicture",
640           "opened_method",
641           "urlBar",
642           null,
643           args
644         );
645       }
646       let pipEvent = new this.contentWindow.CustomEvent(
647         "MozTogglePictureInPicture",
648         {
649           bubbles: true,
650           detail: { reason: "urlBar" },
651         }
652       );
653       video.dispatchEvent(pipEvent);
654     }
655   }
657   isVideoPiPEligible(video) {
658     if (lazy.PIP_TOGGLE_ALWAYS_SHOW) {
659       return true;
660     }
662     if (isNaN(video.duration) || video.duration < lazy.MIN_VIDEO_LENGTH) {
663       return false;
664     }
666     const MIN_VIDEO_DIMENSION = 140; // pixels
667     if (
668       video.clientWidth < MIN_VIDEO_DIMENSION ||
669       video.clientHeight < MIN_VIDEO_DIMENSION
670     ) {
671       return false;
672     }
674     return true;
675   }
677   /**
678    * Changes from the first-time toggle to the icon toggle if the Nimbus variable `displayDuration`'s
679    * end date is reached when hovering over a video. The end date is calculated according to the timestamp
680    * indicating when the PiP toggle was first seen.
681    * @param {Number} firstSeenStartSeconds the timestamp in seconds indicating when the PiP toggle was first seen
682    */
683   changeToIconIfDurationEnd(firstSeenStartSeconds) {
684     const {
685       displayDuration,
686     } = lazy.NimbusFeatures.pictureinpicture.getAllVariables({
687       defaultValues: {
688         displayDuration: TOGGLE_FIRST_TIME_DURATION_DAYS,
689       },
690     });
691     if (!displayDuration || displayDuration < 0) {
692       return;
693     }
695     let daysInSeconds = displayDuration * 24 * 60 * 60;
696     let firstSeenEndSeconds = daysInSeconds + firstSeenStartSeconds;
697     let currentDateSeconds = Math.round(Date.now() / 1000);
699     lazy.logConsole.debug(
700       "Toggle duration experiment - first time toggle seen on:",
701       new Date(firstSeenStartSeconds * 1000).toLocaleDateString()
702     );
703     lazy.logConsole.debug(
704       "Toggle duration experiment - first time toggle will change on:",
705       new Date(firstSeenEndSeconds * 1000).toLocaleDateString()
706     );
707     lazy.logConsole.debug(
708       "Toggle duration experiment - current date:",
709       new Date(currentDateSeconds * 1000).toLocaleDateString()
710     );
712     if (currentDateSeconds >= firstSeenEndSeconds) {
713       this.sendAsyncMessage("PictureInPicture:SetHasUsed", {
714         hasUsed: true,
715       });
716     }
717   }
719   /**
720    * Called by the IntersectionObserver callback once a video becomes visible.
721    * This adds some fine-grained checking to ensure that a sufficient amount of
722    * the video is visible before we consider showing the toggles on it. For now,
723    * that means that the entirety of the video must be in the viewport.
724    *
725    * @param {IntersectionEntry} intersectionEntry An IntersectionEntry passed to
726    * the IntersectionObserver callback.
727    * @return bool Whether or not we should start tracking mousemove events for
728    * this registered video.
729    */
730   worthTracking(intersectionEntry) {
731     return intersectionEntry.isIntersecting;
732   }
734   /**
735    * Called by the IntersectionObserver once a video crosses one of the
736    * thresholds dictated by the IntersectionObserver configuration.
737    *
738    * @param {Array<IntersectionEntry>} A collection of one or more
739    * IntersectionEntry's for <video> elements that might have entered or exited
740    * the viewport.
741    */
742   onIntersection(entries) {
743     // The IntersectionObserver will also fire when a previously intersecting
744     // element is removed from the DOM. We know, however, that the node is
745     // still alive and referrable from the WeakSet because the
746     // IntersectionObserverEntry holds a strong reference to the video.
747     let state = this.docState;
748     if (!state) {
749       return;
750     }
751     let oldVisibleVideosCount = state.visibleVideosCount;
752     for (let entry of entries) {
753       let video = entry.target;
754       if (this.worthTracking(entry)) {
755         if (!state.weakVisibleVideos.has(video)) {
756           state.weakVisibleVideos.add(video);
757           state.visibleVideosCount++;
758           if (this.toggleTesting) {
759             gWeakIntersectingVideosForTesting.add(video);
760           }
761         }
762       } else if (state.weakVisibleVideos.has(video)) {
763         state.weakVisibleVideos.delete(video);
764         state.visibleVideosCount--;
765         if (this.toggleTesting) {
766           gWeakIntersectingVideosForTesting.delete(video);
767         }
768       }
769     }
771     // For testing, especially in debug or asan builds, we might not
772     // run this idle callback within an acceptable time. While we're
773     // testing, we'll bypass the idle callback performance optimization
774     // and run our callbacks as soon as possible during the next idle
775     // period.
776     if (!oldVisibleVideosCount && state.visibleVideosCount) {
777       if (this.toggleTesting || !this.contentWindow) {
778         this.beginTrackingMouseOverVideos();
779       } else {
780         this.contentWindow.requestIdleCallback(() => {
781           this.beginTrackingMouseOverVideos();
782         });
783       }
784     } else if (oldVisibleVideosCount && !state.visibleVideosCount) {
785       if (this.toggleTesting || !this.contentWindow) {
786         this.stopTrackingMouseOverVideos();
787       } else {
788         this.contentWindow.requestIdleCallback(() => {
789           this.stopTrackingMouseOverVideos();
790         });
791       }
792     }
793   }
795   addMouseButtonListeners() {
796     // We want to try to cancel the mouse events from continuing
797     // on into content if the user has clicked on the toggle, so
798     // we don't use the mozSystemGroup here, and add the listener
799     // to the parent target of the window, which in this case,
800     // is the windowRoot. Since this event listener is attached to
801     // part of the outer window, we need to also remove it in a
802     // pagehide event listener in the event that the page unloads
803     // before stopTrackingMouseOverVideos fires.
804     this.contentWindow.windowRoot.addEventListener("pointerdown", this, {
805       capture: true,
806     });
807     this.contentWindow.windowRoot.addEventListener("mousedown", this, {
808       capture: true,
809     });
810     this.contentWindow.windowRoot.addEventListener("mouseup", this, {
811       capture: true,
812     });
813     this.contentWindow.windowRoot.addEventListener("pointerup", this, {
814       capture: true,
815     });
816     this.contentWindow.windowRoot.addEventListener("click", this, {
817       capture: true,
818     });
819     this.contentWindow.windowRoot.addEventListener("mouseout", this, {
820       capture: true,
821     });
822     this.contentWindow.windowRoot.addEventListener("touchstart", this, {
823       capture: true,
824     });
825   }
827   removeMouseButtonListeners() {
828     // This can be null when closing the tab, but the event
829     // listeners should be removed in that case already.
830     if (!this.contentWindow || !this.contentWindow.windowRoot) {
831       return;
832     }
834     this.contentWindow.windowRoot.removeEventListener("pointerdown", this, {
835       capture: true,
836     });
837     this.contentWindow.windowRoot.removeEventListener("mousedown", this, {
838       capture: true,
839     });
840     this.contentWindow.windowRoot.removeEventListener("mouseup", this, {
841       capture: true,
842     });
843     this.contentWindow.windowRoot.removeEventListener("pointerup", this, {
844       capture: true,
845     });
846     this.contentWindow.windowRoot.removeEventListener("click", this, {
847       capture: true,
848     });
849     this.contentWindow.windowRoot.removeEventListener("mouseout", this, {
850       capture: true,
851     });
852     this.contentWindow.windowRoot.removeEventListener("touchstart", this, {
853       capture: true,
854     });
855   }
857   /**
858    * One of the challenges of displaying this toggle is that many sites put
859    * things over top of <video> elements, like custom controls, or images, or
860    * all manner of things that might intercept mouseevents that would normally
861    * fire directly on the <video>. In order to properly detect when the mouse
862    * is over top of one of the <video> elements in this situation, we currently
863    * add a mousemove event handler to the entire document, and stash the most
864    * recent mousemove that fires. At periodic intervals, that stashed mousemove
865    * event is checked to see if it's hovering over one of our registered
866    * <video> elements.
867    *
868    * This sort of thing will not be necessary once bug 1539652 is fixed.
869    */
870   beginTrackingMouseOverVideos() {
871     let state = this.docState;
872     if (!state.mousemoveDeferredTask) {
873       state.mousemoveDeferredTask = new lazy.DeferredTask(() => {
874         this.checkLastMouseMove();
875       }, MOUSEMOVE_PROCESSING_DELAY_MS);
876     }
877     this.document.addEventListener("mousemove", this, {
878       mozSystemGroup: true,
879       capture: true,
880     });
881     this.contentWindow.addEventListener("pageshow", this, {
882       mozSystemGroup: true,
883     });
884     this.contentWindow.addEventListener("pagehide", this, {
885       mozSystemGroup: true,
886     });
887     this.addMouseButtonListeners();
888     state.isTrackingVideos = true;
889   }
891   /**
892    * If we no longer have any interesting videos in the viewport, we deregister
893    * the mousemove and click listeners, and also remove any toggles that might
894    * be on the page still.
895    */
896   stopTrackingMouseOverVideos() {
897     let state = this.docState;
898     // We initialize `mousemoveDeferredTask` in `beginTrackingMouseOverVideos`.
899     // If it doesn't exist, that can't have happened. Nothing else ever sets
900     // this value (though we arm/disarm in various places). So we don't need
901     // to do anything else here and can return early.
902     if (!state.mousemoveDeferredTask) {
903       return;
904     }
905     state.mousemoveDeferredTask.disarm();
906     this.document.removeEventListener("mousemove", this, {
907       mozSystemGroup: true,
908       capture: true,
909     });
910     if (this.contentWindow) {
911       this.contentWindow.removeEventListener("pageshow", this, {
912         mozSystemGroup: true,
913       });
914       this.contentWindow.removeEventListener("pagehide", this, {
915         mozSystemGroup: true,
916       });
917     }
918     this.removeMouseButtonListeners();
919     let oldOverVideo = this.getWeakOverVideo();
920     if (oldOverVideo) {
921       this.onMouseLeaveVideo(oldOverVideo);
922     }
923     state.isTrackingVideos = false;
924   }
926   /**
927    * This pageshow event handler will get called if and when we complete a tab
928    * tear out or in. If we happened to be tracking videos before the tear
929    * occurred, we re-add the mouse event listeners so that they're attached to
930    * the right WindowRoot.
931    *
932    * @param {Event} event The pageshow event fired when completing a tab tear
933    * out or in.
934    */
935   onPageShow(event) {
936     let state = this.docState;
937     if (state.isTrackingVideos) {
938       this.addMouseButtonListeners();
939     }
940   }
942   /**
943    * This pagehide event handler will get called if and when we start a tab
944    * tear out or in. If we happened to be tracking videos before the tear
945    * occurred, we remove the mouse event listeners. We'll re-add them when the
946    * pageshow event fires.
947    *
948    * @param {Event} event The pagehide event fired when starting a tab tear
949    * out or in.
950    */
951   onPageHide(event) {
952     let state = this.docState;
953     if (state.isTrackingVideos) {
954       this.removeMouseButtonListeners();
955     }
956   }
958   /**
959    * If we're tracking <video> elements, this pointerdown event handler is run anytime
960    * a pointerdown occurs on the document. This function is responsible for checking
961    * if the user clicked on the Picture-in-Picture toggle. It does this by first
962    * checking if the video is visible beneath the point that was clicked. Then
963    * it tests whether or not the pointerdown occurred within the rectangle of the
964    * toggle. If so, the event's propagation is stopped, and Picture-in-Picture is
965    * triggered.
966    *
967    * @param {Event} event The mousemove event.
968    */
969   onPointerDown(event) {
970     // The toggle ignores non-primary mouse clicks.
971     if (event.button != 0) {
972       return;
973     }
975     let video = this.getWeakOverVideo();
976     if (!video) {
977       return;
978     }
980     let shadowRoot = video.openOrClosedShadowRoot;
981     if (!shadowRoot) {
982       return;
983     }
985     let state = this.docState;
987     let overVideo = (() => {
988       let { clientX, clientY } = event;
989       let winUtils = this.contentWindow.windowUtils;
990       // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
991       // since document.elementsFromPoint always flushes layout. The 1's in that
992       // function call are for the size of the rect that we want, which is 1x1.
993       //
994       // We pass the aOnlyVisible boolean argument to check that the video isn't
995       // occluded by anything visible at the point of mousedown. If it is, we'll
996       // ignore the mousedown.
997       let elements = winUtils.nodesFromRect(
998         clientX,
999         clientY,
1000         1,
1001         1,
1002         1,
1003         1,
1004         true,
1005         false,
1006         /* aOnlyVisible = */ true,
1007         state.toggleVisibilityThreshold
1008       );
1010       for (let element of elements) {
1011         if (element == video || element.containingShadowRoot == shadowRoot) {
1012           return true;
1013         }
1014       }
1016       return false;
1017     })();
1019     if (!overVideo) {
1020       return;
1021     }
1023     let toggle = this.getToggleElement(shadowRoot);
1024     if (this.isMouseOverToggle(toggle, event)) {
1025       state.isClickingToggle = true;
1026       state.clickedElement = Cu.getWeakReference(event.originalTarget);
1027       event.stopImmediatePropagation();
1029       this.startPictureInPicture(event, video, toggle);
1030     }
1031   }
1033   startPictureInPicture(event, video, toggle) {
1034     Services.telemetry.keyedScalarAdd(
1035       "pictureinpicture.opened_method",
1036       "toggle",
1037       1
1038     );
1039     let args = {
1040       firstTimeToggle: (!Services.prefs.getBoolPref(
1041         TOGGLE_HAS_USED_PREF
1042       )).toString(),
1043     };
1044     Services.telemetry.recordEvent(
1045       "pictureinpicture",
1046       "opened_method",
1047       "toggle",
1048       null,
1049       args
1050     );
1052     let pipEvent = new this.contentWindow.CustomEvent(
1053       "MozTogglePictureInPicture",
1054       {
1055         bubbles: true,
1056       }
1057     );
1058     video.dispatchEvent(pipEvent);
1060     // Since we've initiated Picture-in-Picture, we can go ahead and
1061     // hide the toggle now.
1062     this.onMouseLeaveVideo(video);
1063   }
1065   /**
1066    * Called for mousedown, pointerup, mouseup and click events. If we
1067    * detected that the user is clicking on the Picture-in-Picture toggle,
1068    * these events are cancelled in the capture-phase before they reach
1069    * content. The state for suppressing these events is cleared on the
1070    * click event (unless the mouseup occurs on a different element from
1071    * the mousedown, in which case, the state is cleared on mouseup).
1072    *
1073    * @param {Event} event A mousedown, pointerup, mouseup or click event.
1074    */
1075   onMouseButtonEvent(event) {
1076     // The toggle ignores non-primary mouse clicks.
1077     if (event.button != 0) {
1078       return;
1079     }
1081     let state = this.docState;
1082     if (state.isClickingToggle) {
1083       event.stopImmediatePropagation();
1085       // If this is a mouseup event, check to see if we have a record of what
1086       // the original target was on pointerdown. If so, and if it doesn't match
1087       // the mouseup original target, that means we won't get a click event, and
1088       // we can clear the "clicking the toggle" state right away.
1089       //
1090       // Otherwise, we wait for the click event to do that.
1091       let isMouseUpOnOtherElement =
1092         event.type == "mouseup" &&
1093         (!state.clickedElement ||
1094           state.clickedElement.get() != event.originalTarget);
1096       if (
1097         isMouseUpOnOtherElement ||
1098         event.type == "click" ||
1099         // pointerup event still triggers after a touchstart event. We just need to detect
1100         // the pointer type and determine if we got to this part of the code through a touch event.
1101         event.pointerType == "touch"
1102       ) {
1103         // The click is complete, so now we reset the state so that
1104         // we stop suppressing these events.
1105         state.isClickingToggle = false;
1106         state.clickedElement = null;
1107       }
1108     }
1109   }
1111   /**
1112    * Called on mouseout events to determine whether or not the mouse has
1113    * exited the window.
1114    *
1115    * @param {Event} event The mouseout event.
1116    */
1117   onMouseOut(event) {
1118     if (!event.relatedTarget) {
1119       // For mouseout events, if there's no relatedTarget (which normally
1120       // maps to the element that the mouse entered into) then this means that
1121       // we left the window.
1122       let video = this.getWeakOverVideo();
1123       if (!video) {
1124         return;
1125       }
1127       this.onMouseLeaveVideo(video);
1128     }
1129   }
1131   /**
1132    * Called for each mousemove event when we're tracking those events to
1133    * determine if the cursor is hovering over a <video>.
1134    *
1135    * @param {Event} event The mousemove event.
1136    */
1137   onMouseMove(event) {
1138     let state = this.docState;
1140     if (state.hideToggleDeferredTask) {
1141       state.hideToggleDeferredTask.disarm();
1142       state.hideToggleDeferredTask.arm();
1143     }
1145     state.lastMouseMoveEvent = event;
1146     state.mousemoveDeferredTask.arm();
1147   }
1149   /**
1150    * Called by the DeferredTask after MOUSEMOVE_PROCESSING_DELAY_MS
1151    * milliseconds. Checked to see if that mousemove happens to be overtop of
1152    * any interesting <video> elements that we want to display the toggle
1153    * on. If so, puts the toggle on that video.
1154    */
1155   checkLastMouseMove() {
1156     let state = this.docState;
1157     let event = state.lastMouseMoveEvent;
1158     let { clientX, clientY } = event;
1159     lazy.logConsole.debug("Visible videos count:", state.visibleVideosCount);
1160     lazy.logConsole.debug("Tracking videos:", state.isTrackingVideos);
1161     let winUtils = this.contentWindow.windowUtils;
1162     // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
1163     // since document.elementsFromPoint always flushes layout. The 1's in that
1164     // function call are for the size of the rect that we want, which is 1x1.
1165     let elements = winUtils.nodesFromRect(
1166       clientX,
1167       clientY,
1168       1,
1169       1,
1170       1,
1171       1,
1172       true,
1173       false,
1174       /* aOnlyVisible = */ true
1175     );
1177     for (let element of elements) {
1178       lazy.logConsole.debug("Element id under cursor:", element.id);
1179       lazy.logConsole.debug(
1180         "Node name of an element under cursor:",
1181         element.nodeName
1182       );
1183       lazy.logConsole.debug(
1184         "Supported <video> element:",
1185         state.weakVisibleVideos.has(element)
1186       );
1187       lazy.logConsole.debug(
1188         "PiP window is open:",
1189         element.isCloningElementVisually
1190       );
1192       // Check for hovering over the video controls or so too, not only
1193       // directly over the video.
1194       for (let el = element; el; el = el.containingShadowRoot?.host) {
1195         if (state.weakVisibleVideos.has(el) && !el.isCloningElementVisually) {
1196           lazy.logConsole.debug("Found supported element");
1197           this.onMouseOverVideo(el, event);
1198           return;
1199         }
1200       }
1201     }
1203     let oldOverVideo = this.getWeakOverVideo();
1204     if (oldOverVideo) {
1205       this.onMouseLeaveVideo(oldOverVideo);
1206     }
1207   }
1209   /**
1210    * Called once it has been determined that the mouse is overtop of a video
1211    * that is in the viewport.
1212    *
1213    * @param {Element} video The video the mouse is over.
1214    */
1215   onMouseOverVideo(video, event) {
1216     let oldOverVideo = this.getWeakOverVideo();
1217     let shadowRoot = video.openOrClosedShadowRoot;
1219     if (shadowRoot.firstChild && video != oldOverVideo) {
1220       if (video.getTransformToViewport().a == -1) {
1221         shadowRoot.firstChild.setAttribute("flipped", true);
1222       } else {
1223         shadowRoot.firstChild.removeAttribute("flipped");
1224       }
1225     }
1227     // It seems from automated testing that if it's still very early on in the
1228     // lifecycle of a <video> element, it might not yet have a shadowRoot,
1229     // in which case, we can bail out here early.
1230     if (!shadowRoot) {
1231       if (oldOverVideo) {
1232         // We also clear the hover state on the old video we were hovering,
1233         // if there was one.
1234         this.onMouseLeaveVideo(oldOverVideo);
1235       }
1237       return;
1238     }
1240     let state = this.docState;
1241     let toggle = this.getToggleElement(shadowRoot);
1242     let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
1244     if (state.checkedPolicyDocumentURI != this.document.documentURI) {
1245       state.togglePolicy = lazy.TOGGLE_POLICIES.DEFAULT;
1246       // We cache the matchers process-wide. We'll skip this while running tests to make that
1247       // easier.
1248       let siteOverrides = this.toggleTesting
1249         ? PictureInPictureToggleChild.getSiteOverrides()
1250         : lazy.gSiteOverrides;
1252       let visibilityThresholdPref = Services.prefs.getFloatPref(
1253         TOGGLE_VISIBILITY_THRESHOLD_PREF,
1254         "1.0"
1255       );
1257       if (!this.videoWrapper) {
1258         this.videoWrapper = applyWrapper(this, video);
1259       }
1261       // Do we have any toggle overrides? If so, try to apply them.
1262       for (let [override, { policy, visibilityThreshold }] of siteOverrides) {
1263         if (
1264           (policy || visibilityThreshold) &&
1265           override.matches(this.document.documentURI)
1266         ) {
1267           state.togglePolicy = this.videoWrapper?.shouldHideToggle(video)
1268             ? lazy.TOGGLE_POLICIES.HIDDEN
1269             : policy || lazy.TOGGLE_POLICIES.DEFAULT;
1270           state.toggleVisibilityThreshold =
1271             visibilityThreshold || visibilityThresholdPref;
1272           break;
1273         }
1274       }
1276       state.checkedPolicyDocumentURI = this.document.documentURI;
1277     }
1279     // The built-in <video> controls are along the bottom, which would overlap the
1280     // toggle if the override is set to BOTTOM, so we ignore overrides that set
1281     // a policy of BOTTOM for <video> elements with controls.
1282     if (
1283       state.togglePolicy != lazy.TOGGLE_POLICIES.DEFAULT &&
1284       !(state.togglePolicy == lazy.TOGGLE_POLICIES.BOTTOM && video.controls)
1285     ) {
1286       toggle.setAttribute(
1287         "policy",
1288         lazy.TOGGLE_POLICY_STRINGS[state.togglePolicy]
1289       );
1290     } else {
1291       toggle.removeAttribute("policy");
1292     }
1294     // nimbusExperimentVariables will be defaultValues when the experiment is disabled
1295     const nimbusExperimentVariables = lazy.NimbusFeatures.pictureinpicture.getAllVariables(
1296       {
1297         defaultValues: {
1298           oldToggle: true,
1299           title: null,
1300           message: false,
1301           showIconOnly: false,
1302           displayDuration: TOGGLE_FIRST_TIME_DURATION_DAYS,
1303         },
1304       }
1305     );
1307     /**
1308      * If a Nimbus variable exists for the first-time PiP toggle design,
1309      * override the old design via a classname "experiment".
1310      */
1311     if (!nimbusExperimentVariables.oldToggle) {
1312       let controlsContainer = shadowRoot.querySelector(".controlsContainer");
1313       let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
1315       controlsContainer.classList.add("experiment");
1316       pipWrapper.classList.add("experiment");
1317     } else {
1318       let controlsContainer = shadowRoot.querySelector(".controlsContainer");
1319       let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
1321       controlsContainer.classList.remove("experiment");
1322       pipWrapper.classList.remove("experiment");
1323     }
1325     if (nimbusExperimentVariables.title) {
1326       let pipExplainer = shadowRoot.querySelector(".pip-explainer");
1327       let pipLabel = shadowRoot.querySelector(".pip-label");
1329       if (pipExplainer && nimbusExperimentVariables.message) {
1330         pipExplainer.innerText = nimbusExperimentVariables.message;
1331       }
1332       pipLabel.innerText = nimbusExperimentVariables.title;
1333     } else if (nimbusExperimentVariables.showIconOnly) {
1334       // We only want to show the PiP icon in this experiment scenario
1335       let pipExpanded = shadowRoot.querySelector(".pip-expanded");
1336       pipExpanded.style.display = "none";
1337       let pipSmall = shadowRoot.querySelector(".pip-small");
1338       pipSmall.style.opacity = "1";
1340       let pipIcon = shadowRoot.querySelectorAll(".pip-icon")[1];
1341       pipIcon.style.display = "block";
1342     }
1344     controlsOverlay.removeAttribute("hidetoggle");
1346     // The hideToggleDeferredTask we create here is for automatically hiding
1347     // the toggle after a period of no mousemove activity for
1348     // TOGGLE_HIDING_TIMEOUT_MS. If the mouse moves, then the DeferredTask
1349     // timer is reset.
1350     //
1351     // We disable the toggle hiding timeout during testing to reduce
1352     // non-determinism from timers when testing the toggle.
1353     if (!state.hideToggleDeferredTask && !this.toggleTesting) {
1354       state.hideToggleDeferredTask = new lazy.DeferredTask(() => {
1355         controlsOverlay.setAttribute("hidetoggle", true);
1356       }, TOGGLE_HIDING_TIMEOUT_MS);
1357     }
1359     if (oldOverVideo) {
1360       if (oldOverVideo == video) {
1361         // If we're still hovering the old video, we might have entered or
1362         // exited the toggle region.
1363         this.checkHoverToggle(toggle, event);
1364         return;
1365       }
1367       // We had an old video that we were hovering, and we're not hovering
1368       // it anymore. Let's leave it.
1369       this.onMouseLeaveVideo(oldOverVideo);
1370     }
1372     state.weakOverVideo = Cu.getWeakReference(video);
1373     controlsOverlay.classList.add("hovering");
1375     if (
1376       state.togglePolicy != lazy.TOGGLE_POLICIES.HIDDEN &&
1377       !toggle.hasAttribute("hidden")
1378     ) {
1379       Services.telemetry.scalarAdd("pictureinpicture.saw_toggle", 1);
1380       const hasUsedPiP = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
1381       let args = {
1382         firstTime: (!hasUsedPiP).toString(),
1383       };
1384       Services.telemetry.recordEvent(
1385         "pictureinpicture",
1386         "saw_toggle",
1387         "toggle",
1388         null,
1389         args
1390       );
1391       // only record if this is the first time seeing the toggle
1392       if (!hasUsedPiP) {
1393         lazy.NimbusFeatures.pictureinpicture.recordExposureEvent();
1395         const firstSeenSeconds = Services.prefs.getIntPref(
1396           TOGGLE_FIRST_SEEN_PREF,
1397           0
1398         );
1400         if (!firstSeenSeconds || firstSeenSeconds < 0) {
1401           let firstTimePiPStartDate = Math.round(Date.now() / 1000);
1402           this.sendAsyncMessage("PictureInPicture:SetFirstSeen", {
1403             dateSeconds: firstTimePiPStartDate,
1404           });
1405         } else if (nimbusExperimentVariables.displayDuration) {
1406           this.changeToIconIfDurationEnd(firstSeenSeconds);
1407         }
1408       }
1409     }
1411     // Now that we're hovering the video, we'll check to see if we're
1412     // hovering the toggle too.
1413     this.checkHoverToggle(toggle, event);
1414   }
1416   /**
1417    * Checks if a mouse event is happening over a toggle element. If it is,
1418    * sets the hovering class on it. Otherwise, it clears the hovering
1419    * class.
1420    *
1421    * @param {Element} toggle The Picture-in-Picture toggle to check.
1422    * @param {MouseEvent} event A MouseEvent to test.
1423    */
1424   checkHoverToggle(toggle, event) {
1425     toggle.classList.toggle("hovering", this.isMouseOverToggle(toggle, event));
1426   }
1428   /**
1429    * Called once it has been determined that the mouse is no longer overlapping
1430    * a video that we'd previously called onMouseOverVideo with.
1431    *
1432    * @param {Element} video The video that the mouse left.
1433    */
1434   onMouseLeaveVideo(video) {
1435     let state = this.docState;
1436     let shadowRoot = video.openOrClosedShadowRoot;
1438     if (shadowRoot) {
1439       let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
1440       let toggle = this.getToggleElement(shadowRoot);
1441       controlsOverlay.classList.remove("hovering");
1442       toggle.classList.remove("hovering");
1443     }
1445     state.weakOverVideo = null;
1447     if (!this.toggleTesting) {
1448       state.hideToggleDeferredTask.disarm();
1449       state.mousemoveDeferredTask.disarm();
1450     }
1452     state.hideToggleDeferredTask = null;
1453   }
1455   /**
1456    * Given a reference to a Picture-in-Picture toggle element, determines
1457    * if a MouseEvent event is occurring within its bounds.
1458    *
1459    * @param {Element} toggle The Picture-in-Picture toggle.
1460    * @param {MouseEvent} event A MouseEvent to test.
1461    *
1462    * @return {Boolean}
1463    */
1464   isMouseOverToggle(toggle, event) {
1465     let toggleRect = toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing(
1466       toggle
1467     );
1469     // The way the toggle is currently implemented with
1470     // absolute positioning, the root toggle element bounds don't actually
1471     // contain all of the toggle child element bounds. Until we find a way to
1472     // sort that out, we workaround the issue by having each clickable child
1473     // elements of the toggle have a clicklable class, and then compute the
1474     // smallest rect that contains all of their bounding rects and use that
1475     // as the hitbox.
1476     toggleRect = lazy.Rect.fromRect(toggleRect);
1477     let clickableChildren = toggle.querySelectorAll(".clickable");
1478     for (let child of clickableChildren) {
1479       let childRect = lazy.Rect.fromRect(
1480         child.ownerGlobal.windowUtils.getBoundsWithoutFlushing(child)
1481       );
1482       toggleRect.expandToContain(childRect);
1483     }
1485     // If the toggle has no dimensions, we're definitely not over it.
1486     if (!toggleRect.width || !toggleRect.height) {
1487       return false;
1488     }
1490     let { clientX, clientY } = event;
1492     return (
1493       clientX >= toggleRect.left &&
1494       clientX <= toggleRect.right &&
1495       clientY >= toggleRect.top &&
1496       clientY <= toggleRect.bottom
1497     );
1498   }
1500   /**
1501    * Checks a contextmenu event to see if the mouse is currently over the
1502    * Picture-in-Picture toggle. If so, sends a message to the parent process
1503    * to open up the Picture-in-Picture toggle context menu.
1504    *
1505    * @param {MouseEvent} event A contextmenu event.
1506    */
1507   checkContextMenu(event) {
1508     let video = this.getWeakOverVideo();
1509     if (!video) {
1510       return;
1511     }
1513     let shadowRoot = video.openOrClosedShadowRoot;
1514     if (!shadowRoot) {
1515       return;
1516     }
1518     let toggle = this.getToggleElement(shadowRoot);
1519     if (this.isMouseOverToggle(toggle, event)) {
1520       let devicePixelRatio = toggle.ownerGlobal.devicePixelRatio;
1521       this.sendAsyncMessage("PictureInPicture:OpenToggleContextMenu", {
1522         screenXDevPx: event.screenX * devicePixelRatio,
1523         screenYDevPx: event.screenY * devicePixelRatio,
1524         mozInputSource: event.mozInputSource,
1525       });
1526       event.stopImmediatePropagation();
1527       event.preventDefault();
1528     }
1529   }
1531   /**
1532    * Returns the appropriate root element for the Picture-in-Picture toggle,
1533    * depending on whether or not we're using the experimental toggle preference.
1534    *
1535    * @param {Element} shadowRoot The shadowRoot of the video element.
1536    * @returns {Element} The toggle element.
1537    */
1538   getToggleElement(shadowRoot) {
1539     return shadowRoot.getElementById("pictureInPictureToggle");
1540   }
1542   /**
1543    * This is a test-only function that returns true if a video is being tracked
1544    * for mouseover events after having intersected the viewport.
1545    */
1546   static isTracking(video) {
1547     return gWeakIntersectingVideosForTesting.has(video);
1548   }
1550   /**
1551    * Gets any Picture-in-Picture site-specific overrides stored in the
1552    * sharedData struct, and returns them as an Array of two-element Arrays,
1553    * where the first element is a MatchPattern and the second element is an
1554    * object of the form { policy, disabledKeyboardControls } (where each property
1555    * may be missing or undefined).
1556    *
1557    * @returns {Array<Array<2>>} Array of 2-element Arrays where the first element
1558    * is a MatchPattern and the second element is an object with optional policy
1559    * and/or disabledKeyboardControls properties.
1560    */
1561   static getSiteOverrides() {
1562     let result = [];
1563     let patterns = Services.cpmm.sharedData.get(
1564       "PictureInPicture:SiteOverrides"
1565     );
1566     for (let pattern in patterns) {
1567       let matcher = new MatchPattern(pattern);
1568       result.push([matcher, patterns[pattern]]);
1569     }
1570     return result;
1571   }
1574 export class PictureInPictureChild extends JSWindowActorChild {
1575   #subtitlesEnabled = false;
1576   // A weak reference to this PiP window's video element
1577   weakVideo = null;
1579   // A weak reference to this PiP window's content window
1580   weakPlayerContent = null;
1582   // A reference to current WebVTT track currently displayed on the content window
1583   _currentWebVTTTrack = null;
1585   observerFunction = null;
1587   observe(subject, topic, data) {
1588     if (topic != "nsPref:changed") {
1589       return;
1590     }
1592     switch (data) {
1593       case "media.videocontrols.picture-in-picture.display-text-tracks.enabled": {
1594         const originatingVideo = this.getWeakVideo();
1595         let isTextTrackPrefEnabled = Services.prefs.getBoolPref(
1596           "media.videocontrols.picture-in-picture.display-text-tracks.enabled"
1597         );
1599         // Enable or disable text track support
1600         if (isTextTrackPrefEnabled) {
1601           this.setupTextTracks(originatingVideo);
1602         } else {
1603           this.removeTextTracks(originatingVideo);
1604         }
1605         break;
1606       }
1607     }
1608   }
1610   /**
1611    * Creates a link element with a reference to the css stylesheet needed
1612    * for text tracks responsive styling.
1613    * @returns {Element} the link element containing text tracks stylesheet.
1614    */
1615   createTextTracksStyleSheet() {
1616     let headStyleElement = this.document.createElement("link");
1617     headStyleElement.setAttribute("rel", "stylesheet");
1618     headStyleElement.setAttribute(
1619       "href",
1620       "chrome://global/skin/pictureinpicture/texttracks.css"
1621     );
1622     headStyleElement.setAttribute("type", "text/css");
1623     return headStyleElement;
1624   }
1626   /**
1627    * Sets up Picture-in-Picture to support displaying text tracks from WebVTT
1628    * or if WebVTT isn't supported we will register the caption change mutation observer if
1629    * the site wrapper exists.
1630    *
1631    * If the originating video supports WebVTT, try to read the
1632    * active track and cues. Display any active cues on the pip window
1633    * right away if applicable.
1634    *
1635    * @param originatingVideo {Element|null}
1636    *  The <video> being displayed in Picture-in-Picture mode, or null if that <video> no longer exists.
1637    */
1638   setupTextTracks(originatingVideo) {
1639     const isWebVTTSupported = !!originatingVideo.textTracks?.length;
1641     if (!isWebVTTSupported) {
1642       this.setUpCaptionChangeListener(originatingVideo);
1643       return;
1644     }
1646     // Verify active track for originating video
1647     this.setActiveTextTrack(originatingVideo.textTracks);
1649     if (!this._currentWebVTTTrack) {
1650       // If WebVTT track is invalid, try using a video wrapper
1651       this.setUpCaptionChangeListener(originatingVideo);
1652       return;
1653     }
1655     // Listen for changes in tracks and active cues
1656     originatingVideo.textTracks.addEventListener("change", this);
1657     this._currentWebVTTTrack.addEventListener("cuechange", this.onCueChange);
1659     const cues = this._currentWebVTTTrack.activeCues;
1660     this.updateWebVTTTextTracksDisplay(cues);
1661   }
1663   /**
1664    * Toggle the visibility of the subtitles in the PiP window
1665    */
1666   toggleTextTracks() {
1667     let textTracks = this.document.getElementById("texttracks");
1668     textTracks.style.display =
1669       textTracks.style.display === "none" ? "" : "none";
1670   }
1672   /**
1673    * Removes existing text tracks on the Picture in Picture window.
1674    *
1675    * If the originating video supports WebVTT, clear references to active
1676    * tracks and cues. No longer listen for any track or cue changes.
1677    *
1678    * @param originatingVideo {Element|null}
1679    *  The <video> being displayed in Picture-in-Picture mode, or null if that <video> no longer exists.
1680    */
1681   removeTextTracks(originatingVideo) {
1682     const isWebVTTSupported = !!originatingVideo.textTracks;
1684     if (!isWebVTTSupported) {
1685       return;
1686     }
1688     // No longer listen for changes to tracks and active cues
1689     originatingVideo.textTracks.removeEventListener("change", this);
1690     this._currentWebVTTTrack?.removeEventListener(
1691       "cuechange",
1692       this.onCueChange
1693     );
1694     this._currentWebVTTTrack = null;
1695     this.updateWebVTTTextTracksDisplay(null);
1696   }
1698   /**
1699    * Moves the text tracks container position above the pip window's video controls
1700    * if their positions visually overlap. Since pip controls are within the parent
1701    * process, we determine if pip video controls and text tracks visually overlap by
1702    * comparing their relative positions with DOMRect.
1703    *
1704    * If overlap is found, set attribute "overlap-video-controls" to move text tracks
1705    * and define a new relative bottom position according to pip window size and the
1706    * position of video controls.
1707    *  @param {Object} data args needed to determine if text tracks must be moved
1708    */
1709   moveTextTracks(data) {
1710     const {
1711       isFullscreen,
1712       isVideoControlsShowing,
1713       playerBottomControlsDOMRect,
1714       isScrubberShowing,
1715     } = data;
1716     let textTracks = this.document.getElementById("texttracks");
1717     const originatingWindow = this.getWeakVideo().ownerGlobal;
1718     const isReducedMotionEnabled = originatingWindow.matchMedia(
1719       "(prefers-reduced-motion: reduce)"
1720     ).matches;
1721     const textTracksFontScale = this.document
1722       .querySelector(":root")
1723       .style.getPropertyValue("--font-scale");
1725     if (isFullscreen || isReducedMotionEnabled) {
1726       textTracks.removeAttribute("overlap-video-controls");
1727       return;
1728     }
1730     if (isVideoControlsShowing) {
1731       let playerVideoRect = textTracks.parentElement.getBoundingClientRect();
1732       let isOverlap =
1733         playerVideoRect.bottom - textTracksFontScale * playerVideoRect.height >
1734         playerBottomControlsDOMRect.top;
1736       if (isOverlap) {
1737         const root = this.document.querySelector(":root");
1738         if (isScrubberShowing) {
1739           root.style.setProperty("--player-controls-scrubber-height", "30px");
1740         } else {
1741           root.style.setProperty("--player-controls-scrubber-height", "0px");
1742         }
1743         textTracks.setAttribute("overlap-video-controls", true);
1744       } else {
1745         textTracks.removeAttribute("overlap-video-controls");
1746       }
1747     } else {
1748       textTracks.removeAttribute("overlap-video-controls");
1749     }
1750   }
1752   /**
1753    * Updates the text content for the container that holds and displays text tracks
1754    * on the pip window.
1755    * @param textTrackCues {TextTrackCueList|null}
1756    *  Collection of TextTrackCue objects containing text displayed, or null if there is no cue to display.
1757    */
1758   updateWebVTTTextTracksDisplay(textTrackCues) {
1759     let pipWindowTracksContainer = this.document.getElementById("texttracks");
1760     let playerVideo = this.document.getElementById("playervideo");
1761     let playerVideoWindow = playerVideo.ownerGlobal;
1763     // To prevent overlap with previous cues, clear all text from the pip window
1764     pipWindowTracksContainer.replaceChildren();
1766     if (!textTrackCues) {
1767       return;
1768     }
1770     if (!this.isSubtitlesEnabled) {
1771       this.isSubtitlesEnabled = true;
1772       this.sendAsyncMessage("PictureInPicture:EnableSubtitlesButton");
1773     }
1775     let allCuesArray = [...textTrackCues];
1776     // Re-order cues
1777     this.getOrderedWebVTTCues(allCuesArray);
1778     // Parse through WebVTT cue using vtt.js to ensure
1779     // semantic markup like <b> and <i> tags are rendered.
1780     allCuesArray.forEach(cue => {
1781       let text = cue.text;
1782       // Trim extra newlines and whitespaces
1783       const re = /(\s*\n{2,}\s*)/g;
1784       text = text.trim();
1785       text = text.replace(re, "\n");
1786       let cueTextNode = WebVTT.convertCueToDOMTree(playerVideoWindow, text);
1787       let cueDiv = this.document.createElement("div");
1788       cueDiv.appendChild(cueTextNode);
1789       pipWindowTracksContainer.appendChild(cueDiv);
1790     });
1791   }
1793   /**
1794    * Re-orders list of multiple active cues to ensure cues are rendered in the correct order.
1795    * How cues are ordered depends on the VTTCue.line value of the cue.
1796    *
1797    * If line is string "auto", we want to reverse the order of cues.
1798    * Cues are read from top to bottom in a vtt file, but are inserted into a video from bottom to top.
1799    * Ensure this order is followed.
1800    *
1801    * If line is an integer or percentage, we want to order cues according to numeric value.
1802    * Assumptions:
1803    *  1) all active cues are numeric
1804    *  2) all active cues are in range 0..100
1805    *  3) all actives cue are horizontal (no VTTCue.vertical)
1806    *  4) all active cues with VTTCue.line integer have VTTCue.snapToLines = true
1807    *  5) all active cues with VTTCue.line percentage have VTTCue.snapToLines = false
1808    *
1809    * vtt.jsm currently sets snapToLines to false if line is a percentage value, but
1810    * cues are still ordered by line. In most cases, snapToLines is set to true by default,
1811    * unless intentionally overridden.
1812    * @param allCuesArray {Array<VTTCue>} array of active cues
1813    */
1814   getOrderedWebVTTCues(allCuesArray) {
1815     if (!allCuesArray || allCuesArray.length <= 1) {
1816       return;
1817     }
1819     let allCuesHaveNumericLines = allCuesArray.find(cue => cue.line !== "auto");
1821     if (allCuesHaveNumericLines) {
1822       allCuesArray.sort((cue1, cue2) => cue1.line - cue2.line);
1823     } else if (allCuesArray.length >= 2) {
1824       allCuesArray.reverse();
1825     }
1826   }
1828   /**
1829    * Returns a reference to the PiP's <video> element being displayed in Picture-in-Picture
1830    * mode.
1831    *
1832    * @return {Element} The <video> being displayed in Picture-in-Picture mode, or null
1833    * if that <video> no longer exists.
1834    */
1835   getWeakVideo() {
1836     if (this.weakVideo) {
1837       // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
1838       // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
1839       try {
1840         return this.weakVideo.get();
1841       } catch (e) {
1842         return null;
1843       }
1844     }
1845     return null;
1846   }
1848   /**
1849    * Returns a reference to the inner window of the about:blank document that is
1850    * cloning the originating <video> in the always-on-top player <xul:browser>.
1851    *
1852    * @return {Window} The inner window of the about:blank player <xul:browser>, or
1853    * null if that window has been closed.
1854    */
1855   getWeakPlayerContent() {
1856     if (this.weakPlayerContent) {
1857       // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
1858       // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
1859       try {
1860         return this.weakPlayerContent.get();
1861       } catch (e) {
1862         return null;
1863       }
1864     }
1865     return null;
1866   }
1868   /**
1869    * Returns true if the passed video happens to be the one that this
1870    * content process is running in a Picture-in-Picture window.
1871    *
1872    * @param {Element} video The <video> element to check.
1873    *
1874    * @return {Boolean}
1875    */
1876   inPictureInPicture(video) {
1877     return this.getWeakVideo() === video;
1878   }
1880   static videoIsPlaying(video) {
1881     return !!(!video.paused && !video.ended && video.readyState > 2);
1882   }
1884   static videoIsMuted(video) {
1885     return this.videoWrapper.isMuted(video);
1886   }
1888   handleEvent(event) {
1889     switch (event.type) {
1890       case "MozStopPictureInPicture": {
1891         if (event.isTrusted && event.target === this.getWeakVideo()) {
1892           const reason = event.detail?.reason || "videoElRemove";
1893           this.closePictureInPicture({ reason });
1894         }
1895         break;
1896       }
1897       case "pagehide": {
1898         // The originating video's content document has unloaded,
1899         // so close Picture-in-Picture.
1900         this.closePictureInPicture({ reason: "pagehide" });
1901         break;
1902       }
1903       case "MozDOMFullscreen:Request": {
1904         this.closePictureInPicture({ reason: "fullscreen" });
1905         break;
1906       }
1907       case "play": {
1908         this.sendAsyncMessage("PictureInPicture:Playing");
1909         break;
1910       }
1911       case "pause": {
1912         this.sendAsyncMessage("PictureInPicture:Paused");
1913         break;
1914       }
1915       case "volumechange": {
1916         let video = this.getWeakVideo();
1918         // Just double-checking that we received the event for the right
1919         // video element.
1920         if (video !== event.target) {
1921           lazy.logConsole.error(
1922             "PictureInPictureChild received volumechange for " +
1923               "the wrong video!"
1924           );
1925           return;
1926         }
1928         if (this.constructor.videoIsMuted(video)) {
1929           this.sendAsyncMessage("PictureInPicture:Muting");
1930         } else {
1931           this.sendAsyncMessage("PictureInPicture:Unmuting");
1932         }
1933         break;
1934       }
1935       case "resize": {
1936         let video = event.target;
1937         if (this.inPictureInPicture(video)) {
1938           this.sendAsyncMessage("PictureInPicture:Resize", {
1939             videoHeight: video.videoHeight,
1940             videoWidth: video.videoWidth,
1941           });
1942         }
1943         this.setupTextTracks(video);
1944         break;
1945       }
1946       case "emptied": {
1947         this.isSubtitlesEnabled = false;
1948         if (this.emptiedTimeout) {
1949           clearTimeout(this.emptiedTimeout);
1950           this.emptiedTimeout = null;
1951         }
1952         let video = this.getWeakVideo();
1953         // We may want to keep the pip window open if the video
1954         // is still in DOM. But if video src is no longer defined,
1955         // close Picture-in-Picture.
1956         this.emptiedTimeout = setTimeout(() => {
1957           if (!video || !video.src) {
1958             this.closePictureInPicture({ reason: "videoElEmptied" });
1959           }
1960         }, EMPTIED_TIMEOUT_MS);
1961         break;
1962       }
1963       case "change": {
1964         // Clear currently stored track data (webvtt support) before reading
1965         // a new track.
1966         if (this._currentWebVTTTrack) {
1967           this._currentWebVTTTrack.removeEventListener(
1968             "cuechange",
1969             this.onCueChange
1970           );
1971           this._currentWebVTTTrack = null;
1972         }
1974         const tracks = event.target;
1975         this.setActiveTextTrack(tracks);
1976         const isCurrentTrackAvailable = this._currentWebVTTTrack;
1978         // If tracks are disabled or invalid while change occurs,
1979         // remove text tracks from the pip window and stop here.
1980         if (!isCurrentTrackAvailable || !tracks.length) {
1981           this.updateWebVTTTextTracksDisplay(null);
1982           return;
1983         }
1985         this._currentWebVTTTrack.addEventListener(
1986           "cuechange",
1987           this.onCueChange
1988         );
1989         const cues = this._currentWebVTTTrack.activeCues;
1990         this.updateWebVTTTextTracksDisplay(cues);
1991         break;
1992       }
1993       case "timeupdate":
1994       case "durationchange": {
1995         let video = this.getWeakVideo();
1996         let currentTime = this.videoWrapper.getCurrentTime(video);
1997         let duration = this.videoWrapper.getDuration(video);
1998         let scrubberPosition = currentTime === 0 ? 0 : currentTime / duration;
1999         let timestamp = this.videoWrapper.formatTimestamp(
2000           currentTime,
2001           duration
2002         );
2003         // There's no point in sending this message unless we have a
2004         // reasonable timestamp.
2005         if (timestamp !== undefined && lazy.IMPROVED_CONTROLS_ENABLED_PREF) {
2006           this.sendAsyncMessage(
2007             "PictureInPicture:SetTimestampAndScrubberPosition",
2008             {
2009               scrubberPosition,
2010               timestamp,
2011             }
2012           );
2013         }
2014         break;
2015       }
2016     }
2017   }
2019   /**
2020    * Tells the parent to close a pre-existing Picture-in-Picture
2021    * window.
2022    *
2023    * @return {Promise}
2024    *
2025    * @resolves {undefined} Once the pre-existing Picture-in-Picture
2026    * window has unloaded.
2027    */
2028   async closePictureInPicture({ reason }) {
2029     let video = this.getWeakVideo();
2030     if (video) {
2031       this.untrackOriginatingVideo(video);
2032     }
2033     this.sendAsyncMessage("PictureInPicture:Close", {
2034       reason,
2035     });
2037     let playerContent = this.getWeakPlayerContent();
2038     if (playerContent) {
2039       if (!playerContent.closed) {
2040         await new Promise(resolve => {
2041           playerContent.addEventListener("unload", resolve, {
2042             once: true,
2043           });
2044         });
2045       }
2046       // Nothing should be holding a reference to the Picture-in-Picture
2047       // player window content at this point, but just in case, we'll
2048       // clear the weak reference directly so nothing else can get a hold
2049       // of it from this angle.
2050       this.weakPlayerContent = null;
2051     }
2052   }
2054   receiveMessage(message) {
2055     switch (message.name) {
2056       case "PictureInPicture:SetupPlayer": {
2057         const { videoRef } = message.data;
2058         this.setupPlayer(videoRef);
2059         break;
2060       }
2061       case "PictureInPicture:Play": {
2062         this.play();
2063         break;
2064       }
2065       case "PictureInPicture:Pause": {
2066         if (message.data && message.data.reason == "pip-closed") {
2067           let video = this.getWeakVideo();
2069           // Currently in Firefox srcObjects are MediaStreams. However, by spec a srcObject
2070           // can be either a MediaStream, MediaSource or Blob. In case of future changes
2071           // we do not want to pause MediaStream srcObjects and we want to maintain current
2072           // behavior for non-MediaStream srcObjects.
2073           if (video && MediaStream.isInstance(video.srcObject)) {
2074             break;
2075           }
2076         }
2077         this.pause();
2078         break;
2079       }
2080       case "PictureInPicture:Mute": {
2081         this.mute();
2082         break;
2083       }
2084       case "PictureInPicture:Unmute": {
2085         this.unmute();
2086         break;
2087       }
2088       case "PictureInPicture:SeekForward":
2089       case "PictureInPicture:SeekBackward": {
2090         let selectedTime;
2091         let video = this.getWeakVideo();
2092         let currentTime = this.videoWrapper.getCurrentTime(video);
2093         if (message.name == "PictureInPicture:SeekBackward") {
2094           selectedTime = currentTime - SEEK_TIME_SECS;
2095           selectedTime = selectedTime >= 0 ? selectedTime : 0;
2096         } else {
2097           const maxtime = this.videoWrapper.getDuration(video);
2098           selectedTime = currentTime + SEEK_TIME_SECS;
2099           selectedTime = selectedTime <= maxtime ? selectedTime : maxtime;
2100         }
2101         this.videoWrapper.setCurrentTime(video, selectedTime);
2102         break;
2103       }
2104       case "PictureInPicture:KeyDown": {
2105         this.keyDown(message.data);
2106         break;
2107       }
2108       case "PictureInPicture:EnterFullscreen":
2109       case "PictureInPicture:ExitFullscreen": {
2110         let textTracks = this.document.getElementById("texttracks");
2111         if (textTracks) {
2112           this.moveTextTracks(message.data);
2113         }
2114         break;
2115       }
2116       case "PictureInPicture:ShowVideoControls":
2117       case "PictureInPicture:HideVideoControls": {
2118         let textTracks = this.document.getElementById("texttracks");
2119         if (textTracks) {
2120           this.moveTextTracks(message.data);
2121         }
2122         break;
2123       }
2124       case "PictureInPicture:ToggleTextTracks": {
2125         this.toggleTextTracks();
2126         break;
2127       }
2128       case "PictureInPicture:ChangeFontSizeTextTracks": {
2129         this.setTextTrackFontSize();
2130         break;
2131       }
2132       case "PictureInPicture:SetVideoTime": {
2133         const { scrubberPosition, wasPlaying } = message.data;
2134         this.setVideoTime(scrubberPosition, wasPlaying);
2135         break;
2136       }
2137     }
2138   }
2140   /**
2141    * Set the current time of the video based of the position of the scrubber
2142    * @param {Number} scrubberPosition A number between 0 and 1 representing the position of the scrubber
2143    */
2144   setVideoTime(scrubberPosition, wasPlaying) {
2145     const video = this.getWeakVideo();
2146     let duration = this.videoWrapper.getDuration(video);
2147     let currentTime = scrubberPosition * duration;
2148     this.videoWrapper.setCurrentTime(video, currentTime, wasPlaying);
2149   }
2151   /**
2152    * @returns {boolean} true if a textTrack with mode "hidden" should be treated as "showing"
2153    */
2154   shouldShowHiddenTextTracks() {
2155     const video = this.getWeakVideo();
2156     if (!video) {
2157       return false;
2158     }
2159     const { documentURI } = video.ownerDocument;
2160     if (!documentURI) {
2161       return false;
2162     }
2163     for (let [override, { showHiddenTextTracks }] of lazy.gSiteOverrides) {
2164       if (override.matches(documentURI) && showHiddenTextTracks) {
2165         return true;
2166       }
2167     }
2168     return false;
2169   }
2171   /**
2172    * Updates this._currentWebVTTTrack if an active track is found
2173    * for the originating video.
2174    * @param {TextTrackList} textTrackList list of text tracks
2175    */
2176   setActiveTextTrack(textTrackList) {
2177     this._currentWebVTTTrack = null;
2179     for (let i = 0; i < textTrackList.length; i++) {
2180       let track = textTrackList[i];
2181       let isCCText = track.kind === "subtitles" || track.kind === "captions";
2182       let shouldShowTrack =
2183         track.mode === "showing" ||
2184         (track.mode === "hidden" && this.shouldShowHiddenTextTracks());
2185       if (isCCText && shouldShowTrack && track.cues) {
2186         this._currentWebVTTTrack = track;
2187         break;
2188       }
2189     }
2190   }
2192   /**
2193    * Set the font size on the PiP window using the current font size value from
2194    * the "media.videocontrols.picture-in-picture.display-text-tracks.size" pref
2195    */
2196   setTextTrackFontSize() {
2197     const fontSize = Services.prefs.getStringPref(
2198       TEXT_TRACK_FONT_SIZE,
2199       "medium"
2200     );
2201     const root = this.document.querySelector(":root");
2202     if (fontSize === "small") {
2203       root.style.setProperty("--font-scale", "0.03");
2204     } else if (fontSize === "large") {
2205       root.style.setProperty("--font-scale", "0.09");
2206     } else {
2207       root.style.setProperty("--font-scale", "0.06");
2208     }
2209   }
2211   /**
2212    * Keeps an eye on the originating video's document. If it ever
2213    * goes away, this will cause the Picture-in-Picture window for any
2214    * of its content to go away as well.
2215    */
2216   trackOriginatingVideo(originatingVideo) {
2217     this.observerFunction = (subject, topic, data) => {
2218       this.observe(subject, topic, data);
2219     };
2220     Services.prefs.addObserver(
2221       "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
2222       this.observerFunction
2223     );
2225     let originatingWindow = originatingVideo.ownerGlobal;
2226     if (originatingWindow) {
2227       originatingWindow.addEventListener("pagehide", this);
2228       originatingVideo.addEventListener("play", this);
2229       originatingVideo.addEventListener("pause", this);
2230       originatingVideo.addEventListener("volumechange", this);
2231       originatingVideo.addEventListener("resize", this);
2232       originatingVideo.addEventListener("emptied", this);
2233       originatingVideo.addEventListener("timeupdate", this);
2235       if (lazy.DISPLAY_TEXT_TRACKS_PREF) {
2236         this.setupTextTracks(originatingVideo);
2237       }
2239       let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
2240       chromeEventHandler.addEventListener(
2241         "MozDOMFullscreen:Request",
2242         this,
2243         true
2244       );
2245       chromeEventHandler.addEventListener(
2246         "MozStopPictureInPicture",
2247         this,
2248         true
2249       );
2250     }
2251   }
2253   setUpCaptionChangeListener(originatingVideo) {
2254     if (this.videoWrapper) {
2255       this.videoWrapper.setCaptionContainerObserver(originatingVideo, this);
2256     }
2257   }
2259   /**
2260    * Stops tracking the originating video's document. This should
2261    * happen once the Picture-in-Picture window goes away (or is about
2262    * to go away), and we no longer care about hearing when the originating
2263    * window's document unloads.
2264    */
2265   untrackOriginatingVideo(originatingVideo) {
2266     Services.prefs.removeObserver(
2267       "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
2268       this.observerFunction
2269     );
2271     let originatingWindow = originatingVideo.ownerGlobal;
2272     if (originatingWindow) {
2273       originatingWindow.removeEventListener("pagehide", this);
2274       originatingVideo.removeEventListener("play", this);
2275       originatingVideo.removeEventListener("pause", this);
2276       originatingVideo.removeEventListener("volumechange", this);
2277       originatingVideo.removeEventListener("resize", this);
2278       originatingVideo.removeEventListener("emptied", this);
2279       originatingVideo.removeEventListener("timeupdate", this);
2281       if (lazy.DISPLAY_TEXT_TRACKS_PREF) {
2282         this.removeTextTracks(originatingVideo);
2283       }
2285       let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
2286       chromeEventHandler.removeEventListener(
2287         "MozDOMFullscreen:Request",
2288         this,
2289         true
2290       );
2291       chromeEventHandler.removeEventListener(
2292         "MozStopPictureInPicture",
2293         this,
2294         true
2295       );
2296     }
2297   }
2299   /**
2300    * Runs in an instance of PictureInPictureChild for the
2301    * player window's content, and not the originating video
2302    * content. Sets up the player so that it clones the originating
2303    * video. If anything goes wrong during set up, a message is
2304    * sent to the parent to close the Picture-in-Picture window.
2305    *
2306    * @param videoRef {ContentDOMReference}
2307    *    A reference to the video element that a Picture-in-Picture window
2308    *    is being created for
2309    * @return {Promise}
2310    * @resolves {undefined} Once the player window has been set up
2311    * properly, or a pre-existing Picture-in-Picture window has gone
2312    * away due to an unexpected error.
2313    */
2314   async setupPlayer(videoRef) {
2315     const video = await lazy.ContentDOMReference.resolve(videoRef);
2317     this.weakVideo = Cu.getWeakReference(video);
2318     let originatingVideo = this.getWeakVideo();
2319     if (!originatingVideo) {
2320       // If the video element has gone away before we've had a chance to set up
2321       // Picture-in-Picture for it, tell the parent to close the Picture-in-Picture
2322       // window.
2323       await this.closePictureInPicture({ reason: "setupFailure" });
2324       return;
2325     }
2327     this.videoWrapper = applyWrapper(this, originatingVideo);
2329     let loadPromise = new Promise(resolve => {
2330       this.contentWindow.addEventListener("load", resolve, {
2331         once: true,
2332         mozSystemGroup: true,
2333         capture: true,
2334       });
2335     });
2336     this.contentWindow.location.reload();
2337     await loadPromise;
2339     // We're committed to adding the video to this window now. Ensure we track
2340     // the content window before we do so, so that the toggle actor can
2341     // distinguish this new video we're creating from web-controlled ones.
2342     this.weakPlayerContent = Cu.getWeakReference(this.contentWindow);
2343     gPlayerContents.add(this.contentWindow);
2345     let doc = this.document;
2346     let playerVideo = doc.createElement("video");
2347     playerVideo.id = "playervideo";
2348     let textTracks = doc.createElement("div");
2350     doc.body.style.overflow = "hidden";
2351     doc.body.style.margin = "0";
2353     // Force the player video to assume maximum height and width of the
2354     // containing window
2355     playerVideo.style.height = "100vh";
2356     playerVideo.style.width = "100vw";
2357     playerVideo.style.backgroundColor = "#000";
2359     // Load text tracks container in the content process so that
2360     // we can load text tracks without having to constantly
2361     // access the parent process.
2362     textTracks.id = "texttracks";
2363     // When starting pip, player controls are expected to appear.
2364     textTracks.setAttribute("overlap-video-controls", true);
2365     doc.body.appendChild(playerVideo);
2366     doc.body.appendChild(textTracks);
2367     // Load text tracks stylesheet
2368     let textTracksStyleSheet = this.createTextTracksStyleSheet();
2369     doc.head.appendChild(textTracksStyleSheet);
2371     this.setTextTrackFontSize();
2373     originatingVideo.cloneElementVisually(playerVideo);
2375     let shadowRoot = originatingVideo.openOrClosedShadowRoot;
2376     if (originatingVideo.getTransformToViewport().a == -1) {
2377       shadowRoot.firstChild.setAttribute("flipped", true);
2378       playerVideo.style.transform = "scaleX(-1)";
2379     }
2381     this.onCueChange = this.onCueChange.bind(this);
2382     this.trackOriginatingVideo(originatingVideo);
2384     // A request to open PIP implies that the user intends to be interacting
2385     // with the page, even if they open PIP by some means outside of the page
2386     // itself (e.g., the keyboard shortcut or the page action button). So we
2387     // manually record that the document has been activated via user gesture
2388     // to make sure the video can be played regardless of autoplay permissions.
2389     originatingVideo.ownerDocument.notifyUserGestureActivation();
2391     this.contentWindow.addEventListener(
2392       "unload",
2393       () => {
2394         let video = this.getWeakVideo();
2395         if (video) {
2396           this.untrackOriginatingVideo(video);
2397           video.stopCloningElementVisually();
2398         }
2399         this.weakVideo = null;
2400       },
2401       { once: true }
2402     );
2403   }
2405   play() {
2406     let video = this.getWeakVideo();
2407     if (video && this.videoWrapper) {
2408       this.videoWrapper.play(video);
2409     }
2410   }
2412   pause() {
2413     let video = this.getWeakVideo();
2414     if (video && this.videoWrapper) {
2415       this.videoWrapper.pause(video);
2416     }
2417   }
2419   mute() {
2420     let video = this.getWeakVideo();
2421     if (video && this.videoWrapper) {
2422       this.videoWrapper.setMuted(video, true);
2423     }
2424   }
2426   unmute() {
2427     let video = this.getWeakVideo();
2428     if (video && this.videoWrapper) {
2429       this.videoWrapper.setMuted(video, false);
2430     }
2431   }
2433   onCueChange(e) {
2434     if (!lazy.DISPLAY_TEXT_TRACKS_PREF) {
2435       this.updateWebVTTTextTracksDisplay(null);
2436     } else {
2437       const cues = this._currentWebVTTTrack.activeCues;
2438       this.updateWebVTTTextTracksDisplay(cues);
2439     }
2440   }
2442   /**
2443    * This checks if a given keybinding has been disabled for the specific site
2444    * currently being viewed.
2445    */
2446   isKeyDisabled(key) {
2447     const video = this.getWeakVideo();
2448     if (!video) {
2449       return false;
2450     }
2451     const { documentURI } = video.ownerDocument;
2452     if (!documentURI) {
2453       return true;
2454     }
2455     for (let [override, { disabledKeyboardControls }] of lazy.gSiteOverrides) {
2456       if (
2457         disabledKeyboardControls !== undefined &&
2458         override.matches(documentURI)
2459       ) {
2460         if (disabledKeyboardControls === lazy.KEYBOARD_CONTROLS.ALL) {
2461           return true;
2462         }
2463         return !!(disabledKeyboardControls & key);
2464       }
2465     }
2466     return false;
2467   }
2469   /**
2470    * This reuses the keyHandler logic in the VideoControlsWidget
2471    * https://searchfox.org/mozilla-central/rev/cfd1cc461f1efe0d66c2fdc17c024a203d5a2fd8/toolkit/content/widgets/videocontrols.js#1687-1810.
2472    * There are future plans to eventually combine the two implementations.
2473    */
2474   /* eslint-disable complexity */
2475   keyDown({ altKey, shiftKey, metaKey, ctrlKey, keyCode }) {
2476     let video = this.getWeakVideo();
2477     if (!video) {
2478       return;
2479     }
2481     var keystroke = "";
2482     if (altKey) {
2483       keystroke += "alt-";
2484     }
2485     if (shiftKey) {
2486       keystroke += "shift-";
2487     }
2488     if (this.contentWindow.navigator.platform.startsWith("Mac")) {
2489       if (metaKey) {
2490         keystroke += "accel-";
2491       }
2492       if (ctrlKey) {
2493         keystroke += "control-";
2494       }
2495     } else {
2496       if (metaKey) {
2497         keystroke += "meta-";
2498       }
2499       if (ctrlKey) {
2500         keystroke += "accel-";
2501       }
2502     }
2504     switch (keyCode) {
2505       case this.contentWindow.KeyEvent.DOM_VK_UP:
2506         keystroke += "upArrow";
2507         break;
2508       case this.contentWindow.KeyEvent.DOM_VK_DOWN:
2509         keystroke += "downArrow";
2510         break;
2511       case this.contentWindow.KeyEvent.DOM_VK_LEFT:
2512         keystroke += "leftArrow";
2513         break;
2514       case this.contentWindow.KeyEvent.DOM_VK_RIGHT:
2515         keystroke += "rightArrow";
2516         break;
2517       case this.contentWindow.KeyEvent.DOM_VK_HOME:
2518         keystroke += "home";
2519         break;
2520       case this.contentWindow.KeyEvent.DOM_VK_END:
2521         keystroke += "end";
2522         break;
2523       case this.contentWindow.KeyEvent.DOM_VK_SPACE:
2524         keystroke += "space";
2525         break;
2526       case this.contentWindow.KeyEvent.DOM_VK_W:
2527         keystroke += "w";
2528         break;
2529     }
2531     const isVideoStreaming = this.videoWrapper.isLive(video);
2532     var oldval, newval;
2534     try {
2535       switch (keystroke) {
2536         case "space" /* Toggle Play / Pause */:
2537           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.PLAY_PAUSE)) {
2538             return;
2539           }
2541           if (
2542             this.videoWrapper.getPaused(video) ||
2543             this.videoWrapper.getEnded(video)
2544           ) {
2545             this.videoWrapper.play(video);
2546           } else {
2547             this.videoWrapper.pause(video);
2548           }
2550           break;
2551         case "accel-w" /* Close video */:
2552           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.CLOSE)) {
2553             return;
2554           }
2555           this.pause();
2556           this.closePictureInPicture({ reason: "closePlayerShortcut" });
2557           break;
2558         case "downArrow" /* Volume decrease */:
2559           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME)) {
2560             return;
2561           }
2562           oldval = this.videoWrapper.getVolume(video);
2563           this.videoWrapper.setVolume(video, oldval < 0.1 ? 0 : oldval - 0.1);
2564           this.videoWrapper.setMuted(video, false);
2565           break;
2566         case "upArrow" /* Volume increase */:
2567           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME)) {
2568             return;
2569           }
2570           oldval = this.videoWrapper.getVolume(video);
2571           this.videoWrapper.setVolume(video, oldval > 0.9 ? 1 : oldval + 0.1);
2572           this.videoWrapper.setMuted(video, false);
2573           break;
2574         case "accel-downArrow" /* Mute */:
2575           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
2576             return;
2577           }
2578           this.videoWrapper.setMuted(video, true);
2579           break;
2580         case "accel-upArrow" /* Unmute */:
2581           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
2582             return;
2583           }
2584           this.videoWrapper.setMuted(video, false);
2585           break;
2586         case "leftArrow": /* Seek back 5 seconds */
2587         case "accel-leftArrow" /* Seek back 10% */:
2588           if (
2589             this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) ||
2590             (isVideoStreaming &&
2591               this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK))
2592           ) {
2593             return;
2594           }
2596           oldval = this.videoWrapper.getCurrentTime(video);
2597           if (keystroke == "leftArrow") {
2598             newval = oldval - SEEK_TIME_SECS;
2599           } else {
2600             newval = oldval - this.videoWrapper.getDuration(video) / 10;
2601           }
2602           this.videoWrapper.setCurrentTime(video, newval >= 0 ? newval : 0);
2603           break;
2604         case "rightArrow": /* Seek forward 5 seconds */
2605         case "accel-rightArrow" /* Seek forward 10% */:
2606           if (
2607             this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) ||
2608             (isVideoStreaming &&
2609               this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK))
2610           ) {
2611             return;
2612           }
2614           oldval = this.videoWrapper.getCurrentTime(video);
2615           var maxtime = this.videoWrapper.getDuration(video);
2616           if (keystroke == "rightArrow") {
2617             newval = oldval + SEEK_TIME_SECS;
2618           } else {
2619             newval = oldval + maxtime / 10;
2620           }
2621           let selectedTime = newval <= maxtime ? newval : maxtime;
2622           this.videoWrapper.setCurrentTime(video, selectedTime);
2623           break;
2624         case "home" /* Seek to beginning */:
2625           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) {
2626             return;
2627           }
2628           if (!isVideoStreaming) {
2629             this.videoWrapper.setCurrentTime(video, 0);
2630           }
2631           break;
2632         case "end" /* Seek to end */:
2633           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) {
2634             return;
2635           }
2637           let duration = this.videoWrapper.getDuration(video);
2638           if (
2639             !isVideoStreaming &&
2640             this.videoWrapper.getCurrentTime(video) != duration
2641           ) {
2642             this.videoWrapper.setCurrentTime(video, duration);
2643           }
2644           break;
2645         default:
2646       }
2647     } catch (e) {
2648       /* ignore any exception from setting video.currentTime */
2649     }
2650   }
2652   get isSubtitlesEnabled() {
2653     return this.#subtitlesEnabled;
2654   }
2656   set isSubtitlesEnabled(val) {
2657     if (val) {
2658       Services.telemetry.recordEvent(
2659         "pictureinpicture",
2660         "subtitles_shown",
2661         "subtitles",
2662         null,
2663         {
2664           webVTTSubtitles: (!!this.getWeakVideo().textTracks
2665             ?.length).toString(),
2666         }
2667       );
2668     } else {
2669       this.sendAsyncMessage("PictureInPicture:DisableSubtitlesButton");
2670     }
2671     this.#subtitlesEnabled = val;
2672   }
2676  * The PictureInPictureChildVideoWrapper class handles providing a path to a script that
2677  * defines a "site wrapper" for the original <video> (or other controls API provided
2678  * by the site) to command it.
2680  * This "site wrapper" provided to PictureInPictureChildVideoWrapper is a script file that
2681  * defines a class called `PictureInPictureVideoWrapper` and exports it. These scripts can
2682  * be found under "browser/extensions/pictureinpicture/video-wrappers" as part of the
2683  * Picture-In-Picture addon.
2685  * Site wrappers need to adhere to a specific interface to work properly with
2686  * PictureInPictureChildVideoWrapper:
2688  * - The "site wrapper" script must export a class called "PictureInPictureVideoWrapper"
2689  * - Method names on a site wrapper class should match its caller's name
2690  *   (i.e: PictureInPictureChildVideoWrapper.play will only call `play` on a site-wrapper, if available)
2691  */
2692 class PictureInPictureChildVideoWrapper {
2693   #sandbox;
2694   #siteWrapper;
2695   #PictureInPictureChild;
2697   /**
2698    * Create a wrapper for the original <video>
2699    *
2700    * @param {String|null} videoWrapperScriptPath
2701    *        Path to a wrapper script from the Picture-in-Picture addon. If a wrapper isn't
2702    *        provided to the class, then we fallback on a default implementation for
2703    *        commanding the original <video>.
2704    * @param {HTMLVideoElement} video
2705    *        The original <video> we want to create a wrapper class for.
2706    * @param {Object} pipChild
2707    *        Reference to PictureInPictureChild class calling this function.
2708    */
2709   constructor(videoWrapperScriptPath, video, pipChild) {
2710     this.#sandbox = videoWrapperScriptPath
2711       ? this.#createSandbox(videoWrapperScriptPath, video)
2712       : null;
2713     this.#PictureInPictureChild = pipChild;
2714   }
2716   /**
2717    * Handles calling methods defined on the site wrapper class to perform video
2718    * controls operations on the source video. If the method doesn't exist,
2719    * or if an error is thrown while calling it, use a fallback implementation.
2720    *
2721    * @param {String} methodInfo.name
2722    *        The method name to call.
2723    * @param {Array} methodInfo.args
2724    *        Arguments to pass to the site wrapper method being called.
2725    * @param {Function} methodInfo.fallback
2726    *        A fallback function that's invoked when a method doesn't exist on the site
2727    *        wrapper class or an error is thrown while calling a method
2728    * @param {Function} methodInfo.validateReturnVal
2729    *        Validates whether or not the return value of the wrapper method is correct.
2730    *        If this isn't provided or if it evaluates false for a return value, then
2731    *        return null.
2732    *
2733    * @returns The expected output of the wrapper function.
2734    */
2735   #callWrapperMethod({ name, args = [], fallback = () => {}, validateRetVal }) {
2736     try {
2737       const wrappedMethod = this.#siteWrapper?.[name];
2738       if (typeof wrappedMethod === "function") {
2739         let retVal = wrappedMethod.call(this.#siteWrapper, ...args);
2741         if (!validateRetVal) {
2742           lazy.logConsole.error(
2743             `No return value validator was provided for method ${name}(). Returning null.`
2744           );
2745           return null;
2746         }
2748         if (!validateRetVal(retVal)) {
2749           lazy.logConsole.error(
2750             `Calling method ${name}() returned an unexpected value: ${retVal}. Returning null.`
2751           );
2752           return null;
2753         }
2755         return retVal;
2756       }
2757     } catch (e) {
2758       lazy.logConsole.error(
2759         `There was an error while calling ${name}(): `,
2760         e.message
2761       );
2762     }
2764     return fallback();
2765   }
2767   /**
2768    * Creates a sandbox with Xray vision to execute content code in an unprivileged
2769    * context. This way, privileged code (PictureInPictureChild) can call into the
2770    * sandbox to perform video controls operations on the originating video
2771    * (content code) and still be protected from direct access by it.
2772    *
2773    * @param {String} videoWrapperScriptPath
2774    *        Path to a wrapper script from the Picture-in-Picture addon.
2775    * @param {HTMLVideoElement} video
2776    *        The source video element whose window to create a sandbox for.
2777    */
2778   #createSandbox(videoWrapperScriptPath, video) {
2779     const addonPolicy = WebExtensionPolicy.getByID(
2780       "pictureinpicture@mozilla.org"
2781     );
2782     let wrapperScriptUrl = addonPolicy.getURL(videoWrapperScriptPath);
2783     let originatingWin = video.ownerGlobal;
2784     let originatingDoc = video.ownerDocument;
2786     let sandbox = Cu.Sandbox([originatingDoc.nodePrincipal], {
2787       sandboxName: "Picture-in-Picture video wrapper sandbox",
2788       sandboxPrototype: originatingWin,
2789       sameZoneAs: originatingWin,
2790       wantXrays: false,
2791     });
2793     try {
2794       Services.scriptloader.loadSubScript(wrapperScriptUrl, sandbox);
2795     } catch (e) {
2796       Cu.nukeSandbox(sandbox);
2797       lazy.logConsole.error(
2798         "Error loading wrapper script for Picture-in-Picture",
2799         e
2800       );
2801       return null;
2802     }
2804     // The prototype of the wrapper class instantiated from the sandbox with Xray
2805     // vision is `Object` and not actually `PictureInPictureVideoWrapper`. But we
2806     // need to be able to access methods defined on this class to perform site-specific
2807     // video control operations otherwise we fallback to a default implementation.
2808     // Because of this, we need to "waive Xray vision" by adding `.wrappedObject` to the
2809     // end.
2810     this.#siteWrapper = new sandbox.PictureInPictureVideoWrapper(
2811       video
2812     ).wrappedJSObject;
2814     return sandbox;
2815   }
2817   #isBoolean(val) {
2818     return typeof val === "boolean";
2819   }
2821   #isNumber(val) {
2822     return typeof val === "number";
2823   }
2825   /**
2826    * Destroys the sandbox for the site wrapper class
2827    */
2828   destroy() {
2829     if (this.#sandbox) {
2830       Cu.nukeSandbox(this.#sandbox);
2831     }
2832   }
2834   /**
2835    * Function to display the captions on the PiP window
2836    * @param text The captions to be shown on the PiP window
2837    */
2838   updatePiPTextTracks(text) {
2839     if (!this.#PictureInPictureChild.isSubtitlesEnabled && text) {
2840       this.#PictureInPictureChild.isSubtitlesEnabled = true;
2841       this.#PictureInPictureChild.sendAsyncMessage(
2842         "PictureInPicture:EnableSubtitlesButton"
2843       );
2844     }
2845     let pipWindowTracksContainer = this.#PictureInPictureChild.document.getElementById(
2846       "texttracks"
2847     );
2848     pipWindowTracksContainer.textContent = text;
2849   }
2851   /* Video methods to be used for video controls from the PiP window. */
2853   /**
2854    * OVERRIDABLE - calls the play() method defined in the site wrapper script. Runs a fallback implementation
2855    * if the method does not exist or if an error is thrown while calling it. This method is meant to handle video
2856    * behaviour when a video is played.
2857    * @param {HTMLVideoElement} video
2858    *  The originating video source element
2859    */
2860   play(video) {
2861     return this.#callWrapperMethod({
2862       name: "play",
2863       args: [video],
2864       fallback: () => video.play(),
2865       validateRetVal: retVal => retVal == null,
2866     });
2867   }
2869   /**
2870    * OVERRIDABLE - calls the pause() method defined in the site wrapper script. Runs a fallback implementation
2871    * if the method does not exist or if an error is thrown while calling it. This method is meant to handle video
2872    * behaviour when a video is paused.
2873    * @param {HTMLVideoElement} video
2874    *  The originating video source element
2875    */
2876   pause(video) {
2877     return this.#callWrapperMethod({
2878       name: "pause",
2879       args: [video],
2880       fallback: () => video.pause(),
2881       validateRetVal: retVal => retVal == null,
2882     });
2883   }
2885   /**
2886    * OVERRIDABLE - calls the getPaused() method defined in the site wrapper script. Runs a fallback implementation
2887    * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if
2888    * a video is paused or not.
2889    * @param {HTMLVideoElement} video
2890    *  The originating video source element
2891    * @returns {Boolean} Boolean value true if paused, or false if video is still playing
2892    */
2893   getPaused(video) {
2894     return this.#callWrapperMethod({
2895       name: "getPaused",
2896       args: [video],
2897       fallback: () => video.paused,
2898       validateRetVal: retVal => this.#isBoolean(retVal),
2899     });
2900   }
2902   /**
2903    * OVERRIDABLE - calls the getEnded() method defined in the site wrapper script. Runs a fallback implementation
2904    * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if
2905    * video playback or streaming has stopped.
2906    * @param {HTMLVideoElement} video
2907    *  The originating video source element
2908    * @returns {Boolean} Boolean value true if the video has ended, or false if still playing
2909    */
2910   getEnded(video) {
2911     return this.#callWrapperMethod({
2912       name: "getEnded",
2913       args: [video],
2914       fallback: () => video.ended,
2915       validateRetVal: retVal => this.#isBoolean(retVal),
2916     });
2917   }
2919   /**
2920    * OVERRIDABLE - calls the getDuration() method defined in the site wrapper script. Runs a fallback implementation
2921    * if the method does not exist or if an error is thrown while calling it. This method is meant to get the current
2922    * duration of a video in seconds.
2923    * @param {HTMLVideoElement} video
2924    *  The originating video source element
2925    * @returns {Number} Duration of the video in seconds
2926    */
2927   getDuration(video) {
2928     return this.#callWrapperMethod({
2929       name: "getDuration",
2930       args: [video],
2931       fallback: () => video.duration,
2932       validateRetVal: retVal => this.#isNumber(retVal),
2933     });
2934   }
2936   /**
2937    * OVERRIDABLE - calls the getCurrentTime() method defined in the site wrapper script. Runs a fallback implementation
2938    * if the method does not exist or if an error is thrown while calling it. This method is meant to get the current
2939    * time of a video in seconds.
2940    * @param {HTMLVideoElement} video
2941    *  The originating video source element
2942    * @returns {Number} Current time of the video in seconds
2943    */
2944   getCurrentTime(video) {
2945     return this.#callWrapperMethod({
2946       name: "getCurrentTime",
2947       args: [video],
2948       fallback: () => video.currentTime,
2949       validateRetVal: retVal => this.#isNumber(retVal),
2950     });
2951   }
2953   /**
2954    * OVERRIDABLE - calls the setCurrentTime() method defined in the site wrapper script. Runs a fallback implementation
2955    * if the method does not exist or if an error is thrown while calling it. This method is meant to set the current
2956    * time of a video.
2957    * @param {HTMLVideoElement} video
2958    *  The originating video source element
2959    * @param {Number} position
2960    *  The current playback time of the video
2961    * @param {Boolean} wasPlaying
2962    *  True if the video was playing before seeking else false
2963    */
2964   setCurrentTime(video, position, wasPlaying) {
2965     return this.#callWrapperMethod({
2966       name: "setCurrentTime",
2967       args: [video, position, wasPlaying],
2968       fallback: () => {
2969         video.currentTime = position;
2970       },
2971       validateRetVal: retVal => retVal == null,
2972     });
2973   }
2975   /**
2976    * Return hours, minutes, and seconds from seconds
2977    * @param {Number} aSeconds
2978    *  The time in seconds
2979    * @returns {String} Timestamp string
2980    **/
2981   timeFromSeconds(aSeconds) {
2982     aSeconds = isNaN(aSeconds) ? 0 : Math.round(aSeconds);
2983     let seconds = Math.floor(aSeconds % 60),
2984       minutes = Math.floor((aSeconds / 60) % 60),
2985       hours = Math.floor(aSeconds / 3600);
2986     seconds = seconds < 10 ? "0" + seconds : seconds;
2987     minutes = hours > 0 && minutes < 10 ? "0" + minutes : minutes;
2988     return aSeconds < 3600
2989       ? `${minutes}:${seconds}`
2990       : `${hours}:${minutes}:${seconds}`;
2991   }
2993   /**
2994    * Format a timestamp from current time and total duration,
2995    * output as a string in the form '0:00 / 0:00'
2996    * @param {Number} aCurrentTime
2997    *  The current time in seconds
2998    * @param {Number} aDuration
2999    *  The total duration in seconds
3000    * @returns {String} Formatted timestamp
3001    **/
3002   formatTimestamp(aCurrentTime, aDuration) {
3003     // We can't format numbers that can't be represented as decimal digits.
3004     if (!Number.isFinite(aCurrentTime) || !Number.isFinite(aDuration)) {
3005       return undefined;
3006     }
3008     return `${this.timeFromSeconds(aCurrentTime)} / ${this.timeFromSeconds(
3009       aDuration
3010     )}`;
3011   }
3013   /**
3014    * OVERRIDABLE - calls the getVolume() method defined in the site wrapper script. Runs a fallback implementation
3015    * if the method does not exist or if an error is thrown while calling it. This method is meant to get the volume
3016    * value of a video.
3017    * @param {HTMLVideoElement} video
3018    *  The originating video source element
3019    * @returns {Number} Volume of the video between 0 (muted) and 1 (loudest)
3020    */
3021   getVolume(video) {
3022     return this.#callWrapperMethod({
3023       name: "getVolume",
3024       args: [video],
3025       fallback: () => video.volume,
3026       validateRetVal: retVal => this.#isNumber(retVal),
3027     });
3028   }
3030   /**
3031    * OVERRIDABLE - calls the setVolume() method defined in the site wrapper script. Runs a fallback implementation
3032    * if the method does not exist or if an error is thrown while calling it. This method is meant to set the volume
3033    * value of a video.
3034    * @param {HTMLVideoElement} video
3035    *  The originating video source element
3036    * @param {Number} volume
3037    *  Value between 0 (muted) and 1 (loudest)
3038    */
3039   setVolume(video, volume) {
3040     return this.#callWrapperMethod({
3041       name: "setVolume",
3042       args: [video, volume],
3043       fallback: () => {
3044         video.volume = volume;
3045       },
3046       validateRetVal: retVal => retVal == null,
3047     });
3048   }
3050   /**
3051    * OVERRIDABLE - calls the isMuted() method defined in the site wrapper script. Runs a fallback implementation
3052    * if the method does not exist or if an error is thrown while calling it. This method is meant to get the mute
3053    * state a video.
3054    * @param {HTMLVideoElement} video
3055    *  The originating video source element
3056    * @param {Boolean} shouldMute
3057    *  Boolean value true to mute the video, or false to unmute the video
3058    */
3059   isMuted(video) {
3060     return this.#callWrapperMethod({
3061       name: "isMuted",
3062       args: [video],
3063       fallback: () => video.muted,
3064       validateRetVal: retVal => this.#isBoolean(retVal),
3065     });
3066   }
3068   /**
3069    * OVERRIDABLE - calls the setMuted() method defined in the site wrapper script. Runs a fallback implementation
3070    * if the method does not exist or if an error is thrown while calling it. This method is meant to mute or unmute
3071    * a video.
3072    * @param {HTMLVideoElement} video
3073    *  The originating video source element
3074    * @param {Boolean} shouldMute
3075    *  Boolean value true to mute the video, or false to unmute the video
3076    */
3077   setMuted(video, shouldMute) {
3078     return this.#callWrapperMethod({
3079       name: "setMuted",
3080       args: [video, shouldMute],
3081       fallback: () => {
3082         video.muted = shouldMute;
3083       },
3084       validateRetVal: retVal => retVal == null,
3085     });
3086   }
3088   /**
3089    * OVERRIDABLE - calls the setCaptionContainerObserver() method defined in the site wrapper script. Runs a fallback implementation
3090    * 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
3091    * video's caption container and execute a callback function responsible for updating the pip window's text tracks container whenever
3092    * a cue change is triggered {@see updatePiPTextTracks()}.
3093    * @param {HTMLVideoElement} video
3094    *  The originating video source element
3095    * @param {Function} callback
3096    *  The callback function to be executed when cue changes are detected
3097    */
3098   setCaptionContainerObserver(video, callback) {
3099     return this.#callWrapperMethod({
3100       name: "setCaptionContainerObserver",
3101       args: [
3102         video,
3103         text => {
3104           this.updatePiPTextTracks(text);
3105         },
3106       ],
3107       fallback: () => {},
3108       validateRetVal: retVal => retVal == null,
3109     });
3110   }
3112   /**
3113    * OVERRIDABLE - calls the shouldHideToggle() method defined in the site wrapper script. Runs a fallback implementation
3114    * 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
3115    * for a video should be hidden by the site wrapper.
3116    * @param {HTMLVideoElement} video
3117    *  The originating video source element
3118    * @returns {Boolean} Boolean value true if the pip toggle should be hidden by the site wrapper, or false if it should not
3119    */
3120   shouldHideToggle(video) {
3121     return this.#callWrapperMethod({
3122       name: "shouldHideToggle",
3123       args: [video],
3124       fallback: () => false,
3125       validateRetVal: retVal => this.#isBoolean(retVal),
3126     });
3127   }
3129   /**
3130    * OVERRIDABLE - calls the isLive() method defined in the site wrapper script. Runs a fallback implementation
3131    * if the method does not exist or if an error is thrown while calling it. This method is meant to get if the
3132    * video is a live stream.
3133    * @param {HTMLVideoElement} video
3134    *  The originating video source element
3135    */
3136   isLive(video) {
3137     return this.#callWrapperMethod({
3138       name: "isLive",
3139       args: [video],
3140       fallback: () => video.duration === Infinity,
3141       validateRetVal: retVal => this.#isBoolean(retVal),
3142     });
3143   }