Backed out changeset 4191b252db9b (bug 1886734) for causing build bustages @netwerk...
[gecko.git] / toolkit / actors / PictureInPictureChild.sys.mjs
blobbffcb55e6d67973ba4c15d361e7107444c470349
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 ChromeUtils.defineLazyGetter(lazy, "gSiteOverrides", () => {
90   return PictureInPictureToggleChild.getSiteOverrides();
91 });
93 ChromeUtils.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?.reason,
142             eventExtraKeys: event.detail?.eventExtraKeys,
143           });
144         }
145         break;
146       }
147     }
148   }
150   receiveMessage(message) {
151     switch (message.name) {
152       case "PictureInPicture:KeyToggle": {
153         this.keyToggle();
154         break;
155       }
156     }
157   }
159   /**
160    * Tells the parent to open a Picture-in-Picture window hosting
161    * a clone of the passed video. If we know about a pre-existing
162    * Picture-in-Picture window existing, this tells the parent to
163    * close it before opening the new one.
164    *
165    * @param {Object} pipObject
166    * @param {HTMLVideoElement} pipObject.video
167    * @param {String} pipObject.reason What toggled PiP, e.g. "shortcut"
168    * @param {Object} pipObject.eventExtraKeys Extra telemetry keys to record
169    *
170    * @return {Promise}
171    * @resolves {undefined} Once the new Picture-in-Picture window
172    * has been requested.
173    */
174   async togglePictureInPicture(pipObject) {
175     let { video, reason, eventExtraKeys = {} } = pipObject;
176     if (video.isCloningElementVisually) {
177       // The only way we could have entered here for the same video is if
178       // we are toggling via the context menu or via the urlbar button,
179       // since we hide the inline Picture-in-Picture toggle when a video
180       // is being displayed in Picture-in-Picture. Turn off PiP in this case
181       const stopPipEvent = new this.contentWindow.CustomEvent(
182         "MozStopPictureInPicture",
183         {
184           bubbles: true,
185           detail: { reason },
186         }
187       );
188       video.dispatchEvent(stopPipEvent);
189       return;
190     }
192     if (!PictureInPictureChild.videoWrapper) {
193       PictureInPictureChild.videoWrapper = applyWrapper(
194         PictureInPictureChild,
195         video
196       );
197     }
199     let timestamp = undefined;
200     let scrubberPosition = undefined;
202     if (lazy.IMPROVED_CONTROLS_ENABLED_PREF) {
203       timestamp = PictureInPictureChild.videoWrapper.formatTimestamp(
204         PictureInPictureChild.videoWrapper.getCurrentTime(video),
205         PictureInPictureChild.videoWrapper.getDuration(video)
206       );
208       // Scrubber is hidden if undefined, so only set it to something else
209       // if the timestamp is not undefined.
210       scrubberPosition =
211         timestamp === undefined
212           ? undefined
213           : PictureInPictureChild.videoWrapper.getCurrentTime(video) /
214             PictureInPictureChild.videoWrapper.getDuration(video);
215     }
217     // All other requests to toggle PiP should open a new PiP
218     // window
219     const videoRef = lazy.ContentDOMReference.get(video);
220     this.sendAsyncMessage("PictureInPicture:Request", {
221       isMuted: PictureInPictureChild.videoIsMuted(video),
222       playing: PictureInPictureChild.videoIsPlaying(video),
223       videoHeight: video.videoHeight,
224       videoWidth: video.videoWidth,
225       videoRef,
226       ccEnabled: lazy.DISPLAY_TEXT_TRACKS_PREF,
227       webVTTSubtitles: !!video.textTracks?.length,
228       scrubberPosition,
229       timestamp,
230       volume: PictureInPictureChild.videoWrapper.getVolume(video),
231     });
233     Services.telemetry.recordEvent(
234       "pictureinpicture",
235       "opened_method",
236       reason,
237       null,
238       {
239         firstTimeToggle: (!Services.prefs.getBoolPref(
240           TOGGLE_HAS_USED_PREF
241         )).toString(),
242         ...eventExtraKeys,
243       }
244     );
245   }
247   /**
248    * The keyboard was used to attempt to open Picture-in-Picture. If a video is focused,
249    * select that video. Otherwise find the first playing video, or if none, the largest
250    * dimension video. We suspect this heuristic will handle most cases, though we
251    * might refine this later on. Note that we assume that this method will only be
252    * called for the focused document.
253    */
254   keyToggle() {
255     let doc = this.document;
256     if (doc) {
257       let video = doc.activeElement;
258       if (!HTMLVideoElement.isInstance(video)) {
259         let listOfVideos = [...doc.querySelectorAll("video")].filter(
260           video => !isNaN(video.duration)
261         );
262         // Get the first non-paused video, otherwise the longest video. This
263         // fallback is designed to skip over "preview"-style videos on sidebars.
264         video =
265           listOfVideos.filter(v => !v.paused)[0] ||
266           listOfVideos.sort((a, b) => b.duration - a.duration)[0];
267       }
268       if (video) {
269         this.togglePictureInPicture({ video, reason: "shortcut" });
270       }
271     }
272   }
276  * The PictureInPictureToggleChild is responsible for displaying the overlaid
277  * Picture-in-Picture toggle over top of <video> elements that the mouse is
278  * hovering.
279  */
280 export class PictureInPictureToggleChild extends JSWindowActorChild {
281   constructor() {
282     super();
283     // We need to maintain some state about various things related to the
284     // Picture-in-Picture toggles - however, for now, the same
285     // PictureInPictureToggleChild might be re-used for different documents.
286     // We keep the state stashed inside of this WeakMap, keyed on the document
287     // itself.
288     this.weakDocStates = new WeakMap();
289     this.toggleEnabled =
290       Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF) &&
291       Services.prefs.getBoolPref(PIP_ENABLED_PREF);
292     this.toggleTesting = Services.prefs.getBoolPref(TOGGLE_TESTING_PREF, false);
294     // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's
295     // directly, so we create a new function here instead to act as our
296     // nsIObserver, which forwards the notification to the observe method.
297     this.observerFunction = (subject, topic, data) => {
298       this.observe(subject, topic, data);
299     };
300     Services.prefs.addObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
301     Services.prefs.addObserver(PIP_ENABLED_PREF, this.observerFunction);
302     Services.prefs.addObserver(TOGGLE_FIRST_SEEN_PREF, this.observerFunction);
303     Services.cpmm.sharedData.addEventListener("change", this);
305     this.eligiblePipVideos = new WeakSet();
306     this.trackingVideos = new WeakSet();
307   }
309   receiveMessage(message) {
310     switch (message.name) {
311       case "PictureInPicture:UrlbarToggle": {
312         this.urlbarToggle(message.data);
313         break;
314       }
315     }
316     return null;
317   }
319   didDestroy() {
320     this.stopTrackingMouseOverVideos();
321     Services.prefs.removeObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
322     Services.prefs.removeObserver(PIP_ENABLED_PREF, this.observerFunction);
323     Services.prefs.removeObserver(
324       TOGGLE_FIRST_SEEN_PREF,
325       this.observerFunction
326     );
327     Services.cpmm.sharedData.removeEventListener("change", this);
329     // remove the observer on the <video> element
330     let state = this.docState;
331     if (state?.intersectionObserver) {
332       state.intersectionObserver.disconnect();
333     }
335     // ensure the sandbox created by the video is destroyed
336     this.videoWrapper?.destroy();
337     this.videoWrapper = null;
339     for (let video of ChromeUtils.nondeterministicGetWeakSetKeys(
340       this.eligiblePipVideos
341     )) {
342       video.removeEventListener("emptied", this);
343       video.removeEventListener("loadedmetadata", this);
344       video.removeEventListener("durationchange", this);
345     }
347     for (let video of ChromeUtils.nondeterministicGetWeakSetKeys(
348       this.trackingVideos
349     )) {
350       video.removeEventListener("emptied", this);
351       video.removeEventListener("loadedmetadata", this);
352       video.removeEventListener("durationchange", this);
353     }
355     // ensure we don't access the state
356     this.isDestroyed = true;
357   }
359   observe(subject, topic, data) {
360     if (topic != "nsPref:changed") {
361       return;
362     }
364     this.toggleEnabled =
365       Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF) &&
366       Services.prefs.getBoolPref(PIP_ENABLED_PREF);
368     if (this.toggleEnabled) {
369       // We have enabled the Picture-in-Picture toggle, so we need to make
370       // sure we register all of the videos that might already be on the page.
371       this.contentWindow.requestIdleCallback(() => {
372         let videos = this.document.querySelectorAll("video");
373         for (let video of videos) {
374           this.registerVideo(video);
375         }
376       });
377     }
379     switch (data) {
380       case TOGGLE_FIRST_SEEN_PREF:
381         const firstSeenSeconds = Services.prefs.getIntPref(
382           TOGGLE_FIRST_SEEN_PREF
383         );
384         if (!firstSeenSeconds || firstSeenSeconds < 0) {
385           return;
386         }
387         this.changeToIconIfDurationEnd(firstSeenSeconds);
388         break;
389     }
390   }
392   /**
393    * Returns the state for the current document referred to via
394    * this.document. If no such state exists, creates it, stores it
395    * and returns it.
396    */
397   get docState() {
398     if (this.isDestroyed || !this.document) {
399       return false;
400     }
402     let state = this.weakDocStates.get(this.document);
404     let visibilityThresholdPref = Services.prefs.getFloatPref(
405       TOGGLE_VISIBILITY_THRESHOLD_PREF,
406       "1.0"
407     );
409     if (!state) {
410       state = {
411         // A reference to the IntersectionObserver that's monitoring for videos
412         // to become visible.
413         intersectionObserver: null,
414         // A WeakSet of videos that are supposedly visible, according to the
415         // IntersectionObserver.
416         weakVisibleVideos: new WeakSet(),
417         // The number of videos that are supposedly visible, according to the
418         // IntersectionObserver
419         visibleVideosCount: 0,
420         // The DeferredTask that we'll arm every time a mousemove event occurs
421         // on a page where we have one or more visible videos.
422         mousemoveDeferredTask: null,
423         // A weak reference to the last video we displayed the toggle over.
424         weakOverVideo: null,
425         // True if the user is in the midst of clicking the toggle.
426         isClickingToggle: false,
427         // Set to the original target element on pointerdown if the user is clicking
428         // the toggle - this way, we can determine if a "click" event will need to be
429         // suppressed ("click" events don't fire if a "mouseup" occurs on a different
430         // element from the "pointerdown" / "mousedown" event).
431         clickedElement: null,
432         // This is a DeferredTask to hide the toggle after a period of mouse
433         // inactivity.
434         hideToggleDeferredTask: null,
435         // If we reach a point where we're tracking videos for mouse movements,
436         // then this will be true. If there are no videos worth tracking, then
437         // this is false.
438         isTrackingVideos: false,
439         togglePolicy: lazy.TOGGLE_POLICIES.DEFAULT,
440         toggleVisibilityThreshold: visibilityThresholdPref,
441         // The documentURI that has been checked with toggle policies and
442         // visibility thresholds for this document. Note that the documentURI
443         // might change for a document via the history API, so we remember
444         // the last checked documentURI to determine if we need to check again.
445         checkedPolicyDocumentURI: null,
446       };
447       this.weakDocStates.set(this.document, state);
448     }
450     return state;
451   }
453   /**
454    * Returns the video that the user was last hovering with the mouse if it
455    * still exists.
456    *
457    * @return {Element} the <video> element that the user was last hovering,
458    * or null if there was no such <video>, or the <video> no longer exists.
459    */
460   getWeakOverVideo() {
461     let { weakOverVideo } = this.docState;
462     if (weakOverVideo) {
463       // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
464       // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
465       try {
466         return weakOverVideo.get();
467       } catch (e) {
468         return null;
469       }
470     }
471     return null;
472   }
474   handleEvent(event) {
475     if (!event.isTrusted) {
476       // We don't care about synthesized events that might be coming from
477       // content JS.
478       return;
479     }
481     // Don't capture events from Picture-in-Picture content windows
482     if (gPlayerContents.has(this.contentWindow)) {
483       return;
484     }
486     switch (event.type) {
487       case "touchstart": {
488         // Even if this is a touch event, there may be subsequent click events.
489         // Suppress those events after selecting the toggle to prevent playback changes
490         // when opening the Picture-in-Picture window.
491         if (this.docState.isClickingToggle) {
492           event.stopImmediatePropagation();
493           event.preventDefault();
494         }
495         break;
496       }
497       case "change": {
498         const { changedKeys } = event;
499         if (changedKeys.includes("PictureInPicture:SiteOverrides")) {
500           // For now we only update our cache if the site overrides change.
501           // the user will need to refresh the page for changes to apply.
502           try {
503             lazy.gSiteOverrides =
504               PictureInPictureToggleChild.getSiteOverrides();
505           } catch (e) {
506             // Ignore resulting TypeError if gSiteOverrides is still unloaded
507             if (!(e instanceof TypeError)) {
508               throw e;
509             }
510           }
511         }
512         break;
513       }
514       case "UAWidgetSetupOrChange": {
515         if (
516           this.toggleEnabled &&
517           this.contentWindow.HTMLVideoElement.isInstance(event.target) &&
518           event.target.ownerDocument == this.document
519         ) {
520           this.registerVideo(event.target);
521         }
522         break;
523       }
524       case "contextmenu": {
525         if (this.toggleEnabled) {
526           this.checkContextMenu(event);
527         }
528         break;
529       }
530       case "mouseout": {
531         this.onMouseOut(event);
532         break;
533       }
534       case "click":
535         if (event.detail == 0) {
536           let shadowRoot = event.originalTarget.containingShadowRoot;
537           let toggle = this.getToggleElement(shadowRoot);
538           if (event.originalTarget == toggle) {
539             this.startPictureInPicture(event, shadowRoot.host, toggle);
540             return;
541           }
542         }
543       // fall through
544       case "mousedown":
545       case "pointerup":
546       case "mouseup": {
547         this.onMouseButtonEvent(event);
548         break;
549       }
550       case "pointerdown": {
551         this.onPointerDown(event);
552         break;
553       }
554       case "mousemove": {
555         this.onMouseMove(event);
556         break;
557       }
558       case "pageshow": {
559         this.onPageShow(event);
560         break;
561       }
562       case "pagehide": {
563         this.onPageHide(event);
564         break;
565       }
566       case "durationchange":
567       // Intentional fall-through
568       case "emptied":
569       // Intentional fall-through
570       case "loadedmetadata": {
571         this.updatePipVideoEligibility(event.target);
572         break;
573       }
574     }
575   }
577   /**
578    * Adds a <video> to the IntersectionObserver so that we know when it becomes
579    * visible.
580    *
581    * @param {Element} video The <video> element to register.
582    */
583   registerVideo(video) {
584     let state = this.docState;
585     if (!state.intersectionObserver) {
586       let fn = this.onIntersection.bind(this);
587       state.intersectionObserver = new this.contentWindow.IntersectionObserver(
588         fn,
589         {
590           threshold: [0.0, 0.5],
591         }
592       );
593     }
595     state.intersectionObserver.observe(video);
597     if (!lazy.PIP_URLBAR_BUTTON) {
598       return;
599     }
601     video.addEventListener("emptied", this);
602     video.addEventListener("loadedmetadata", this);
603     video.addEventListener("durationchange", this);
605     this.trackingVideos.add(video);
607     this.updatePipVideoEligibility(video);
608   }
610   updatePipVideoEligibility(video) {
611     let isEligible = this.isVideoPiPEligible(video);
612     if (isEligible) {
613       if (!this.eligiblePipVideos.has(video)) {
614         this.eligiblePipVideos.add(video);
616         let mutationObserver = new this.contentWindow.MutationObserver(
617           mutationList => {
618             this.handleEligiblePipVideoMutation(mutationList);
619           }
620         );
621         mutationObserver.observe(video.parentElement, { childList: true });
622       }
623     } else if (this.eligiblePipVideos.has(video)) {
624       this.eligiblePipVideos.delete(video);
625     }
627     let videos = ChromeUtils.nondeterministicGetWeakSetKeys(
628       this.eligiblePipVideos
629     );
631     this.sendAsyncMessage("PictureInPicture:UpdateEligiblePipVideoCount", {
632       pipCount: videos.length,
633       pipDisabledCount: videos.reduce(
634         (accumulator, currentVal) =>
635           accumulator + (currentVal.disablePictureInPicture ? 1 : 0),
636         0
637       ),
638     });
639   }
641   handleEligiblePipVideoMutation(mutationList) {
642     for (let mutationRecord of mutationList) {
643       let video = mutationRecord.removedNodes[0];
644       this.eligiblePipVideos.delete(video);
645     }
647     let videos = ChromeUtils.nondeterministicGetWeakSetKeys(
648       this.eligiblePipVideos
649     );
651     this.sendAsyncMessage("PictureInPicture:UpdateEligiblePipVideoCount", {
652       pipCount: videos.length,
653       pipDisabledCount: videos.reduce(
654         (accumulator, currentVal) =>
655           accumulator + (currentVal.disablePictureInPicture ? 1 : 0),
656         0
657       ),
658     });
659   }
661   urlbarToggle(eventExtraKeys) {
662     let video = ChromeUtils.nondeterministicGetWeakSetKeys(
663       this.eligiblePipVideos
664     )[0];
665     if (video) {
666       let pipEvent = new this.contentWindow.CustomEvent(
667         "MozTogglePictureInPicture",
668         {
669           bubbles: true,
670           detail: { reason: "urlBar", eventExtraKeys },
671         }
672       );
673       video.dispatchEvent(pipEvent);
674     }
675   }
677   isVideoPiPEligible(video) {
678     if (lazy.PIP_TOGGLE_ALWAYS_SHOW) {
679       return true;
680     }
682     if (isNaN(video.duration) || video.duration < lazy.MIN_VIDEO_LENGTH) {
683       return false;
684     }
686     const MIN_VIDEO_DIMENSION = 140; // pixels
687     if (
688       video.clientWidth < MIN_VIDEO_DIMENSION ||
689       video.clientHeight < MIN_VIDEO_DIMENSION
690     ) {
691       return false;
692     }
694     return true;
695   }
697   /**
698    * Changes from the first-time toggle to the icon toggle if the Nimbus variable `displayDuration`'s
699    * end date is reached when hovering over a video. The end date is calculated according to the timestamp
700    * indicating when the PiP toggle was first seen.
701    * @param {Number} firstSeenStartSeconds the timestamp in seconds indicating when the PiP toggle was first seen
702    */
703   changeToIconIfDurationEnd(firstSeenStartSeconds) {
704     const { displayDuration } =
705       lazy.NimbusFeatures.pictureinpicture.getAllVariables({
706         defaultValues: {
707           displayDuration: TOGGLE_FIRST_TIME_DURATION_DAYS,
708         },
709       });
710     if (!displayDuration || displayDuration < 0) {
711       return;
712     }
714     let daysInSeconds = displayDuration * 24 * 60 * 60;
715     let firstSeenEndSeconds = daysInSeconds + firstSeenStartSeconds;
716     let currentDateSeconds = Math.round(Date.now() / 1000);
718     lazy.logConsole.debug(
719       "Toggle duration experiment - first time toggle seen on:",
720       new Date(firstSeenStartSeconds * 1000).toLocaleDateString()
721     );
722     lazy.logConsole.debug(
723       "Toggle duration experiment - first time toggle will change on:",
724       new Date(firstSeenEndSeconds * 1000).toLocaleDateString()
725     );
726     lazy.logConsole.debug(
727       "Toggle duration experiment - current date:",
728       new Date(currentDateSeconds * 1000).toLocaleDateString()
729     );
731     if (currentDateSeconds >= firstSeenEndSeconds) {
732       this.sendAsyncMessage("PictureInPicture:SetHasUsed", {
733         hasUsed: true,
734       });
735     }
736   }
738   /**
739    * Called by the IntersectionObserver callback once a video becomes visible.
740    * This adds some fine-grained checking to ensure that a sufficient amount of
741    * the video is visible before we consider showing the toggles on it. For now,
742    * that means that the entirety of the video must be in the viewport.
743    *
744    * @param {IntersectionEntry} intersectionEntry An IntersectionEntry passed to
745    * the IntersectionObserver callback.
746    * @return bool Whether or not we should start tracking mousemove events for
747    * this registered video.
748    */
749   worthTracking(intersectionEntry) {
750     return intersectionEntry.isIntersecting;
751   }
753   /**
754    * Called by the IntersectionObserver once a video crosses one of the
755    * thresholds dictated by the IntersectionObserver configuration.
756    *
757    * @param {Array<IntersectionEntry>} A collection of one or more
758    * IntersectionEntry's for <video> elements that might have entered or exited
759    * the viewport.
760    */
761   onIntersection(entries) {
762     // The IntersectionObserver will also fire when a previously intersecting
763     // element is removed from the DOM. We know, however, that the node is
764     // still alive and referrable from the WeakSet because the
765     // IntersectionObserverEntry holds a strong reference to the video.
766     let state = this.docState;
767     if (!state) {
768       return;
769     }
770     let oldVisibleVideosCount = state.visibleVideosCount;
771     for (let entry of entries) {
772       let video = entry.target;
773       if (this.worthTracking(entry)) {
774         if (!state.weakVisibleVideos.has(video)) {
775           state.weakVisibleVideos.add(video);
776           state.visibleVideosCount++;
777           if (this.toggleTesting) {
778             gWeakIntersectingVideosForTesting.add(video);
779           }
780         }
781       } else if (state.weakVisibleVideos.has(video)) {
782         state.weakVisibleVideos.delete(video);
783         state.visibleVideosCount--;
784         if (this.toggleTesting) {
785           gWeakIntersectingVideosForTesting.delete(video);
786         }
787       }
788     }
790     // For testing, especially in debug or asan builds, we might not
791     // run this idle callback within an acceptable time. While we're
792     // testing, we'll bypass the idle callback performance optimization
793     // and run our callbacks as soon as possible during the next idle
794     // period.
795     if (!oldVisibleVideosCount && state.visibleVideosCount) {
796       if (this.toggleTesting || !this.contentWindow) {
797         this.beginTrackingMouseOverVideos();
798       } else {
799         this.contentWindow.requestIdleCallback(() => {
800           this.beginTrackingMouseOverVideos();
801         });
802       }
803     } else if (oldVisibleVideosCount && !state.visibleVideosCount) {
804       if (this.toggleTesting || !this.contentWindow) {
805         this.stopTrackingMouseOverVideos();
806       } else {
807         this.contentWindow.requestIdleCallback(() => {
808           this.stopTrackingMouseOverVideos();
809         });
810       }
811     }
812   }
814   addMouseButtonListeners() {
815     // We want to try to cancel the mouse events from continuing
816     // on into content if the user has clicked on the toggle, so
817     // we don't use the mozSystemGroup here, and add the listener
818     // to the parent target of the window, which in this case,
819     // is the windowRoot. Since this event listener is attached to
820     // part of the outer window, we need to also remove it in a
821     // pagehide event listener in the event that the page unloads
822     // before stopTrackingMouseOverVideos fires.
823     this.contentWindow.windowRoot.addEventListener("pointerdown", this, {
824       capture: true,
825     });
826     this.contentWindow.windowRoot.addEventListener("mousedown", this, {
827       capture: true,
828     });
829     this.contentWindow.windowRoot.addEventListener("mouseup", this, {
830       capture: true,
831     });
832     this.contentWindow.windowRoot.addEventListener("pointerup", this, {
833       capture: true,
834     });
835     this.contentWindow.windowRoot.addEventListener("click", this, {
836       capture: true,
837     });
838     this.contentWindow.windowRoot.addEventListener("mouseout", this, {
839       capture: true,
840     });
841     this.contentWindow.windowRoot.addEventListener("touchstart", this, {
842       capture: true,
843     });
844   }
846   removeMouseButtonListeners() {
847     // This can be null when closing the tab, but the event
848     // listeners should be removed in that case already.
849     if (!this.contentWindow || !this.contentWindow.windowRoot) {
850       return;
851     }
853     this.contentWindow.windowRoot.removeEventListener("pointerdown", this, {
854       capture: true,
855     });
856     this.contentWindow.windowRoot.removeEventListener("mousedown", this, {
857       capture: true,
858     });
859     this.contentWindow.windowRoot.removeEventListener("mouseup", this, {
860       capture: true,
861     });
862     this.contentWindow.windowRoot.removeEventListener("pointerup", this, {
863       capture: true,
864     });
865     this.contentWindow.windowRoot.removeEventListener("click", this, {
866       capture: true,
867     });
868     this.contentWindow.windowRoot.removeEventListener("mouseout", this, {
869       capture: true,
870     });
871     this.contentWindow.windowRoot.removeEventListener("touchstart", this, {
872       capture: true,
873     });
874   }
876   /**
877    * One of the challenges of displaying this toggle is that many sites put
878    * things over top of <video> elements, like custom controls, or images, or
879    * all manner of things that might intercept mouseevents that would normally
880    * fire directly on the <video>. In order to properly detect when the mouse
881    * is over top of one of the <video> elements in this situation, we currently
882    * add a mousemove event handler to the entire document, and stash the most
883    * recent mousemove that fires. At periodic intervals, that stashed mousemove
884    * event is checked to see if it's hovering over one of our registered
885    * <video> elements.
886    *
887    * This sort of thing will not be necessary once bug 1539652 is fixed.
888    */
889   beginTrackingMouseOverVideos() {
890     let state = this.docState;
891     if (!state.mousemoveDeferredTask) {
892       state.mousemoveDeferredTask = new lazy.DeferredTask(() => {
893         this.checkLastMouseMove();
894       }, MOUSEMOVE_PROCESSING_DELAY_MS);
895     }
896     this.document.addEventListener("mousemove", this, {
897       mozSystemGroup: true,
898       capture: true,
899     });
900     this.contentWindow.addEventListener("pageshow", this, {
901       mozSystemGroup: true,
902     });
903     this.contentWindow.addEventListener("pagehide", this, {
904       mozSystemGroup: true,
905     });
906     this.addMouseButtonListeners();
907     state.isTrackingVideos = true;
908   }
910   /**
911    * If we no longer have any interesting videos in the viewport, we deregister
912    * the mousemove and click listeners, and also remove any toggles that might
913    * be on the page still.
914    */
915   stopTrackingMouseOverVideos() {
916     let state = this.docState;
917     // We initialize `mousemoveDeferredTask` in `beginTrackingMouseOverVideos`.
918     // If it doesn't exist, that can't have happened. Nothing else ever sets
919     // this value (though we arm/disarm in various places). So we don't need
920     // to do anything else here and can return early.
921     if (!state.mousemoveDeferredTask) {
922       return;
923     }
924     state.mousemoveDeferredTask.disarm();
925     this.document.removeEventListener("mousemove", this, {
926       mozSystemGroup: true,
927       capture: true,
928     });
929     if (this.contentWindow) {
930       this.contentWindow.removeEventListener("pageshow", this, {
931         mozSystemGroup: true,
932       });
933       this.contentWindow.removeEventListener("pagehide", this, {
934         mozSystemGroup: true,
935       });
936     }
937     this.removeMouseButtonListeners();
938     let oldOverVideo = this.getWeakOverVideo();
939     if (oldOverVideo) {
940       this.onMouseLeaveVideo(oldOverVideo);
941     }
942     state.isTrackingVideos = false;
943   }
945   /**
946    * This pageshow event handler will get called if and when we complete a tab
947    * tear out or in. If we happened to be tracking videos before the tear
948    * occurred, we re-add the mouse event listeners so that they're attached to
949    * the right WindowRoot.
950    */
951   onPageShow() {
952     let state = this.docState;
953     if (state.isTrackingVideos) {
954       this.addMouseButtonListeners();
955     }
956   }
958   /**
959    * This pagehide event handler will get called if and when we start a tab
960    * tear out or in. If we happened to be tracking videos before the tear
961    * occurred, we remove the mouse event listeners. We'll re-add them when the
962    * pageshow event fires.
963    */
964   onPageHide() {
965     let state = this.docState;
966     if (state.isTrackingVideos) {
967       this.removeMouseButtonListeners();
968     }
969   }
971   /**
972    * If we're tracking <video> elements, this pointerdown event handler is run anytime
973    * a pointerdown occurs on the document. This function is responsible for checking
974    * if the user clicked on the Picture-in-Picture toggle. It does this by first
975    * checking if the video is visible beneath the point that was clicked. Then
976    * it tests whether or not the pointerdown occurred within the rectangle of the
977    * toggle. If so, the event's propagation is stopped, and Picture-in-Picture is
978    * triggered.
979    *
980    * @param {Event} event The mousemove event.
981    */
982   onPointerDown(event) {
983     // The toggle ignores non-primary mouse clicks.
984     if (event.button != 0) {
985       return;
986     }
988     let video = this.getWeakOverVideo();
989     if (!video) {
990       return;
991     }
993     let shadowRoot = video.openOrClosedShadowRoot;
994     if (!shadowRoot) {
995       return;
996     }
998     let state = this.docState;
1000     let overVideo = (() => {
1001       let { clientX, clientY } = event;
1002       let winUtils = this.contentWindow.windowUtils;
1003       // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
1004       // since document.elementsFromPoint always flushes layout. The 1's in that
1005       // function call are for the size of the rect that we want, which is 1x1.
1006       //
1007       // We pass the aOnlyVisible boolean argument to check that the video isn't
1008       // occluded by anything visible at the point of mousedown. If it is, we'll
1009       // ignore the mousedown.
1010       let elements = winUtils.nodesFromRect(
1011         clientX,
1012         clientY,
1013         1,
1014         1,
1015         1,
1016         1,
1017         true,
1018         false,
1019         /* aOnlyVisible = */ true,
1020         state.toggleVisibilityThreshold
1021       );
1023       for (let element of elements) {
1024         if (element == video || element.containingShadowRoot == shadowRoot) {
1025           return true;
1026         }
1027       }
1029       return false;
1030     })();
1032     if (!overVideo) {
1033       return;
1034     }
1036     let toggle = this.getToggleElement(shadowRoot);
1037     if (this.isMouseOverToggle(toggle, event)) {
1038       state.isClickingToggle = true;
1039       state.clickedElement = Cu.getWeakReference(event.originalTarget);
1040       event.stopImmediatePropagation();
1042       this.startPictureInPicture(event, video, toggle);
1043     }
1044   }
1046   startPictureInPicture(event, video) {
1047     Services.telemetry.keyedScalarAdd(
1048       "pictureinpicture.opened_method",
1049       "toggle",
1050       1
1051     );
1053     let pipEvent = new this.contentWindow.CustomEvent(
1054       "MozTogglePictureInPicture",
1055       {
1056         bubbles: true,
1057         detail: { reason: "toggle" },
1058       }
1059     );
1060     video.dispatchEvent(pipEvent);
1062     // Since we've initiated Picture-in-Picture, we can go ahead and
1063     // hide the toggle now.
1064     this.onMouseLeaveVideo(video);
1065   }
1067   /**
1068    * Called for mousedown, pointerup, mouseup and click events. If we
1069    * detected that the user is clicking on the Picture-in-Picture toggle,
1070    * these events are cancelled in the capture-phase before they reach
1071    * content. The state for suppressing these events is cleared on the
1072    * click event (unless the mouseup occurs on a different element from
1073    * the mousedown, in which case, the state is cleared on mouseup).
1074    *
1075    * @param {Event} event A mousedown, pointerup, mouseup or click event.
1076    */
1077   onMouseButtonEvent(event) {
1078     // The toggle ignores non-primary mouse clicks.
1079     if (event.button != 0) {
1080       return;
1081     }
1083     let state = this.docState;
1084     if (state.isClickingToggle) {
1085       event.stopImmediatePropagation();
1087       // If this is a mouseup event, check to see if we have a record of what
1088       // the original target was on pointerdown. If so, and if it doesn't match
1089       // the mouseup original target, that means we won't get a click event, and
1090       // we can clear the "clicking the toggle" state right away.
1091       //
1092       // Otherwise, we wait for the click event to do that.
1093       let isMouseUpOnOtherElement =
1094         event.type == "mouseup" &&
1095         (!state.clickedElement ||
1096           state.clickedElement.get() != event.originalTarget);
1098       if (
1099         isMouseUpOnOtherElement ||
1100         event.type == "click" ||
1101         // pointerup event still triggers after a touchstart event. We just need to detect
1102         // the pointer type and determine if we got to this part of the code through a touch event.
1103         event.pointerType == "touch"
1104       ) {
1105         // The click is complete, so now we reset the state so that
1106         // we stop suppressing these events.
1107         state.isClickingToggle = false;
1108         state.clickedElement = null;
1109       }
1110     }
1111   }
1113   /**
1114    * Called on mouseout events to determine whether or not the mouse has
1115    * exited the window.
1116    *
1117    * @param {Event} event The mouseout event.
1118    */
1119   onMouseOut(event) {
1120     if (!event.relatedTarget) {
1121       // For mouseout events, if there's no relatedTarget (which normally
1122       // maps to the element that the mouse entered into) then this means that
1123       // we left the window.
1124       let video = this.getWeakOverVideo();
1125       if (!video) {
1126         return;
1127       }
1129       this.onMouseLeaveVideo(video);
1130     }
1131   }
1133   /**
1134    * Called for each mousemove event when we're tracking those events to
1135    * determine if the cursor is hovering over a <video>.
1136    *
1137    * @param {Event} event The mousemove event.
1138    */
1139   onMouseMove(event) {
1140     let state = this.docState;
1142     if (state.hideToggleDeferredTask) {
1143       state.hideToggleDeferredTask.disarm();
1144       state.hideToggleDeferredTask.arm();
1145     }
1147     state.lastMouseMoveEvent = event;
1148     state.mousemoveDeferredTask.arm();
1149   }
1151   /**
1152    * Called by the DeferredTask after MOUSEMOVE_PROCESSING_DELAY_MS
1153    * milliseconds. Checked to see if that mousemove happens to be overtop of
1154    * any interesting <video> elements that we want to display the toggle
1155    * on. If so, puts the toggle on that video.
1156    */
1157   checkLastMouseMove() {
1158     let state = this.docState;
1159     let event = state.lastMouseMoveEvent;
1160     let { clientX, clientY } = event;
1161     lazy.logConsole.debug("Visible videos count:", state.visibleVideosCount);
1162     lazy.logConsole.debug("Tracking videos:", state.isTrackingVideos);
1163     let winUtils = this.contentWindow.windowUtils;
1164     // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
1165     // since document.elementsFromPoint always flushes layout. The 1's in that
1166     // function call are for the size of the rect that we want, which is 1x1.
1167     let elements = winUtils.nodesFromRect(
1168       clientX,
1169       clientY,
1170       1,
1171       1,
1172       1,
1173       1,
1174       true,
1175       false,
1176       /* aOnlyVisible = */ true
1177     );
1179     for (let element of elements) {
1180       lazy.logConsole.debug("Element id under cursor:", element.id);
1181       lazy.logConsole.debug(
1182         "Node name of an element under cursor:",
1183         element.nodeName
1184       );
1185       lazy.logConsole.debug(
1186         "Supported <video> element:",
1187         state.weakVisibleVideos.has(element)
1188       );
1189       lazy.logConsole.debug(
1190         "PiP window is open:",
1191         element.isCloningElementVisually
1192       );
1194       // Check for hovering over the video controls or so too, not only
1195       // directly over the video.
1196       for (let el = element; el; el = el.containingShadowRoot?.host) {
1197         if (state.weakVisibleVideos.has(el) && !el.isCloningElementVisually) {
1198           lazy.logConsole.debug("Found supported element");
1199           this.onMouseOverVideo(el, event);
1200           return;
1201         }
1202       }
1203     }
1205     let oldOverVideo = this.getWeakOverVideo();
1206     if (oldOverVideo) {
1207       this.onMouseLeaveVideo(oldOverVideo);
1208     }
1209   }
1211   /**
1212    * Called once it has been determined that the mouse is overtop of a video
1213    * that is in the viewport.
1214    *
1215    * @param {Element} video The video the mouse is over.
1216    */
1217   onMouseOverVideo(video, event) {
1218     let oldOverVideo = this.getWeakOverVideo();
1219     let shadowRoot = video.openOrClosedShadowRoot;
1221     if (shadowRoot.firstChild && video != oldOverVideo) {
1222       // TODO: Maybe this should move to videocontrols.js somehow.
1223       shadowRoot.firstChild.toggleAttribute(
1224         "flipped",
1225         video.getTransformToViewport().a == -1
1226       );
1227     }
1229     // It seems from automated testing that if it's still very early on in the
1230     // lifecycle of a <video> element, it might not yet have a shadowRoot,
1231     // in which case, we can bail out here early.
1232     if (!shadowRoot) {
1233       if (oldOverVideo) {
1234         // We also clear the hover state on the old video we were hovering,
1235         // if there was one.
1236         this.onMouseLeaveVideo(oldOverVideo);
1237       }
1239       return;
1240     }
1242     let state = this.docState;
1243     let toggle = this.getToggleElement(shadowRoot);
1244     let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
1246     if (state.checkedPolicyDocumentURI != this.document.documentURI) {
1247       state.togglePolicy = lazy.TOGGLE_POLICIES.DEFAULT;
1248       // We cache the matchers process-wide. We'll skip this while running tests to make that
1249       // easier.
1250       let siteOverrides = this.toggleTesting
1251         ? PictureInPictureToggleChild.getSiteOverrides()
1252         : lazy.gSiteOverrides;
1254       let visibilityThresholdPref = Services.prefs.getFloatPref(
1255         TOGGLE_VISIBILITY_THRESHOLD_PREF,
1256         "1.0"
1257       );
1259       if (!this.videoWrapper) {
1260         this.videoWrapper = applyWrapper(this, video);
1261       }
1263       // Do we have any toggle overrides? If so, try to apply them.
1264       for (let [override, { policy, visibilityThreshold }] of siteOverrides) {
1265         if (
1266           (policy || visibilityThreshold) &&
1267           override.matches(this.document.documentURI)
1268         ) {
1269           state.togglePolicy = this.videoWrapper?.shouldHideToggle(video)
1270             ? lazy.TOGGLE_POLICIES.HIDDEN
1271             : policy || lazy.TOGGLE_POLICIES.DEFAULT;
1272           state.toggleVisibilityThreshold =
1273             visibilityThreshold || visibilityThresholdPref;
1274           break;
1275         }
1276       }
1278       state.checkedPolicyDocumentURI = this.document.documentURI;
1279     }
1281     // The built-in <video> controls are along the bottom, which would overlap the
1282     // toggle if the override is set to BOTTOM, so we ignore overrides that set
1283     // a policy of BOTTOM for <video> elements with controls.
1284     if (
1285       state.togglePolicy != lazy.TOGGLE_POLICIES.DEFAULT &&
1286       !(state.togglePolicy == lazy.TOGGLE_POLICIES.BOTTOM && video.controls)
1287     ) {
1288       toggle.setAttribute(
1289         "policy",
1290         lazy.TOGGLE_POLICY_STRINGS[state.togglePolicy]
1291       );
1292     } else {
1293       toggle.removeAttribute("policy");
1294     }
1296     // nimbusExperimentVariables will be defaultValues when the experiment is disabled
1297     const nimbusExperimentVariables =
1298       lazy.NimbusFeatures.pictureinpicture.getAllVariables({
1299         defaultValues: {
1300           oldToggle: true,
1301           title: null,
1302           message: false,
1303           showIconOnly: false,
1304           displayDuration: TOGGLE_FIRST_TIME_DURATION_DAYS,
1305         },
1306       });
1308     /**
1309      * If a Nimbus variable exists for the first-time PiP toggle design,
1310      * override the old design via a classname "experiment".
1311      */
1312     if (!nimbusExperimentVariables.oldToggle) {
1313       let controlsContainer = shadowRoot.querySelector(".controlsContainer");
1314       let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
1316       controlsContainer.classList.add("experiment");
1317       pipWrapper.classList.add("experiment");
1318     } else {
1319       let controlsContainer = shadowRoot.querySelector(".controlsContainer");
1320       let pipWrapper = shadowRoot.querySelector(".pip-wrapper");
1322       controlsContainer.classList.remove("experiment");
1323       pipWrapper.classList.remove("experiment");
1324     }
1326     if (nimbusExperimentVariables.title) {
1327       let pipExplainer = shadowRoot.querySelector(".pip-explainer");
1328       let pipLabel = shadowRoot.querySelector(".pip-label");
1330       if (pipExplainer && nimbusExperimentVariables.message) {
1331         pipExplainer.innerText = nimbusExperimentVariables.message;
1332       }
1333       pipLabel.innerText = nimbusExperimentVariables.title;
1334     } else if (nimbusExperimentVariables.showIconOnly) {
1335       // We only want to show the PiP icon in this experiment scenario
1336       let pipExpanded = shadowRoot.querySelector(".pip-expanded");
1337       pipExpanded.style.display = "none";
1338       let pipSmall = shadowRoot.querySelector(".pip-small");
1339       pipSmall.style.opacity = "1";
1341       let pipIcon = shadowRoot.querySelectorAll(".pip-icon")[1];
1342       pipIcon.style.display = "block";
1343     }
1345     controlsOverlay.removeAttribute("hidetoggle");
1347     // The hideToggleDeferredTask we create here is for automatically hiding
1348     // the toggle after a period of no mousemove activity for
1349     // TOGGLE_HIDING_TIMEOUT_MS. If the mouse moves, then the DeferredTask
1350     // timer is reset.
1351     //
1352     // We disable the toggle hiding timeout during testing to reduce
1353     // non-determinism from timers when testing the toggle.
1354     if (!state.hideToggleDeferredTask && !this.toggleTesting) {
1355       state.hideToggleDeferredTask = new lazy.DeferredTask(() => {
1356         controlsOverlay.setAttribute("hidetoggle", true);
1357       }, TOGGLE_HIDING_TIMEOUT_MS);
1358     }
1360     if (oldOverVideo) {
1361       if (oldOverVideo == video) {
1362         // If we're still hovering the old video, we might have entered or
1363         // exited the toggle region.
1364         this.checkHoverToggle(toggle, event);
1365         return;
1366       }
1368       // We had an old video that we were hovering, and we're not hovering
1369       // it anymore. Let's leave it.
1370       this.onMouseLeaveVideo(oldOverVideo);
1371     }
1373     state.weakOverVideo = Cu.getWeakReference(video);
1374     controlsOverlay.classList.add("hovering");
1376     if (
1377       state.togglePolicy != lazy.TOGGLE_POLICIES.HIDDEN &&
1378       !toggle.hasAttribute("hidden")
1379     ) {
1380       Services.telemetry.scalarAdd("pictureinpicture.saw_toggle", 1);
1381       const hasUsedPiP = Services.prefs.getBoolPref(TOGGLE_HAS_USED_PREF);
1382       let args = {
1383         firstTime: (!hasUsedPiP).toString(),
1384       };
1385       Services.telemetry.recordEvent(
1386         "pictureinpicture",
1387         "saw_toggle",
1388         "toggle",
1389         null,
1390         args
1391       );
1392       // only record if this is the first time seeing the toggle
1393       if (!hasUsedPiP) {
1394         lazy.NimbusFeatures.pictureinpicture.recordExposureEvent();
1396         const firstSeenSeconds = Services.prefs.getIntPref(
1397           TOGGLE_FIRST_SEEN_PREF,
1398           0
1399         );
1401         if (!firstSeenSeconds || firstSeenSeconds < 0) {
1402           let firstTimePiPStartDate = Math.round(Date.now() / 1000);
1403           this.sendAsyncMessage("PictureInPicture:SetFirstSeen", {
1404             dateSeconds: firstTimePiPStartDate,
1405           });
1406         } else if (nimbusExperimentVariables.displayDuration) {
1407           this.changeToIconIfDurationEnd(firstSeenSeconds);
1408         }
1409       }
1410     }
1412     // Now that we're hovering the video, we'll check to see if we're
1413     // hovering the toggle too.
1414     this.checkHoverToggle(toggle, event);
1415   }
1417   /**
1418    * Checks if a mouse event is happening over a toggle element. If it is,
1419    * sets the hovering class on it. Otherwise, it clears the hovering
1420    * class.
1421    *
1422    * @param {Element} toggle The Picture-in-Picture toggle to check.
1423    * @param {MouseEvent} event A MouseEvent to test.
1424    */
1425   checkHoverToggle(toggle, event) {
1426     toggle.classList.toggle("hovering", this.isMouseOverToggle(toggle, event));
1427   }
1429   /**
1430    * Called once it has been determined that the mouse is no longer overlapping
1431    * a video that we'd previously called onMouseOverVideo with.
1432    *
1433    * @param {Element} video The video that the mouse left.
1434    */
1435   onMouseLeaveVideo(video) {
1436     let state = this.docState;
1437     let shadowRoot = video.openOrClosedShadowRoot;
1439     if (shadowRoot) {
1440       let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
1441       let toggle = this.getToggleElement(shadowRoot);
1442       controlsOverlay.classList.remove("hovering");
1443       toggle.classList.remove("hovering");
1444     }
1446     state.weakOverVideo = null;
1448     if (!this.toggleTesting) {
1449       state.hideToggleDeferredTask.disarm();
1450       state.mousemoveDeferredTask.disarm();
1451     }
1453     state.hideToggleDeferredTask = null;
1454   }
1456   /**
1457    * Given a reference to a Picture-in-Picture toggle element, determines
1458    * if a MouseEvent event is occurring within its bounds.
1459    *
1460    * @param {Element} toggle The Picture-in-Picture toggle.
1461    * @param {MouseEvent} event A MouseEvent to test.
1462    *
1463    * @return {Boolean}
1464    */
1465   isMouseOverToggle(toggle, event) {
1466     let toggleRect =
1467       toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing(toggle);
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         inputSource: event.inputSource,
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.sys.mjs 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         this.sendAsyncMessage("PictureInPicture:VolumeChange", {
1934           volume: this.videoWrapper.getVolume(video),
1935         });
1936         break;
1937       }
1938       case "resize": {
1939         let video = event.target;
1940         if (this.inPictureInPicture(video)) {
1941           this.sendAsyncMessage("PictureInPicture:Resize", {
1942             videoHeight: video.videoHeight,
1943             videoWidth: video.videoWidth,
1944           });
1945         }
1946         this.setupTextTracks(video);
1947         break;
1948       }
1949       case "emptied": {
1950         this.isSubtitlesEnabled = false;
1951         if (this.emptiedTimeout) {
1952           clearTimeout(this.emptiedTimeout);
1953           this.emptiedTimeout = null;
1954         }
1955         let video = this.getWeakVideo();
1956         // We may want to keep the pip window open if the video
1957         // is still in DOM. But if video src is no longer defined,
1958         // close Picture-in-Picture.
1959         this.emptiedTimeout = setTimeout(() => {
1960           if (!video || !video.src) {
1961             this.closePictureInPicture({ reason: "videoElEmptied" });
1962           }
1963         }, EMPTIED_TIMEOUT_MS);
1964         break;
1965       }
1966       case "change": {
1967         // Clear currently stored track data (webvtt support) before reading
1968         // a new track.
1969         if (this._currentWebVTTTrack) {
1970           this._currentWebVTTTrack.removeEventListener(
1971             "cuechange",
1972             this.onCueChange
1973           );
1974           this._currentWebVTTTrack = null;
1975         }
1977         const tracks = event.target;
1978         this.setActiveTextTrack(tracks);
1979         const isCurrentTrackAvailable = this._currentWebVTTTrack;
1981         // If tracks are disabled or invalid while change occurs,
1982         // remove text tracks from the pip window and stop here.
1983         if (!isCurrentTrackAvailable || !tracks.length) {
1984           this.updateWebVTTTextTracksDisplay(null);
1985           return;
1986         }
1988         this._currentWebVTTTrack.addEventListener(
1989           "cuechange",
1990           this.onCueChange
1991         );
1992         const cues = this._currentWebVTTTrack.activeCues;
1993         this.updateWebVTTTextTracksDisplay(cues);
1994         break;
1995       }
1996       case "timeupdate":
1997       case "durationchange": {
1998         let video = this.getWeakVideo();
1999         let currentTime = this.videoWrapper.getCurrentTime(video);
2000         let duration = this.videoWrapper.getDuration(video);
2001         let scrubberPosition = currentTime === 0 ? 0 : currentTime / duration;
2002         let timestamp = this.videoWrapper.formatTimestamp(
2003           currentTime,
2004           duration
2005         );
2006         // There's no point in sending this message unless we have a
2007         // reasonable timestamp.
2008         if (timestamp !== undefined && lazy.IMPROVED_CONTROLS_ENABLED_PREF) {
2009           this.sendAsyncMessage(
2010             "PictureInPicture:SetTimestampAndScrubberPosition",
2011             {
2012               scrubberPosition,
2013               timestamp,
2014             }
2015           );
2016         }
2017         break;
2018       }
2019     }
2020   }
2022   /**
2023    * Tells the parent to close a pre-existing Picture-in-Picture
2024    * window.
2025    *
2026    * @return {Promise}
2027    *
2028    * @resolves {undefined} Once the pre-existing Picture-in-Picture
2029    * window has unloaded.
2030    */
2031   async closePictureInPicture({ reason }) {
2032     let video = this.getWeakVideo();
2033     if (video) {
2034       this.untrackOriginatingVideo(video);
2035     }
2036     this.sendAsyncMessage("PictureInPicture:Close", {
2037       reason,
2038     });
2040     let playerContent = this.getWeakPlayerContent();
2041     if (playerContent) {
2042       if (!playerContent.closed) {
2043         await new Promise(resolve => {
2044           playerContent.addEventListener("unload", resolve, {
2045             once: true,
2046           });
2047         });
2048       }
2049       // Nothing should be holding a reference to the Picture-in-Picture
2050       // player window content at this point, but just in case, we'll
2051       // clear the weak reference directly so nothing else can get a hold
2052       // of it from this angle.
2053       this.weakPlayerContent = null;
2054     }
2055   }
2057   receiveMessage(message) {
2058     switch (message.name) {
2059       case "PictureInPicture:SetupPlayer": {
2060         const { videoRef } = message.data;
2061         this.setupPlayer(videoRef);
2062         break;
2063       }
2064       case "PictureInPicture:Play": {
2065         this.play();
2066         break;
2067       }
2068       case "PictureInPicture:Pause": {
2069         if (message.data && message.data.reason == "pip-closed") {
2070           let video = this.getWeakVideo();
2072           // Currently in Firefox srcObjects are MediaStreams. However, by spec a srcObject
2073           // can be either a MediaStream, MediaSource or Blob. In case of future changes
2074           // we do not want to pause MediaStream srcObjects and we want to maintain current
2075           // behavior for non-MediaStream srcObjects.
2076           if (video && MediaStream.isInstance(video.srcObject)) {
2077             break;
2078           }
2079         }
2080         this.pause();
2081         break;
2082       }
2083       case "PictureInPicture:Mute": {
2084         this.mute();
2085         break;
2086       }
2087       case "PictureInPicture:Unmute": {
2088         this.unmute();
2089         break;
2090       }
2091       case "PictureInPicture:SeekForward":
2092       case "PictureInPicture:SeekBackward": {
2093         let selectedTime;
2094         let video = this.getWeakVideo();
2095         let currentTime = this.videoWrapper.getCurrentTime(video);
2096         if (message.name == "PictureInPicture:SeekBackward") {
2097           selectedTime = currentTime - SEEK_TIME_SECS;
2098           selectedTime = selectedTime >= 0 ? selectedTime : 0;
2099         } else {
2100           const maxtime = this.videoWrapper.getDuration(video);
2101           selectedTime = currentTime + SEEK_TIME_SECS;
2102           selectedTime = selectedTime <= maxtime ? selectedTime : maxtime;
2103         }
2104         this.videoWrapper.setCurrentTime(video, selectedTime);
2105         break;
2106       }
2107       case "PictureInPicture:KeyDown": {
2108         this.keyDown(message.data);
2109         break;
2110       }
2111       case "PictureInPicture:EnterFullscreen":
2112       case "PictureInPicture:ExitFullscreen": {
2113         let textTracks = this.document.getElementById("texttracks");
2114         if (textTracks) {
2115           this.moveTextTracks(message.data);
2116         }
2117         break;
2118       }
2119       case "PictureInPicture:ShowVideoControls":
2120       case "PictureInPicture:HideVideoControls": {
2121         let textTracks = this.document.getElementById("texttracks");
2122         if (textTracks) {
2123           this.moveTextTracks(message.data);
2124         }
2125         break;
2126       }
2127       case "PictureInPicture:ToggleTextTracks": {
2128         this.toggleTextTracks();
2129         break;
2130       }
2131       case "PictureInPicture:ChangeFontSizeTextTracks": {
2132         this.setTextTrackFontSize();
2133         break;
2134       }
2135       case "PictureInPicture:SetVideoTime": {
2136         const { scrubberPosition, wasPlaying } = message.data;
2137         this.setVideoTime(scrubberPosition, wasPlaying);
2138         break;
2139       }
2140       case "PictureInPicture:SetVolume": {
2141         const { volume } = message.data;
2142         let video = this.getWeakVideo();
2143         this.videoWrapper.setVolume(video, volume);
2144         break;
2145       }
2146     }
2147   }
2149   /**
2150    * Set the current time of the video based of the position of the scrubber
2151    * @param {Number} scrubberPosition A number between 0 and 1 representing the position of the scrubber
2152    */
2153   setVideoTime(scrubberPosition, wasPlaying) {
2154     const video = this.getWeakVideo();
2155     let duration = this.videoWrapper.getDuration(video);
2156     let currentTime = scrubberPosition * duration;
2157     this.videoWrapper.setCurrentTime(video, currentTime, wasPlaying);
2158   }
2160   /**
2161    * @returns {boolean} true if a textTrack with mode "hidden" should be treated as "showing"
2162    */
2163   shouldShowHiddenTextTracks() {
2164     const video = this.getWeakVideo();
2165     if (!video) {
2166       return false;
2167     }
2168     const { documentURI } = video.ownerDocument;
2169     if (!documentURI) {
2170       return false;
2171     }
2172     for (let [override, { showHiddenTextTracks }] of lazy.gSiteOverrides) {
2173       if (override.matches(documentURI) && showHiddenTextTracks) {
2174         return true;
2175       }
2176     }
2177     return false;
2178   }
2180   /**
2181    * Updates this._currentWebVTTTrack if an active track is found
2182    * for the originating video.
2183    * @param {TextTrackList} textTrackList list of text tracks
2184    */
2185   setActiveTextTrack(textTrackList) {
2186     this._currentWebVTTTrack = null;
2188     for (let i = 0; i < textTrackList.length; i++) {
2189       let track = textTrackList[i];
2190       let isCCText = track.kind === "subtitles" || track.kind === "captions";
2191       let shouldShowTrack =
2192         track.mode === "showing" ||
2193         (track.mode === "hidden" && this.shouldShowHiddenTextTracks());
2194       if (isCCText && shouldShowTrack && track.cues) {
2195         this._currentWebVTTTrack = track;
2196         break;
2197       }
2198     }
2199   }
2201   /**
2202    * Set the font size on the PiP window using the current font size value from
2203    * the "media.videocontrols.picture-in-picture.display-text-tracks.size" pref
2204    */
2205   setTextTrackFontSize() {
2206     const fontSize = Services.prefs.getStringPref(
2207       TEXT_TRACK_FONT_SIZE,
2208       "medium"
2209     );
2210     const root = this.document.querySelector(":root");
2211     if (fontSize === "small") {
2212       root.style.setProperty("--font-scale", "0.03");
2213     } else if (fontSize === "large") {
2214       root.style.setProperty("--font-scale", "0.09");
2215     } else {
2216       root.style.setProperty("--font-scale", "0.06");
2217     }
2218   }
2220   /**
2221    * Keeps an eye on the originating video's document. If it ever
2222    * goes away, this will cause the Picture-in-Picture window for any
2223    * of its content to go away as well.
2224    */
2225   trackOriginatingVideo(originatingVideo) {
2226     this.observerFunction = (subject, topic, data) => {
2227       this.observe(subject, topic, data);
2228     };
2229     Services.prefs.addObserver(
2230       "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
2231       this.observerFunction
2232     );
2234     let originatingWindow = originatingVideo.ownerGlobal;
2235     if (originatingWindow) {
2236       originatingWindow.addEventListener("pagehide", this);
2237       originatingVideo.addEventListener("play", this);
2238       originatingVideo.addEventListener("pause", this);
2239       originatingVideo.addEventListener("volumechange", this);
2240       originatingVideo.addEventListener("resize", this);
2241       originatingVideo.addEventListener("emptied", this);
2242       originatingVideo.addEventListener("timeupdate", this);
2244       if (lazy.DISPLAY_TEXT_TRACKS_PREF) {
2245         this.setupTextTracks(originatingVideo);
2246       }
2248       let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
2249       chromeEventHandler.addEventListener(
2250         "MozDOMFullscreen:Request",
2251         this,
2252         true
2253       );
2254       chromeEventHandler.addEventListener(
2255         "MozStopPictureInPicture",
2256         this,
2257         true
2258       );
2259     }
2260   }
2262   setUpCaptionChangeListener(originatingVideo) {
2263     if (this.videoWrapper) {
2264       this.videoWrapper.setCaptionContainerObserver(originatingVideo, this);
2265     }
2266   }
2268   /**
2269    * Stops tracking the originating video's document. This should
2270    * happen once the Picture-in-Picture window goes away (or is about
2271    * to go away), and we no longer care about hearing when the originating
2272    * window's document unloads.
2273    */
2274   untrackOriginatingVideo(originatingVideo) {
2275     Services.prefs.removeObserver(
2276       "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
2277       this.observerFunction
2278     );
2280     let originatingWindow = originatingVideo.ownerGlobal;
2281     if (originatingWindow) {
2282       originatingWindow.removeEventListener("pagehide", this);
2283       originatingVideo.removeEventListener("play", this);
2284       originatingVideo.removeEventListener("pause", this);
2285       originatingVideo.removeEventListener("volumechange", this);
2286       originatingVideo.removeEventListener("resize", this);
2287       originatingVideo.removeEventListener("emptied", this);
2288       originatingVideo.removeEventListener("timeupdate", this);
2290       if (lazy.DISPLAY_TEXT_TRACKS_PREF) {
2291         this.removeTextTracks(originatingVideo);
2292       }
2294       let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
2295       chromeEventHandler.removeEventListener(
2296         "MozDOMFullscreen:Request",
2297         this,
2298         true
2299       );
2300       chromeEventHandler.removeEventListener(
2301         "MozStopPictureInPicture",
2302         this,
2303         true
2304       );
2305     }
2306   }
2308   /**
2309    * Runs in an instance of PictureInPictureChild for the
2310    * player window's content, and not the originating video
2311    * content. Sets up the player so that it clones the originating
2312    * video. If anything goes wrong during set up, a message is
2313    * sent to the parent to close the Picture-in-Picture window.
2314    *
2315    * @param videoRef {ContentDOMReference}
2316    *    A reference to the video element that a Picture-in-Picture window
2317    *    is being created for
2318    * @return {Promise}
2319    * @resolves {undefined} Once the player window has been set up
2320    * properly, or a pre-existing Picture-in-Picture window has gone
2321    * away due to an unexpected error.
2322    */
2323   async setupPlayer(videoRef) {
2324     const video = await lazy.ContentDOMReference.resolve(videoRef);
2326     this.weakVideo = Cu.getWeakReference(video);
2327     let originatingVideo = this.getWeakVideo();
2328     if (!originatingVideo) {
2329       // If the video element has gone away before we've had a chance to set up
2330       // Picture-in-Picture for it, tell the parent to close the Picture-in-Picture
2331       // window.
2332       await this.closePictureInPicture({ reason: "setupFailure" });
2333       return;
2334     }
2336     this.videoWrapper = applyWrapper(this, originatingVideo);
2338     let loadPromise = new Promise(resolve => {
2339       this.contentWindow.addEventListener("load", resolve, {
2340         once: true,
2341         mozSystemGroup: true,
2342         capture: true,
2343       });
2344     });
2345     this.contentWindow.location.reload();
2346     await loadPromise;
2348     // We're committed to adding the video to this window now. Ensure we track
2349     // the content window before we do so, so that the toggle actor can
2350     // distinguish this new video we're creating from web-controlled ones.
2351     this.weakPlayerContent = Cu.getWeakReference(this.contentWindow);
2352     gPlayerContents.add(this.contentWindow);
2354     let doc = this.document;
2355     let playerVideo = doc.createElement("video");
2356     playerVideo.id = "playervideo";
2357     let textTracks = doc.createElement("div");
2359     doc.body.style.overflow = "hidden";
2360     doc.body.style.margin = "0";
2362     // Force the player video to assume maximum height and width of the
2363     // containing window
2364     playerVideo.style.height = "100vh";
2365     playerVideo.style.width = "100vw";
2366     playerVideo.style.backgroundColor = "#000";
2368     // Load text tracks container in the content process so that
2369     // we can load text tracks without having to constantly
2370     // access the parent process.
2371     textTracks.id = "texttracks";
2372     // When starting pip, player controls are expected to appear.
2373     textTracks.setAttribute("overlap-video-controls", true);
2374     doc.body.appendChild(playerVideo);
2375     doc.body.appendChild(textTracks);
2376     // Load text tracks stylesheet
2377     let textTracksStyleSheet = this.createTextTracksStyleSheet();
2378     doc.head.appendChild(textTracksStyleSheet);
2380     this.setTextTrackFontSize();
2382     originatingVideo.cloneElementVisually(playerVideo);
2384     let shadowRoot = originatingVideo.openOrClosedShadowRoot;
2385     if (originatingVideo.getTransformToViewport().a == -1) {
2386       shadowRoot.firstChild.setAttribute("flipped", true);
2387       playerVideo.style.transform = "scaleX(-1)";
2388     }
2390     this.onCueChange = this.onCueChange.bind(this);
2391     this.trackOriginatingVideo(originatingVideo);
2393     // A request to open PIP implies that the user intends to be interacting
2394     // with the page, even if they open PIP by some means outside of the page
2395     // itself (e.g., the keyboard shortcut or the page action button). So we
2396     // manually record that the document has been activated via user gesture
2397     // to make sure the video can be played regardless of autoplay permissions.
2398     originatingVideo.ownerDocument.notifyUserGestureActivation();
2400     this.contentWindow.addEventListener(
2401       "unload",
2402       () => {
2403         let video = this.getWeakVideo();
2404         if (video) {
2405           this.untrackOriginatingVideo(video);
2406           video.stopCloningElementVisually();
2407         }
2408         this.weakVideo = null;
2409       },
2410       { once: true }
2411     );
2412   }
2414   play() {
2415     let video = this.getWeakVideo();
2416     if (video && this.videoWrapper) {
2417       this.videoWrapper.play(video);
2418     }
2419   }
2421   pause() {
2422     let video = this.getWeakVideo();
2423     if (video && this.videoWrapper) {
2424       this.videoWrapper.pause(video);
2425     }
2426   }
2428   mute() {
2429     let video = this.getWeakVideo();
2430     if (video && this.videoWrapper) {
2431       this.videoWrapper.setMuted(video, true);
2432     }
2433   }
2435   unmute() {
2436     let video = this.getWeakVideo();
2437     if (video && this.videoWrapper) {
2438       this.videoWrapper.setMuted(video, false);
2439     }
2440   }
2442   onCueChange() {
2443     if (!lazy.DISPLAY_TEXT_TRACKS_PREF) {
2444       this.updateWebVTTTextTracksDisplay(null);
2445     } else {
2446       const cues = this._currentWebVTTTrack.activeCues;
2447       this.updateWebVTTTextTracksDisplay(cues);
2448     }
2449   }
2451   /**
2452    * This checks if a given keybinding has been disabled for the specific site
2453    * currently being viewed.
2454    */
2455   isKeyDisabled(key) {
2456     const video = this.getWeakVideo();
2457     if (!video) {
2458       return false;
2459     }
2460     const { documentURI } = video.ownerDocument;
2461     if (!documentURI) {
2462       return true;
2463     }
2464     for (let [override, { disabledKeyboardControls }] of lazy.gSiteOverrides) {
2465       if (
2466         disabledKeyboardControls !== undefined &&
2467         override.matches(documentURI)
2468       ) {
2469         if (disabledKeyboardControls === lazy.KEYBOARD_CONTROLS.ALL) {
2470           return true;
2471         }
2472         return !!(disabledKeyboardControls & key);
2473       }
2474     }
2475     return false;
2476   }
2478   /**
2479    * This reuses the keyHandler logic in the VideoControlsWidget
2480    * https://searchfox.org/mozilla-central/rev/cfd1cc461f1efe0d66c2fdc17c024a203d5a2fd8/toolkit/content/widgets/videocontrols.js#1687-1810.
2481    * There are future plans to eventually combine the two implementations.
2482    */
2483   /* eslint-disable complexity */
2484   keyDown({ altKey, shiftKey, metaKey, ctrlKey, keyCode }) {
2485     let video = this.getWeakVideo();
2486     if (!video) {
2487       return;
2488     }
2490     var keystroke = "";
2491     if (altKey) {
2492       keystroke += "alt-";
2493     }
2494     if (shiftKey) {
2495       keystroke += "shift-";
2496     }
2497     if (this.contentWindow.navigator.platform.startsWith("Mac")) {
2498       if (metaKey) {
2499         keystroke += "accel-";
2500       }
2501       if (ctrlKey) {
2502         keystroke += "control-";
2503       }
2504     } else {
2505       if (metaKey) {
2506         keystroke += "meta-";
2507       }
2508       if (ctrlKey) {
2509         keystroke += "accel-";
2510       }
2511     }
2513     switch (keyCode) {
2514       case this.contentWindow.KeyEvent.DOM_VK_UP:
2515         keystroke += "upArrow";
2516         break;
2517       case this.contentWindow.KeyEvent.DOM_VK_DOWN:
2518         keystroke += "downArrow";
2519         break;
2520       case this.contentWindow.KeyEvent.DOM_VK_LEFT:
2521         keystroke += "leftArrow";
2522         break;
2523       case this.contentWindow.KeyEvent.DOM_VK_RIGHT:
2524         keystroke += "rightArrow";
2525         break;
2526       case this.contentWindow.KeyEvent.DOM_VK_HOME:
2527         keystroke += "home";
2528         break;
2529       case this.contentWindow.KeyEvent.DOM_VK_END:
2530         keystroke += "end";
2531         break;
2532       case this.contentWindow.KeyEvent.DOM_VK_SPACE:
2533         keystroke += "space";
2534         break;
2535       case this.contentWindow.KeyEvent.DOM_VK_W:
2536         keystroke += "w";
2537         break;
2538     }
2540     const isVideoStreaming = this.videoWrapper.isLive(video);
2541     var oldval, newval;
2543     try {
2544       switch (keystroke) {
2545         case "space" /* Toggle Play / Pause */:
2546           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.PLAY_PAUSE)) {
2547             return;
2548           }
2550           if (
2551             this.videoWrapper.getPaused(video) ||
2552             this.videoWrapper.getEnded(video)
2553           ) {
2554             this.videoWrapper.play(video);
2555           } else {
2556             this.videoWrapper.pause(video);
2557           }
2559           break;
2560         case "accel-w" /* Close video */:
2561           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.CLOSE)) {
2562             return;
2563           }
2564           this.pause();
2565           this.closePictureInPicture({ reason: "closePlayerShortcut" });
2566           break;
2567         case "downArrow" /* Volume decrease */:
2568           if (
2569             this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME) ||
2570             this.videoWrapper.isMuted(video)
2571           ) {
2572             return;
2573           }
2574           oldval = this.videoWrapper.getVolume(video);
2575           newval = oldval < 0.1 ? 0 : oldval - 0.1;
2576           this.videoWrapper.setVolume(video, newval);
2577           this.videoWrapper.setMuted(video, newval === 0);
2578           break;
2579         case "upArrow" /* Volume increase */:
2580           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME)) {
2581             return;
2582           }
2583           oldval = this.videoWrapper.getVolume(video);
2584           this.videoWrapper.setVolume(video, oldval > 0.9 ? 1 : oldval + 0.1);
2585           this.videoWrapper.setMuted(video, false);
2586           break;
2587         case "accel-downArrow" /* Mute */:
2588           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
2589             return;
2590           }
2591           this.videoWrapper.setMuted(video, true);
2592           break;
2593         case "accel-upArrow" /* Unmute */:
2594           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
2595             return;
2596           }
2597           this.videoWrapper.setMuted(video, false);
2598           break;
2599         case "leftArrow": /* Seek back 5 seconds */
2600         case "accel-leftArrow" /* Seek back 10% */:
2601           if (
2602             this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) ||
2603             (isVideoStreaming &&
2604               this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK))
2605           ) {
2606             return;
2607           }
2609           oldval = this.videoWrapper.getCurrentTime(video);
2610           if (keystroke == "leftArrow") {
2611             newval = oldval - SEEK_TIME_SECS;
2612           } else {
2613             newval = oldval - this.videoWrapper.getDuration(video) / 10;
2614           }
2615           this.videoWrapper.setCurrentTime(video, newval >= 0 ? newval : 0);
2616           break;
2617         case "rightArrow": /* Seek forward 5 seconds */
2618         case "accel-rightArrow" /* Seek forward 10% */:
2619           if (
2620             this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) ||
2621             (isVideoStreaming &&
2622               this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK))
2623           ) {
2624             return;
2625           }
2627           oldval = this.videoWrapper.getCurrentTime(video);
2628           var maxtime = this.videoWrapper.getDuration(video);
2629           if (keystroke == "rightArrow") {
2630             newval = oldval + SEEK_TIME_SECS;
2631           } else {
2632             newval = oldval + maxtime / 10;
2633           }
2634           let selectedTime = newval <= maxtime ? newval : maxtime;
2635           this.videoWrapper.setCurrentTime(video, selectedTime);
2636           break;
2637         case "home" /* Seek to beginning */:
2638           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) {
2639             return;
2640           }
2641           if (!isVideoStreaming) {
2642             this.videoWrapper.setCurrentTime(video, 0);
2643           }
2644           break;
2645         case "end" /* Seek to end */:
2646           if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) {
2647             return;
2648           }
2650           let duration = this.videoWrapper.getDuration(video);
2651           if (
2652             !isVideoStreaming &&
2653             this.videoWrapper.getCurrentTime(video) != duration
2654           ) {
2655             this.videoWrapper.setCurrentTime(video, duration);
2656           }
2657           break;
2658         default:
2659       }
2660     } catch (e) {
2661       /* ignore any exception from setting video.currentTime */
2662     }
2663   }
2665   get isSubtitlesEnabled() {
2666     return this.#subtitlesEnabled;
2667   }
2669   set isSubtitlesEnabled(val) {
2670     if (val) {
2671       Services.telemetry.recordEvent(
2672         "pictureinpicture",
2673         "subtitles_shown",
2674         "subtitles",
2675         null,
2676         {
2677           webVTTSubtitles: (!!this.getWeakVideo().textTracks
2678             ?.length).toString(),
2679         }
2680       );
2681     } else {
2682       this.sendAsyncMessage("PictureInPicture:DisableSubtitlesButton");
2683     }
2684     this.#subtitlesEnabled = val;
2685   }
2689  * The PictureInPictureChildVideoWrapper class handles providing a path to a script that
2690  * defines a "site wrapper" for the original <video> (or other controls API provided
2691  * by the site) to command it.
2693  * This "site wrapper" provided to PictureInPictureChildVideoWrapper is a script file that
2694  * defines a class called `PictureInPictureVideoWrapper` and exports it. These scripts can
2695  * be found under "browser/extensions/pictureinpicture/video-wrappers" as part of the
2696  * Picture-In-Picture addon.
2698  * Site wrappers need to adhere to a specific interface to work properly with
2699  * PictureInPictureChildVideoWrapper:
2701  * - The "site wrapper" script must export a class called "PictureInPictureVideoWrapper"
2702  * - Method names on a site wrapper class should match its caller's name
2703  *   (i.e: PictureInPictureChildVideoWrapper.play will only call `play` on a site-wrapper, if available)
2704  */
2705 class PictureInPictureChildVideoWrapper {
2706   #sandbox;
2707   #siteWrapper;
2708   #PictureInPictureChild;
2710   /**
2711    * Create a wrapper for the original <video>
2712    *
2713    * @param {String|null} videoWrapperScriptPath
2714    *        Path to a wrapper script from the Picture-in-Picture addon. If a wrapper isn't
2715    *        provided to the class, then we fallback on a default implementation for
2716    *        commanding the original <video>.
2717    * @param {HTMLVideoElement} video
2718    *        The original <video> we want to create a wrapper class for.
2719    * @param {Object} pipChild
2720    *        Reference to PictureInPictureChild class calling this function.
2721    */
2722   constructor(videoWrapperScriptPath, video, pipChild) {
2723     this.#sandbox = videoWrapperScriptPath
2724       ? this.#createSandbox(videoWrapperScriptPath, video)
2725       : null;
2726     this.#PictureInPictureChild = pipChild;
2727   }
2729   /**
2730    * Handles calling methods defined on the site wrapper class to perform video
2731    * controls operations on the source video. If the method doesn't exist,
2732    * or if an error is thrown while calling it, use a fallback implementation.
2733    *
2734    * @param {String} methodInfo.name
2735    *        The method name to call.
2736    * @param {Array} methodInfo.args
2737    *        Arguments to pass to the site wrapper method being called.
2738    * @param {Function} methodInfo.fallback
2739    *        A fallback function that's invoked when a method doesn't exist on the site
2740    *        wrapper class or an error is thrown while calling a method
2741    * @param {Function} methodInfo.validateReturnVal
2742    *        Validates whether or not the return value of the wrapper method is correct.
2743    *        If this isn't provided or if it evaluates false for a return value, then
2744    *        return null.
2745    *
2746    * @returns The expected output of the wrapper function.
2747    */
2748   #callWrapperMethod({ name, args = [], fallback = () => {}, validateRetVal }) {
2749     try {
2750       const wrappedMethod = this.#siteWrapper?.[name];
2751       if (typeof wrappedMethod === "function") {
2752         let retVal = wrappedMethod.call(this.#siteWrapper, ...args);
2754         if (!validateRetVal) {
2755           lazy.logConsole.error(
2756             `No return value validator was provided for method ${name}(). Returning null.`
2757           );
2758           return null;
2759         }
2761         if (!validateRetVal(retVal)) {
2762           lazy.logConsole.error(
2763             `Calling method ${name}() returned an unexpected value: ${retVal}. Returning null.`
2764           );
2765           return null;
2766         }
2768         return retVal;
2769       }
2770     } catch (e) {
2771       lazy.logConsole.error(
2772         `There was an error while calling ${name}(): `,
2773         e.message
2774       );
2775     }
2777     return fallback();
2778   }
2780   /**
2781    * Creates a sandbox with Xray vision to execute content code in an unprivileged
2782    * context. This way, privileged code (PictureInPictureChild) can call into the
2783    * sandbox to perform video controls operations on the originating video
2784    * (content code) and still be protected from direct access by it.
2785    *
2786    * @param {String} videoWrapperScriptPath
2787    *        Path to a wrapper script from the Picture-in-Picture addon.
2788    * @param {HTMLVideoElement} video
2789    *        The source video element whose window to create a sandbox for.
2790    */
2791   #createSandbox(videoWrapperScriptPath, video) {
2792     const addonPolicy = WebExtensionPolicy.getByID(
2793       "pictureinpicture@mozilla.org"
2794     );
2795     let wrapperScriptUrl = addonPolicy.getURL(videoWrapperScriptPath);
2796     let originatingWin = video.ownerGlobal;
2797     let originatingDoc = video.ownerDocument;
2799     let sandbox = Cu.Sandbox([originatingDoc.nodePrincipal], {
2800       sandboxName: "Picture-in-Picture video wrapper sandbox",
2801       sandboxPrototype: originatingWin,
2802       sameZoneAs: originatingWin,
2803       wantXrays: false,
2804     });
2806     try {
2807       Services.scriptloader.loadSubScript(wrapperScriptUrl, sandbox);
2808     } catch (e) {
2809       Cu.nukeSandbox(sandbox);
2810       lazy.logConsole.error(
2811         "Error loading wrapper script for Picture-in-Picture",
2812         e
2813       );
2814       return null;
2815     }
2817     // The prototype of the wrapper class instantiated from the sandbox with Xray
2818     // vision is `Object` and not actually `PictureInPictureVideoWrapper`. But we
2819     // need to be able to access methods defined on this class to perform site-specific
2820     // video control operations otherwise we fallback to a default implementation.
2821     // Because of this, we need to "waive Xray vision" by adding `.wrappedObject` to the
2822     // end.
2823     this.#siteWrapper = new sandbox.PictureInPictureVideoWrapper(
2824       video
2825     ).wrappedJSObject;
2827     return sandbox;
2828   }
2830   #isBoolean(val) {
2831     return typeof val === "boolean";
2832   }
2834   #isNumber(val) {
2835     return typeof val === "number";
2836   }
2838   /**
2839    * Destroys the sandbox for the site wrapper class
2840    */
2841   destroy() {
2842     if (this.#sandbox) {
2843       Cu.nukeSandbox(this.#sandbox);
2844     }
2845   }
2847   /**
2848    * Function to display the captions on the PiP window
2849    * @param text The captions to be shown on the PiP window
2850    */
2851   updatePiPTextTracks(text) {
2852     if (!this.#PictureInPictureChild.isSubtitlesEnabled && text) {
2853       this.#PictureInPictureChild.isSubtitlesEnabled = true;
2854       this.#PictureInPictureChild.sendAsyncMessage(
2855         "PictureInPicture:EnableSubtitlesButton"
2856       );
2857     }
2858     let pipWindowTracksContainer =
2859       this.#PictureInPictureChild.document.getElementById("texttracks");
2860     pipWindowTracksContainer.textContent = text;
2861   }
2863   /* Video methods to be used for video controls from the PiP window. */
2865   /**
2866    * OVERRIDABLE - calls the play() method defined in the site wrapper script. Runs a fallback implementation
2867    * if the method does not exist or if an error is thrown while calling it. This method is meant to handle video
2868    * behaviour when a video is played.
2869    * @param {HTMLVideoElement} video
2870    *  The originating video source element
2871    */
2872   play(video) {
2873     return this.#callWrapperMethod({
2874       name: "play",
2875       args: [video],
2876       fallback: () => video.play(),
2877       validateRetVal: retVal => retVal == null,
2878     });
2879   }
2881   /**
2882    * OVERRIDABLE - calls the pause() method defined in the site wrapper script. Runs a fallback implementation
2883    * if the method does not exist or if an error is thrown while calling it. This method is meant to handle video
2884    * behaviour when a video is paused.
2885    * @param {HTMLVideoElement} video
2886    *  The originating video source element
2887    */
2888   pause(video) {
2889     return this.#callWrapperMethod({
2890       name: "pause",
2891       args: [video],
2892       fallback: () => video.pause(),
2893       validateRetVal: retVal => retVal == null,
2894     });
2895   }
2897   /**
2898    * OVERRIDABLE - calls the getPaused() method defined in the site wrapper script. Runs a fallback implementation
2899    * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if
2900    * a video is paused or not.
2901    * @param {HTMLVideoElement} video
2902    *  The originating video source element
2903    * @returns {Boolean} Boolean value true if paused, or false if video is still playing
2904    */
2905   getPaused(video) {
2906     return this.#callWrapperMethod({
2907       name: "getPaused",
2908       args: [video],
2909       fallback: () => video.paused,
2910       validateRetVal: retVal => this.#isBoolean(retVal),
2911     });
2912   }
2914   /**
2915    * OVERRIDABLE - calls the getEnded() method defined in the site wrapper script. Runs a fallback implementation
2916    * if the method does not exist or if an error is thrown while calling it. This method is meant to determine if
2917    * video playback or streaming has stopped.
2918    * @param {HTMLVideoElement} video
2919    *  The originating video source element
2920    * @returns {Boolean} Boolean value true if the video has ended, or false if still playing
2921    */
2922   getEnded(video) {
2923     return this.#callWrapperMethod({
2924       name: "getEnded",
2925       args: [video],
2926       fallback: () => video.ended,
2927       validateRetVal: retVal => this.#isBoolean(retVal),
2928     });
2929   }
2931   /**
2932    * OVERRIDABLE - calls the getDuration() method defined in the site wrapper script. Runs a fallback implementation
2933    * if the method does not exist or if an error is thrown while calling it. This method is meant to get the current
2934    * duration of a video in seconds.
2935    * @param {HTMLVideoElement} video
2936    *  The originating video source element
2937    * @returns {Number} Duration of the video in seconds
2938    */
2939   getDuration(video) {
2940     return this.#callWrapperMethod({
2941       name: "getDuration",
2942       args: [video],
2943       fallback: () => video.duration,
2944       validateRetVal: retVal => this.#isNumber(retVal),
2945     });
2946   }
2948   /**
2949    * OVERRIDABLE - calls the getCurrentTime() method defined in the site wrapper script. Runs a fallback implementation
2950    * if the method does not exist or if an error is thrown while calling it. This method is meant to get the current
2951    * time of a video in seconds.
2952    * @param {HTMLVideoElement} video
2953    *  The originating video source element
2954    * @returns {Number} Current time of the video in seconds
2955    */
2956   getCurrentTime(video) {
2957     return this.#callWrapperMethod({
2958       name: "getCurrentTime",
2959       args: [video],
2960       fallback: () => video.currentTime,
2961       validateRetVal: retVal => this.#isNumber(retVal),
2962     });
2963   }
2965   /**
2966    * OVERRIDABLE - calls the setCurrentTime() method defined in the site wrapper script. Runs a fallback implementation
2967    * if the method does not exist or if an error is thrown while calling it. This method is meant to set the current
2968    * time of a video.
2969    * @param {HTMLVideoElement} video
2970    *  The originating video source element
2971    * @param {Number} position
2972    *  The current playback time of the video
2973    * @param {Boolean} wasPlaying
2974    *  True if the video was playing before seeking else false
2975    */
2976   setCurrentTime(video, position, wasPlaying) {
2977     return this.#callWrapperMethod({
2978       name: "setCurrentTime",
2979       args: [video, position, wasPlaying],
2980       fallback: () => {
2981         video.currentTime = position;
2982       },
2983       validateRetVal: retVal => retVal == null,
2984     });
2985   }
2987   /**
2988    * Return hours, minutes, and seconds from seconds
2989    * @param {Number} aSeconds
2990    *  The time in seconds
2991    * @returns {String} Timestamp string
2992    **/
2993   timeFromSeconds(aSeconds) {
2994     aSeconds = isNaN(aSeconds) ? 0 : Math.round(aSeconds);
2995     let seconds = Math.floor(aSeconds % 60),
2996       minutes = Math.floor((aSeconds / 60) % 60),
2997       hours = Math.floor(aSeconds / 3600);
2998     seconds = seconds < 10 ? "0" + seconds : seconds;
2999     minutes = hours > 0 && minutes < 10 ? "0" + minutes : minutes;
3000     return aSeconds < 3600
3001       ? `${minutes}:${seconds}`
3002       : `${hours}:${minutes}:${seconds}`;
3003   }
3005   /**
3006    * Format a timestamp from current time and total duration,
3007    * output as a string in the form '0:00 / 0:00'
3008    * @param {Number} aCurrentTime
3009    *  The current time in seconds
3010    * @param {Number} aDuration
3011    *  The total duration in seconds
3012    * @returns {String} Formatted timestamp
3013    **/
3014   formatTimestamp(aCurrentTime, aDuration) {
3015     // We can't format numbers that can't be represented as decimal digits.
3016     if (!Number.isFinite(aCurrentTime) || !Number.isFinite(aDuration)) {
3017       return undefined;
3018     }
3020     return `${this.timeFromSeconds(aCurrentTime)} / ${this.timeFromSeconds(
3021       aDuration
3022     )}`;
3023   }
3025   /**
3026    * OVERRIDABLE - calls the getVolume() method defined in the site wrapper script. Runs a fallback implementation
3027    * if the method does not exist or if an error is thrown while calling it. This method is meant to get the volume
3028    * value of a video.
3029    * @param {HTMLVideoElement} video
3030    *  The originating video source element
3031    * @returns {Number} Volume of the video between 0 (muted) and 1 (loudest)
3032    */
3033   getVolume(video) {
3034     return this.#callWrapperMethod({
3035       name: "getVolume",
3036       args: [video],
3037       fallback: () => video.volume,
3038       validateRetVal: retVal => this.#isNumber(retVal),
3039     });
3040   }
3042   /**
3043    * OVERRIDABLE - calls the setVolume() method defined in the site wrapper script. Runs a fallback implementation
3044    * if the method does not exist or if an error is thrown while calling it. This method is meant to set the volume
3045    * value of a video.
3046    * @param {HTMLVideoElement} video
3047    *  The originating video source element
3048    * @param {Number} volume
3049    *  Value between 0 (muted) and 1 (loudest)
3050    */
3051   setVolume(video, volume) {
3052     return this.#callWrapperMethod({
3053       name: "setVolume",
3054       args: [video, volume],
3055       fallback: () => {
3056         video.volume = volume;
3057       },
3058       validateRetVal: retVal => retVal == null,
3059     });
3060   }
3062   /**
3063    * OVERRIDABLE - calls the isMuted() method defined in the site wrapper script. Runs a fallback implementation
3064    * if the method does not exist or if an error is thrown while calling it. This method is meant to get the mute
3065    * state a video.
3066    * @param {HTMLVideoElement} video
3067    *  The originating video source element
3068    * @param {Boolean} shouldMute
3069    *  Boolean value true to mute the video, or false to unmute the video
3070    */
3071   isMuted(video) {
3072     return this.#callWrapperMethod({
3073       name: "isMuted",
3074       args: [video],
3075       fallback: () => video.muted,
3076       validateRetVal: retVal => this.#isBoolean(retVal),
3077     });
3078   }
3080   /**
3081    * OVERRIDABLE - calls the setMuted() method defined in the site wrapper script. Runs a fallback implementation
3082    * if the method does not exist or if an error is thrown while calling it. This method is meant to mute or unmute
3083    * a video.
3084    * @param {HTMLVideoElement} video
3085    *  The originating video source element
3086    * @param {Boolean} shouldMute
3087    *  Boolean value true to mute the video, or false to unmute the video
3088    */
3089   setMuted(video, shouldMute) {
3090     return this.#callWrapperMethod({
3091       name: "setMuted",
3092       args: [video, shouldMute],
3093       fallback: () => {
3094         video.muted = shouldMute;
3095       },
3096       validateRetVal: retVal => retVal == null,
3097     });
3098   }
3100   /**
3101    * OVERRIDABLE - calls the setCaptionContainerObserver() method defined in the site wrapper script. Runs a fallback implementation
3102    * 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
3103    * video's caption container and execute a callback function responsible for updating the pip window's text tracks container whenever
3104    * a cue change is triggered {@see updatePiPTextTracks()}.
3105    * @param {HTMLVideoElement} video
3106    *  The originating video source element
3107    * @param {Function} _callback
3108    *  The callback function to be executed when cue changes are detected
3109    */
3110   setCaptionContainerObserver(video, _callback) {
3111     return this.#callWrapperMethod({
3112       name: "setCaptionContainerObserver",
3113       args: [
3114         video,
3115         text => {
3116           this.updatePiPTextTracks(text);
3117         },
3118       ],
3119       fallback: () => {},
3120       validateRetVal: retVal => retVal == null,
3121     });
3122   }
3124   /**
3125    * OVERRIDABLE - calls the shouldHideToggle() method defined in the site wrapper script. Runs a fallback implementation
3126    * 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
3127    * for a video should be hidden by the site wrapper.
3128    * @param {HTMLVideoElement} video
3129    *  The originating video source element
3130    * @returns {Boolean} Boolean value true if the pip toggle should be hidden by the site wrapper, or false if it should not
3131    */
3132   shouldHideToggle(video) {
3133     return this.#callWrapperMethod({
3134       name: "shouldHideToggle",
3135       args: [video],
3136       fallback: () => false,
3137       validateRetVal: retVal => this.#isBoolean(retVal),
3138     });
3139   }
3141   /**
3142    * OVERRIDABLE - calls the isLive() method defined in the site wrapper script. Runs a fallback implementation
3143    * if the method does not exist or if an error is thrown while calling it. This method is meant to get if the
3144    * video is a live stream.
3145    * @param {HTMLVideoElement} video
3146    *  The originating video source element
3147    */
3148   isLive(video) {
3149     return this.#callWrapperMethod({
3150       name: "isLive",
3151       args: [video],
3152       fallback: () => video.duration === Infinity,
3153       validateRetVal: retVal => this.#isBoolean(retVal),
3154     });
3155   }