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