Bug 1728059 [wpt PR 30223] - Add Subresource Web Bundle tests for non utf-8 encoding...
[gecko.git] / toolkit / actors / PictureInPictureChild.jsm
blob495e7db5b3af994893d4aede49fff5d0608bbfde
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/. */
5 "use strict";
7 var EXPORTED_SYMBOLS = [
8   "PictureInPictureChild",
9   "PictureInPictureToggleChild",
10   "PictureInPictureLauncherChild",
13 ChromeUtils.defineModuleGetter(
14   this,
15   "DeferredTask",
16   "resource://gre/modules/DeferredTask.jsm"
18 ChromeUtils.defineModuleGetter(
19   this,
20   "Services",
21   "resource://gre/modules/Services.jsm"
23 ChromeUtils.defineModuleGetter(
24   this,
25   "KEYBOARD_CONTROLS",
26   "resource://gre/modules/PictureInPictureControls.jsm"
28 ChromeUtils.defineModuleGetter(
29   this,
30   "TOGGLE_POLICIES",
31   "resource://gre/modules/PictureInPictureControls.jsm"
33 ChromeUtils.defineModuleGetter(
34   this,
35   "TOGGLE_POLICY_STRINGS",
36   "resource://gre/modules/PictureInPictureControls.jsm"
38 ChromeUtils.defineModuleGetter(
39   this,
40   "Rect",
41   "resource://gre/modules/Geometry.jsm"
43 ChromeUtils.defineModuleGetter(
44   this,
45   "ContentDOMReference",
46   "resource://gre/modules/ContentDOMReference.jsm"
49 const { XPCOMUtils } = ChromeUtils.import(
50   "resource://gre/modules/XPCOMUtils.jsm"
53 const TOGGLE_ENABLED_PREF =
54   "media.videocontrols.picture-in-picture.video-toggle.enabled";
55 const TOGGLE_TESTING_PREF =
56   "media.videocontrols.picture-in-picture.video-toggle.testing";
58 const MOUSEMOVE_PROCESSING_DELAY_MS = 50;
59 const TOGGLE_HIDING_TIMEOUT_MS = 2000;
61 // The ToggleChild does not want to capture events from the PiP
62 // windows themselves. This set contains all currently open PiP
63 // players' content windows
64 var gPlayerContents = new WeakSet();
66 // To make it easier to write tests, we have a process-global
67 // WeakSet of all <video> elements that are being tracked for
68 // mouseover
69 var gWeakIntersectingVideosForTesting = new WeakSet();
71 // Overrides are expected to stay constant for the lifetime of a
72 // content process, so we set this as a lazy process global.
73 // See PictureInPictureToggleChild.getSiteOverrides for a
74 // sense of what the return types are.
75 XPCOMUtils.defineLazyGetter(this, "gSiteOverrides", () => {
76   return PictureInPictureToggleChild.getSiteOverrides();
77 });
79 class PictureInPictureLauncherChild extends JSWindowActorChild {
80   handleEvent(event) {
81     switch (event.type) {
82       case "MozTogglePictureInPicture": {
83         if (event.isTrusted) {
84           this.togglePictureInPicture(event.target);
85         }
86         break;
87       }
88     }
89   }
91   receiveMessage(message) {
92     switch (message.name) {
93       case "PictureInPicture:KeyToggle": {
94         this.keyToggle();
95         break;
96       }
97     }
98   }
100   /**
101    * Tells the parent to open a Picture-in-Picture window hosting
102    * a clone of the passed video. If we know about a pre-existing
103    * Picture-in-Picture window existing, this tells the parent to
104    * close it before opening the new one.
105    *
106    * @param {Element} video The <video> element to view in a Picture
107    * in Picture window.
108    *
109    * @return {Promise}
110    * @resolves {undefined} Once the new Picture-in-Picture window
111    * has been requested.
112    */
113   async togglePictureInPicture(video) {
114     if (video.isCloningElementVisually) {
115       // The only way we could have entered here for the same video is if
116       // we are toggling via the context menu, since we hide the inline
117       // Picture-in-Picture toggle when a video is being displayed in
118       // Picture-in-Picture. Turn off PiP in this case
119       const stopPipEvent = new this.contentWindow.CustomEvent(
120         "MozStopPictureInPicture",
121         {
122           bubbles: true,
123           detail: { reason: "context-menu" },
124         }
125       );
126       video.dispatchEvent(stopPipEvent);
127       return;
128     }
130     // All other requests to toggle PiP should open a new PiP
131     // window
132     const videoRef = ContentDOMReference.get(video);
133     this.sendAsyncMessage("PictureInPicture:Request", {
134       isMuted: PictureInPictureChild.videoIsMuted(video),
135       playing: PictureInPictureChild.videoIsPlaying(video),
136       videoHeight: video.videoHeight,
137       videoWidth: video.videoWidth,
138       videoRef,
139     });
140   }
142   //
143   /**
144    * The keyboard was used to attempt to open Picture-in-Picture. In this case,
145    * find the focused window, and open Picture-in-Picture for the first
146    * playing video, or if none, the largest dimension video. We suspect this
147    * heuristic will handle most cases, though we might refine this later on.
148    */
149   keyToggle() {
150     let focusedWindow = Services.focus.focusedWindow;
151     if (focusedWindow) {
152       let doc = focusedWindow.document;
153       if (doc) {
154         let listOfVideos = [...doc.querySelectorAll("video")].filter(
155           video => !isNaN(video.duration)
156         );
157         // Get the first non-paused video, otherwise the longest video. This
158         // fallback is designed to skip over "preview"-style videos on sidebars.
159         let video =
160           listOfVideos.filter(v => !v.paused)[0] ||
161           listOfVideos.sort((a, b) => b.duration - a.duration)[0];
162         if (video) {
163           this.togglePictureInPicture(video);
164         }
165       }
166     }
167   }
171  * The PictureInPictureToggleChild is responsible for displaying the overlaid
172  * Picture-in-Picture toggle over top of <video> elements that the mouse is
173  * hovering.
174  */
175 class PictureInPictureToggleChild extends JSWindowActorChild {
176   constructor() {
177     super();
178     // We need to maintain some state about various things related to the
179     // Picture-in-Picture toggles - however, for now, the same
180     // PictureInPictureToggleChild might be re-used for different documents.
181     // We keep the state stashed inside of this WeakMap, keyed on the document
182     // itself.
183     this.weakDocStates = new WeakMap();
184     this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF);
185     this.toggleTesting = Services.prefs.getBoolPref(TOGGLE_TESTING_PREF, false);
187     // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's
188     // directly, so we create a new function here instead to act as our
189     // nsIObserver, which forwards the notification to the observe method.
190     this.observerFunction = (subject, topic, data) => {
191       this.observe(subject, topic, data);
192     };
193     Services.prefs.addObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
194     Services.cpmm.sharedData.addEventListener("change", this);
195   }
197   didDestroy() {
198     this.stopTrackingMouseOverVideos();
199     Services.prefs.removeObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
200     Services.cpmm.sharedData.removeEventListener("change", this);
202     // remove the observer on the <video> element
203     let state = this.docState;
204     if (state?.intersectionObserver) {
205       state.intersectionObserver.disconnect();
206     }
207     // ensure we don't access the state
208     this.isDestroyed = true;
209   }
211   observe(subject, topic, data) {
212     if (topic != "nsPref:changed") {
213       return;
214     }
216     this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF);
218     if (this.toggleEnabled) {
219       // We have enabled the Picture-in-Picture toggle, so we need to make
220       // sure we register all of the videos that might already be on the page.
221       this.contentWindow.requestIdleCallback(() => {
222         let videos = this.document.querySelectorAll("video");
223         for (let video of videos) {
224           this.registerVideo(video);
225         }
226       });
227     }
228   }
230   /**
231    * Returns the state for the current document referred to via
232    * this.document. If no such state exists, creates it, stores it
233    * and returns it.
234    */
235   get docState() {
236     if (this.isDestroyed || !this.document) {
237       return false;
238     }
240     let state = this.weakDocStates.get(this.document);
242     if (!state) {
243       state = {
244         // A reference to the IntersectionObserver that's monitoring for videos
245         // to become visible.
246         intersectionObserver: null,
247         // A WeakSet of videos that are supposedly visible, according to the
248         // IntersectionObserver.
249         weakVisibleVideos: new WeakSet(),
250         // The number of videos that are supposedly visible, according to the
251         // IntersectionObserver
252         visibleVideosCount: 0,
253         // The DeferredTask that we'll arm every time a mousemove event occurs
254         // on a page where we have one or more visible videos.
255         mousemoveDeferredTask: null,
256         // A weak reference to the last video we displayed the toggle over.
257         weakOverVideo: null,
258         // True if the user is in the midst of clicking the toggle.
259         isClickingToggle: false,
260         // Set to the original target element on pointerdown if the user is clicking
261         // the toggle - this way, we can determine if a "click" event will need to be
262         // suppressed ("click" events don't fire if a "mouseup" occurs on a different
263         // element from the "pointerdown" / "mousedown" event).
264         clickedElement: null,
265         // This is a DeferredTask to hide the toggle after a period of mouse
266         // inactivity.
267         hideToggleDeferredTask: null,
268         // If we reach a point where we're tracking videos for mouse movements,
269         // then this will be true. If there are no videos worth tracking, then
270         // this is false.
271         isTrackingVideos: false,
272         togglePolicy: TOGGLE_POLICIES.DEFAULT,
273         toggleVisibilityThreshold: 1.0,
274         // The documentURI that has been checked with toggle policies and
275         // visibility thresholds for this document. Note that the documentURI
276         // might change for a document via the history API, so we remember
277         // the last checked documentURI to determine if we need to check again.
278         checkedPolicyDocumentURI: null,
279       };
280       this.weakDocStates.set(this.document, state);
281     }
283     return state;
284   }
286   /**
287    * Returns the video that the user was last hovering with the mouse if it
288    * still exists.
289    *
290    * @return {Element} the <video> element that the user was last hovering,
291    * or null if there was no such <video>, or the <video> no longer exists.
292    */
293   getWeakOverVideo() {
294     let { weakOverVideo } = this.docState;
295     if (weakOverVideo) {
296       // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
297       // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
298       try {
299         return weakOverVideo.get();
300       } catch (e) {
301         return null;
302       }
303     }
304     return null;
305   }
307   handleEvent(event) {
308     if (!event.isTrusted) {
309       // We don't care about synthesized events that might be coming from
310       // content JS.
311       return;
312     }
314     // Don't capture events from Picture-in-Picture content windows
315     if (gPlayerContents.has(this.contentWindow)) {
316       return;
317     }
319     switch (event.type) {
320       case "change": {
321         const { changedKeys } = event;
322         if (changedKeys.includes("PictureInPicture:SiteOverrides")) {
323           // For now we only update our cache if the site overrides change.
324           // the user will need to refresh the page for changes to apply.
325           try {
326             gSiteOverrides = PictureInPictureToggleChild.getSiteOverrides();
327           } catch (e) {
328             // Ignore resulting TypeError if gSiteOverrides is still unloaded
329             if (!(e instanceof TypeError)) {
330               throw e;
331             }
332           }
333         }
334         break;
335       }
336       case "UAWidgetSetupOrChange": {
337         if (
338           this.toggleEnabled &&
339           event.target instanceof this.contentWindow.HTMLVideoElement &&
340           event.target.ownerDocument == this.document
341         ) {
342           this.registerVideo(event.target);
343         }
344         break;
345       }
346       case "contextmenu": {
347         if (this.toggleEnabled) {
348           this.checkContextMenu(event);
349         }
350         break;
351       }
352       case "mouseout": {
353         this.onMouseOut(event);
354         break;
355       }
356       case "mousedown":
357       case "pointerup":
358       case "mouseup":
359       case "click": {
360         this.onMouseButtonEvent(event);
361         break;
362       }
363       case "pointerdown": {
364         this.onPointerDown(event);
365         break;
366       }
367       case "mousemove": {
368         this.onMouseMove(event);
369         break;
370       }
371       case "pageshow": {
372         this.onPageShow(event);
373         break;
374       }
375       case "pagehide": {
376         this.onPageHide(event);
377         break;
378       }
379     }
380   }
382   /**
383    * Adds a <video> to the IntersectionObserver so that we know when it becomes
384    * visible.
385    *
386    * @param {Element} video The <video> element to register.
387    */
388   registerVideo(video) {
389     let state = this.docState;
390     if (!state.intersectionObserver) {
391       let fn = this.onIntersection.bind(this);
392       state.intersectionObserver = new this.contentWindow.IntersectionObserver(
393         fn,
394         {
395           threshold: [0.0, 0.5],
396         }
397       );
398     }
400     state.intersectionObserver.observe(video);
401   }
403   /**
404    * Called by the IntersectionObserver callback once a video becomes visible.
405    * This adds some fine-grained checking to ensure that a sufficient amount of
406    * the video is visible before we consider showing the toggles on it. For now,
407    * that means that the entirety of the video must be in the viewport.
408    *
409    * @param {IntersectionEntry} intersectionEntry An IntersectionEntry passed to
410    * the IntersectionObserver callback.
411    * @return bool Whether or not we should start tracking mousemove events for
412    * this registered video.
413    */
414   worthTracking(intersectionEntry) {
415     return intersectionEntry.isIntersecting;
416   }
418   /**
419    * Called by the IntersectionObserver once a video crosses one of the
420    * thresholds dictated by the IntersectionObserver configuration.
421    *
422    * @param {Array<IntersectionEntry>} A collection of one or more
423    * IntersectionEntry's for <video> elements that might have entered or exited
424    * the viewport.
425    */
426   onIntersection(entries) {
427     // The IntersectionObserver will also fire when a previously intersecting
428     // element is removed from the DOM. We know, however, that the node is
429     // still alive and referrable from the WeakSet because the
430     // IntersectionObserverEntry holds a strong reference to the video.
431     let state = this.docState;
432     if (!state) {
433       return;
434     }
435     let oldVisibleVideosCount = state.visibleVideosCount;
436     for (let entry of entries) {
437       let video = entry.target;
438       if (this.worthTracking(entry)) {
439         if (!state.weakVisibleVideos.has(video)) {
440           state.weakVisibleVideos.add(video);
441           state.visibleVideosCount++;
442           if (this.toggleTesting) {
443             gWeakIntersectingVideosForTesting.add(video);
444           }
445         }
446       } else if (state.weakVisibleVideos.has(video)) {
447         state.weakVisibleVideos.delete(video);
448         state.visibleVideosCount--;
449         if (this.toggleTesting) {
450           gWeakIntersectingVideosForTesting.delete(video);
451         }
452       }
453     }
455     // For testing, especially in debug or asan builds, we might not
456     // run this idle callback within an acceptable time. While we're
457     // testing, we'll bypass the idle callback performance optimization
458     // and run our callbacks as soon as possible during the next idle
459     // period.
460     if (!oldVisibleVideosCount && state.visibleVideosCount) {
461       if (this.toggleTesting || !this.contentWindow) {
462         this.beginTrackingMouseOverVideos();
463       } else {
464         this.contentWindow.requestIdleCallback(() => {
465           this.beginTrackingMouseOverVideos();
466         });
467       }
468     } else if (oldVisibleVideosCount && !state.visibleVideosCount) {
469       if (this.toggleTesting || !this.contentWindow) {
470         this.stopTrackingMouseOverVideos();
471       } else {
472         this.contentWindow.requestIdleCallback(() => {
473           this.stopTrackingMouseOverVideos();
474         });
475       }
476     }
477   }
479   addMouseButtonListeners() {
480     // We want to try to cancel the mouse events from continuing
481     // on into content if the user has clicked on the toggle, so
482     // we don't use the mozSystemGroup here, and add the listener
483     // to the parent target of the window, which in this case,
484     // is the windowRoot. Since this event listener is attached to
485     // part of the outer window, we need to also remove it in a
486     // pagehide event listener in the event that the page unloads
487     // before stopTrackingMouseOverVideos fires.
488     this.contentWindow.windowRoot.addEventListener("pointerdown", this, {
489       capture: true,
490     });
491     this.contentWindow.windowRoot.addEventListener("mousedown", this, {
492       capture: true,
493     });
494     this.contentWindow.windowRoot.addEventListener("mouseup", this, {
495       capture: true,
496     });
497     this.contentWindow.windowRoot.addEventListener("pointerup", this, {
498       capture: true,
499     });
500     this.contentWindow.windowRoot.addEventListener("click", this, {
501       capture: true,
502     });
503     this.contentWindow.windowRoot.addEventListener("mouseout", this, {
504       capture: true,
505     });
506   }
508   removeMouseButtonListeners() {
509     // This can be null when closing the tab, but the event
510     // listeners should be removed in that case already.
511     if (!this.contentWindow || !this.contentWindow.windowRoot) {
512       return;
513     }
515     this.contentWindow.windowRoot.removeEventListener("pointerdown", this, {
516       capture: true,
517     });
518     this.contentWindow.windowRoot.removeEventListener("mousedown", this, {
519       capture: true,
520     });
521     this.contentWindow.windowRoot.removeEventListener("mouseup", this, {
522       capture: true,
523     });
524     this.contentWindow.windowRoot.removeEventListener("pointerup", this, {
525       capture: true,
526     });
527     this.contentWindow.windowRoot.removeEventListener("click", this, {
528       capture: true,
529     });
530     this.contentWindow.windowRoot.removeEventListener("mouseout", this, {
531       capture: true,
532     });
533   }
535   /**
536    * One of the challenges of displaying this toggle is that many sites put
537    * things over top of <video> elements, like custom controls, or images, or
538    * all manner of things that might intercept mouseevents that would normally
539    * fire directly on the <video>. In order to properly detect when the mouse
540    * is over top of one of the <video> elements in this situation, we currently
541    * add a mousemove event handler to the entire document, and stash the most
542    * recent mousemove that fires. At periodic intervals, that stashed mousemove
543    * event is checked to see if it's hovering over one of our registered
544    * <video> elements.
545    *
546    * This sort of thing will not be necessary once bug 1539652 is fixed.
547    */
548   beginTrackingMouseOverVideos() {
549     let state = this.docState;
550     if (!state.mousemoveDeferredTask) {
551       state.mousemoveDeferredTask = new DeferredTask(() => {
552         this.checkLastMouseMove();
553       }, MOUSEMOVE_PROCESSING_DELAY_MS);
554     }
555     this.document.addEventListener("mousemove", this, {
556       mozSystemGroup: true,
557       capture: true,
558     });
559     this.contentWindow.addEventListener("pageshow", this, {
560       mozSystemGroup: true,
561     });
562     this.contentWindow.addEventListener("pagehide", this, {
563       mozSystemGroup: true,
564     });
565     this.addMouseButtonListeners();
566     state.isTrackingVideos = true;
567   }
569   /**
570    * If we no longer have any interesting videos in the viewport, we deregister
571    * the mousemove and click listeners, and also remove any toggles that might
572    * be on the page still.
573    */
574   stopTrackingMouseOverVideos() {
575     let state = this.docState;
576     // We initialize `mousemoveDeferredTask` in `beginTrackingMouseOverVideos`.
577     // If it doesn't exist, that can't have happened. Nothing else ever sets
578     // this value (though we arm/disarm in various places). So we don't need
579     // to do anything else here and can return early.
580     if (!state.mousemoveDeferredTask) {
581       return;
582     }
583     state.mousemoveDeferredTask.disarm();
584     this.document.removeEventListener("mousemove", this, {
585       mozSystemGroup: true,
586       capture: true,
587     });
588     if (this.contentWindow) {
589       this.contentWindow.removeEventListener("pageshow", this, {
590         mozSystemGroup: true,
591       });
592       this.contentWindow.removeEventListener("pagehide", this, {
593         mozSystemGroup: true,
594       });
595     }
596     this.removeMouseButtonListeners();
597     let oldOverVideo = this.getWeakOverVideo();
598     if (oldOverVideo) {
599       this.onMouseLeaveVideo(oldOverVideo);
600     }
601     state.isTrackingVideos = false;
602   }
604   /**
605    * This pageshow event handler will get called if and when we complete a tab
606    * tear out or in. If we happened to be tracking videos before the tear
607    * occurred, we re-add the mouse event listeners so that they're attached to
608    * the right WindowRoot.
609    *
610    * @param {Event} event The pageshow event fired when completing a tab tear
611    * out or in.
612    */
613   onPageShow(event) {
614     let state = this.docState;
615     if (state.isTrackingVideos) {
616       this.addMouseButtonListeners();
617     }
618   }
620   /**
621    * This pagehide event handler will get called if and when we start a tab
622    * tear out or in. If we happened to be tracking videos before the tear
623    * occurred, we remove the mouse event listeners. We'll re-add them when the
624    * pageshow event fires.
625    *
626    * @param {Event} event The pagehide event fired when starting a tab tear
627    * out or in.
628    */
629   onPageHide(event) {
630     let state = this.docState;
631     if (state.isTrackingVideos) {
632       this.removeMouseButtonListeners();
633     }
634   }
636   /**
637    * If we're tracking <video> elements, this pointerdown event handler is run anytime
638    * a pointerdown occurs on the document. This function is responsible for checking
639    * if the user clicked on the Picture-in-Picture toggle. It does this by first
640    * checking if the video is visible beneath the point that was clicked. Then
641    * it tests whether or not the pointerdown occurred within the rectangle of the
642    * toggle. If so, the event's propagation is stopped, and Picture-in-Picture is
643    * triggered.
644    *
645    * @param {Event} event The mousemove event.
646    */
647   onPointerDown(event) {
648     // The toggle ignores non-primary mouse clicks.
649     if (event.button != 0) {
650       return;
651     }
653     let video = this.getWeakOverVideo();
654     if (!video) {
655       return;
656     }
658     let shadowRoot = video.openOrClosedShadowRoot;
659     if (!shadowRoot) {
660       return;
661     }
663     let state = this.docState;
664     let { clientX, clientY } = event;
665     let winUtils = this.contentWindow.windowUtils;
666     // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
667     // since document.elementsFromPoint always flushes layout. The 1's in that
668     // function call are for the size of the rect that we want, which is 1x1.
669     //
670     // We pass the aOnlyVisible boolean argument to check that the video isn't
671     // occluded by anything visible at the point of mousedown. If it is, we'll
672     // ignore the mousedown.
673     let elements = winUtils.nodesFromRect(
674       clientX,
675       clientY,
676       1,
677       1,
678       1,
679       1,
680       true,
681       false,
682       true /* aOnlyVisible */,
683       state.toggleVisibilityThreshold
684     );
685     if (!Array.from(elements).includes(video)) {
686       return;
687     }
689     let toggle = this.getToggleElement(shadowRoot);
690     if (this.isMouseOverToggle(toggle, event)) {
691       state.isClickingToggle = true;
692       state.clickedElement = Cu.getWeakReference(event.originalTarget);
693       event.stopImmediatePropagation();
695       Services.telemetry.keyedScalarAdd(
696         "pictureinpicture.opened_method",
697         "toggle",
698         1
699       );
701       let pipEvent = new this.contentWindow.CustomEvent(
702         "MozTogglePictureInPicture",
703         {
704           bubbles: true,
705         }
706       );
707       video.dispatchEvent(pipEvent);
709       // Since we've initiated Picture-in-Picture, we can go ahead and
710       // hide the toggle now.
711       this.onMouseLeaveVideo(video);
712     }
713   }
715   /**
716    * Called for mousedown, pointerup, mouseup and click events. If we
717    * detected that the user is clicking on the Picture-in-Picture toggle,
718    * these events are cancelled in the capture-phase before they reach
719    * content. The state for suppressing these events is cleared on the
720    * click event (unless the mouseup occurs on a different element from
721    * the mousedown, in which case, the state is cleared on mouseup).
722    *
723    * @param {Event} event A mousedown, pointerup, mouseup or click event.
724    */
725   onMouseButtonEvent(event) {
726     // The toggle ignores non-primary mouse clicks.
727     if (event.button != 0) {
728       return;
729     }
731     let state = this.docState;
732     if (state.isClickingToggle) {
733       event.stopImmediatePropagation();
735       // If this is a mouseup event, check to see if we have a record of what
736       // the original target was on pointerdown. If so, and if it doesn't match
737       // the mouseup original target, that means we won't get a click event, and
738       // we can clear the "clicking the toggle" state right away.
739       //
740       // Otherwise, we wait for the click event to do that.
741       let isMouseUpOnOtherElement =
742         event.type == "mouseup" &&
743         (!state.clickedElement ||
744           state.clickedElement.get() != event.originalTarget);
746       if (isMouseUpOnOtherElement || event.type == "click") {
747         // The click is complete, so now we reset the state so that
748         // we stop suppressing these events.
749         state.isClickingToggle = false;
750         state.clickedElement = null;
751       }
752     }
753   }
755   /**
756    * Called on mouseout events to determine whether or not the mouse has
757    * exited the window.
758    *
759    * @param {Event} event The mouseout event.
760    */
761   onMouseOut(event) {
762     if (!event.relatedTarget) {
763       // For mouseout events, if there's no relatedTarget (which normally
764       // maps to the element that the mouse entered into) then this means that
765       // we left the window.
766       let video = this.getWeakOverVideo();
767       if (!video) {
768         return;
769       }
771       this.onMouseLeaveVideo(video);
772     }
773   }
775   /**
776    * Called for each mousemove event when we're tracking those events to
777    * determine if the cursor is hovering over a <video>.
778    *
779    * @param {Event} event The mousemove event.
780    */
781   onMouseMove(event) {
782     let state = this.docState;
784     if (state.hideToggleDeferredTask) {
785       state.hideToggleDeferredTask.disarm();
786       state.hideToggleDeferredTask.arm();
787     }
789     state.lastMouseMoveEvent = event;
790     state.mousemoveDeferredTask.arm();
791   }
793   /**
794    * Called by the DeferredTask after MOUSEMOVE_PROCESSING_DELAY_MS
795    * milliseconds. Checked to see if that mousemove happens to be overtop of
796    * any interesting <video> elements that we want to display the toggle
797    * on. If so, puts the toggle on that video.
798    */
799   checkLastMouseMove() {
800     let state = this.docState;
801     let event = state.lastMouseMoveEvent;
802     let { clientX, clientY } = event;
803     let winUtils = this.contentWindow.windowUtils;
804     // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
805     // since document.elementsFromPoint always flushes layout. The 1's in that
806     // function call are for the size of the rect that we want, which is 1x1.
807     let elements = winUtils.nodesFromRect(
808       clientX,
809       clientY,
810       1,
811       1,
812       1,
813       1,
814       true,
815       false,
816       true
817     );
819     for (let element of elements) {
820       if (
821         state.weakVisibleVideos.has(element) &&
822         !element.isCloningElementVisually
823       ) {
824         this.onMouseOverVideo(element, event);
825         return;
826       }
827     }
829     let oldOverVideo = this.getWeakOverVideo();
830     if (oldOverVideo) {
831       this.onMouseLeaveVideo(oldOverVideo);
832     }
833   }
835   /**
836    * Called once it has been determined that the mouse is overtop of a video
837    * that is in the viewport.
838    *
839    * @param {Element} video The video the mouse is over.
840    */
841   onMouseOverVideo(video, event) {
842     let oldOverVideo = this.getWeakOverVideo();
843     let shadowRoot = video.openOrClosedShadowRoot;
845     if (shadowRoot.firstChild && video != oldOverVideo) {
846       if (video.getTransformToViewport().a == -1) {
847         shadowRoot.firstChild.setAttribute("flipped", true);
848       } else {
849         shadowRoot.firstChild.removeAttribute("flipped");
850       }
851     }
853     // It seems from automated testing that if it's still very early on in the
854     // lifecycle of a <video> element, it might not yet have a shadowRoot,
855     // in which case, we can bail out here early.
856     if (!shadowRoot) {
857       if (oldOverVideo) {
858         // We also clear the hover state on the old video we were hovering,
859         // if there was one.
860         this.onMouseLeaveVideo(oldOverVideo);
861       }
863       return;
864     }
866     let state = this.docState;
867     let toggle = this.getToggleElement(shadowRoot);
868     let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
870     if (state.checkedPolicyDocumentURI != this.document.documentURI) {
871       state.togglePolicy = TOGGLE_POLICIES.DEFAULT;
872       // We cache the matchers process-wide. We'll skip this while running tests to make that
873       // easier.
874       let siteOverrides = this.toggleTesting
875         ? PictureInPictureToggleChild.getSiteOverrides()
876         : gSiteOverrides;
878       // Do we have any toggle overrides? If so, try to apply them.
879       for (let [override, { policy, visibilityThreshold }] of siteOverrides) {
880         if (
881           (policy || visibilityThreshold) &&
882           override.matches(this.document.documentURI)
883         ) {
884           state.togglePolicy = policy || TOGGLE_POLICIES.DEFAULT;
885           state.toggleVisibilityThreshold = visibilityThreshold || 1.0;
886           break;
887         }
888       }
890       state.checkedPolicyDocumentURI = this.document.documentURI;
891     }
893     // The built-in <video> controls are along the bottom, which would overlap the
894     // toggle if the override is set to BOTTOM, so we ignore overrides that set
895     // a policy of BOTTOM for <video> elements with controls.
896     if (
897       state.togglePolicy != TOGGLE_POLICIES.DEFAULT &&
898       !(state.togglePolicy == TOGGLE_POLICIES.BOTTOM && video.controls)
899     ) {
900       toggle.setAttribute("policy", TOGGLE_POLICY_STRINGS[state.togglePolicy]);
901     } else {
902       toggle.removeAttribute("policy");
903     }
905     controlsOverlay.removeAttribute("hidetoggle");
907     // The hideToggleDeferredTask we create here is for automatically hiding
908     // the toggle after a period of no mousemove activity for
909     // TOGGLE_HIDING_TIMEOUT_MS. If the mouse moves, then the DeferredTask
910     // timer is reset.
911     //
912     // We disable the toggle hiding timeout during testing to reduce
913     // non-determinism from timers when testing the toggle.
914     if (!state.hideToggleDeferredTask && !this.toggleTesting) {
915       state.hideToggleDeferredTask = new DeferredTask(() => {
916         controlsOverlay.setAttribute("hidetoggle", true);
917       }, TOGGLE_HIDING_TIMEOUT_MS);
918     }
920     if (oldOverVideo) {
921       if (oldOverVideo == video) {
922         // If we're still hovering the old video, we might have entered or
923         // exited the toggle region.
924         this.checkHoverToggle(toggle, event);
925         return;
926       }
928       // We had an old video that we were hovering, and we're not hovering
929       // it anymore. Let's leave it.
930       this.onMouseLeaveVideo(oldOverVideo);
931     }
933     state.weakOverVideo = Cu.getWeakReference(video);
934     controlsOverlay.classList.add("hovering");
936     if (
937       state.togglePolicy != TOGGLE_POLICIES.HIDDEN &&
938       !toggle.hasAttribute("hidden")
939     ) {
940       Services.telemetry.scalarAdd("pictureinpicture.saw_toggle", 1);
941     }
943     // Now that we're hovering the video, we'll check to see if we're
944     // hovering the toggle too.
945     this.checkHoverToggle(toggle, event);
946   }
948   /**
949    * Checks if a mouse event is happening over a toggle element. If it is,
950    * sets the hovering class on it. Otherwise, it clears the hovering
951    * class.
952    *
953    * @param {Element} toggle The Picture-in-Picture toggle to check.
954    * @param {MouseEvent} event A MouseEvent to test.
955    */
956   checkHoverToggle(toggle, event) {
957     toggle.classList.toggle("hovering", this.isMouseOverToggle(toggle, event));
958   }
960   /**
961    * Called once it has been determined that the mouse is no longer overlapping
962    * a video that we'd previously called onMouseOverVideo with.
963    *
964    * @param {Element} video The video that the mouse left.
965    */
966   onMouseLeaveVideo(video) {
967     let state = this.docState;
968     let shadowRoot = video.openOrClosedShadowRoot;
970     if (shadowRoot) {
971       let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
972       let toggle = this.getToggleElement(shadowRoot);
973       controlsOverlay.classList.remove("hovering");
974       toggle.classList.remove("hovering");
975     }
977     state.weakOverVideo = null;
979     if (!this.toggleTesting) {
980       state.hideToggleDeferredTask.disarm();
981       state.mousemoveDeferredTask.disarm();
982     }
984     state.hideToggleDeferredTask = null;
985   }
987   /**
988    * Given a reference to a Picture-in-Picture toggle element, determines
989    * if a MouseEvent event is occurring within its bounds.
990    *
991    * @param {Element} toggle The Picture-in-Picture toggle.
992    * @param {MouseEvent} event A MouseEvent to test.
993    *
994    * @return {Boolean}
995    */
996   isMouseOverToggle(toggle, event) {
997     let toggleRect = toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing(
998       toggle
999     );
1001     // The way the toggle is currently implemented with
1002     // absolute positioning, the root toggle element bounds don't actually
1003     // contain all of the toggle child element bounds. Until we find a way to
1004     // sort that out, we workaround the issue by having each clickable child
1005     // elements of the toggle have a clicklable class, and then compute the
1006     // smallest rect that contains all of their bounding rects and use that
1007     // as the hitbox.
1008     toggleRect = Rect.fromRect(toggleRect);
1009     let clickableChildren = toggle.querySelectorAll(".clickable");
1010     for (let child of clickableChildren) {
1011       let childRect = Rect.fromRect(
1012         child.ownerGlobal.windowUtils.getBoundsWithoutFlushing(child)
1013       );
1014       toggleRect.expandToContain(childRect);
1015     }
1017     // If the toggle has no dimensions, we're definitely not over it.
1018     if (!toggleRect.width || !toggleRect.height) {
1019       return false;
1020     }
1022     let { clientX, clientY } = event;
1024     return (
1025       clientX >= toggleRect.left &&
1026       clientX <= toggleRect.right &&
1027       clientY >= toggleRect.top &&
1028       clientY <= toggleRect.bottom
1029     );
1030   }
1032   /**
1033    * Checks a contextmenu event to see if the mouse is currently over the
1034    * Picture-in-Picture toggle. If so, sends a message to the parent process
1035    * to open up the Picture-in-Picture toggle context menu.
1036    *
1037    * @param {MouseEvent} event A contextmenu event.
1038    */
1039   checkContextMenu(event) {
1040     let video = this.getWeakOverVideo();
1041     if (!video) {
1042       return;
1043     }
1045     let shadowRoot = video.openOrClosedShadowRoot;
1046     if (!shadowRoot) {
1047       return;
1048     }
1050     let toggle = this.getToggleElement(shadowRoot);
1051     if (this.isMouseOverToggle(toggle, event)) {
1052       event.stopImmediatePropagation();
1053       event.preventDefault();
1055       this.sendAsyncMessage("PictureInPicture:OpenToggleContextMenu", {
1056         screenX: event.screenX,
1057         screenY: event.screenY,
1058         mozInputSource: event.mozInputSource,
1059       });
1060     }
1061   }
1063   /**
1064    * Returns the appropriate root element for the Picture-in-Picture toggle,
1065    * depending on whether or not we're using the experimental toggle preference.
1066    *
1067    * @param {Element} shadowRoot The shadowRoot of the video element.
1068    * @returns {Element} The toggle element.
1069    */
1070   getToggleElement(shadowRoot) {
1071     return shadowRoot.getElementById("pictureInPictureToggle");
1072   }
1074   /**
1075    * This is a test-only function that returns true if a video is being tracked
1076    * for mouseover events after having intersected the viewport.
1077    */
1078   static isTracking(video) {
1079     return gWeakIntersectingVideosForTesting.has(video);
1080   }
1082   /**
1083    * Gets any Picture-in-Picture site-specific overrides stored in the
1084    * sharedData struct, and returns them as an Array of two-element Arrays,
1085    * where the first element is a MatchPattern and the second element is an
1086    * object of the form { policy, keyboardControls } (where each property
1087    * may be missing or undefined).
1088    *
1089    * @returns {Array<Array<2>>} Array of 2-element Arrays where the first element
1090    * is a MatchPattern and the second element is an object with optional policy
1091    * and/or keyboardControls properties.
1092    */
1093   static getSiteOverrides() {
1094     let result = [];
1095     let patterns = Services.cpmm.sharedData.get(
1096       "PictureInPicture:SiteOverrides"
1097     );
1098     for (let pattern in patterns) {
1099       let matcher = new MatchPattern(pattern);
1100       result.push([matcher, patterns[pattern]]);
1101     }
1102     return result;
1103   }
1106 class PictureInPictureChild extends JSWindowActorChild {
1107   // A weak reference to this PiP window's video element
1108   weakVideo = null;
1110   // A weak reference to this PiP window's content window
1111   weakPlayerContent = null;
1113   /**
1114    * Returns a reference to the PiP's <video> element being displayed in Picture-in-Picture
1115    * mode.
1116    *
1117    * @return {Element} The <video> being displayed in Picture-in-Picture mode, or null
1118    * if that <video> no longer exists.
1119    */
1120   getWeakVideo() {
1121     if (this.weakVideo) {
1122       // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
1123       // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
1124       try {
1125         return this.weakVideo.get();
1126       } catch (e) {
1127         return null;
1128       }
1129     }
1130     return null;
1131   }
1133   /**
1134    * Returns a reference to the inner window of the about:blank document that is
1135    * cloning the originating <video> in the always-on-top player <xul:browser>.
1136    *
1137    * @return {Window} The inner window of the about:blank player <xul:browser>, or
1138    * null if that window has been closed.
1139    */
1140   getWeakPlayerContent() {
1141     if (this.weakPlayerContent) {
1142       // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
1143       // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
1144       try {
1145         return this.weakPlayerContent.get();
1146       } catch (e) {
1147         return null;
1148       }
1149     }
1150     return null;
1151   }
1153   /**
1154    * Returns true if the passed video happens to be the one that this
1155    * content process is running in a Picture-in-Picture window.
1156    *
1157    * @param {Element} video The <video> element to check.
1158    *
1159    * @return {Boolean}
1160    */
1161   inPictureInPicture(video) {
1162     return this.getWeakVideo() === video;
1163   }
1165   static videoIsPlaying(video) {
1166     return !!(!video.paused && !video.ended && video.readyState > 2);
1167   }
1169   static videoIsMuted(video) {
1170     return video.muted;
1171   }
1173   handleEvent(event) {
1174     switch (event.type) {
1175       case "MozStopPictureInPicture": {
1176         if (event.isTrusted && event.target === this.getWeakVideo()) {
1177           const reason = event.detail?.reason || "video-el-remove";
1178           this.closePictureInPicture({ reason });
1179         }
1180         break;
1181       }
1182       case "pagehide": {
1183         // The originating video's content document has unloaded,
1184         // so close Picture-in-Picture.
1185         this.closePictureInPicture({ reason: "pagehide" });
1186         break;
1187       }
1188       case "MozDOMFullscreen:Request": {
1189         this.closePictureInPicture({ reason: "fullscreen" });
1190         break;
1191       }
1192       case "play": {
1193         this.sendAsyncMessage("PictureInPicture:Playing");
1194         break;
1195       }
1196       case "pause": {
1197         this.sendAsyncMessage("PictureInPicture:Paused");
1198         break;
1199       }
1200       case "volumechange": {
1201         let video = this.getWeakVideo();
1203         // Just double-checking that we received the event for the right
1204         // video element.
1205         if (video !== event.target) {
1206           Cu.reportError(
1207             "PictureInPictureChild received volumechange for " +
1208               "the wrong video!"
1209           );
1210           return;
1211         }
1213         if (video.muted) {
1214           this.sendAsyncMessage("PictureInPicture:Muting");
1215         } else {
1216           this.sendAsyncMessage("PictureInPicture:Unmuting");
1217         }
1218         break;
1219       }
1220       case "resize": {
1221         let video = event.target;
1222         if (this.inPictureInPicture(video)) {
1223           this.sendAsyncMessage("PictureInPicture:Resize", {
1224             videoHeight: video.videoHeight,
1225             videoWidth: video.videoWidth,
1226           });
1227         }
1228         break;
1229       }
1230     }
1231   }
1233   /**
1234    * Tells the parent to close a pre-existing Picture-in-Picture
1235    * window.
1236    *
1237    * @return {Promise}
1238    *
1239    * @resolves {undefined} Once the pre-existing Picture-in-Picture
1240    * window has unloaded.
1241    */
1242   async closePictureInPicture({ reason }) {
1243     let video = this.getWeakVideo();
1244     if (video) {
1245       this.untrackOriginatingVideo(video);
1246     }
1247     this.sendAsyncMessage("PictureInPicture:Close", {
1248       reason,
1249     });
1251     let playerContent = this.getWeakPlayerContent();
1252     if (playerContent) {
1253       if (!playerContent.closed) {
1254         await new Promise(resolve => {
1255           playerContent.addEventListener("unload", resolve, {
1256             once: true,
1257           });
1258         });
1259       }
1260       // Nothing should be holding a reference to the Picture-in-Picture
1261       // player window content at this point, but just in case, we'll
1262       // clear the weak reference directly so nothing else can get a hold
1263       // of it from this angle.
1264       this.weakPlayerContent = null;
1265     }
1266   }
1268   receiveMessage(message) {
1269     switch (message.name) {
1270       case "PictureInPicture:SetupPlayer": {
1271         const { videoRef } = message.data;
1272         this.setupPlayer(videoRef);
1273         break;
1274       }
1275       case "PictureInPicture:Play": {
1276         this.play();
1277         break;
1278       }
1279       case "PictureInPicture:Pause": {
1280         if (message.data && message.data.reason == "pip-closed") {
1281           let video = this.getWeakVideo();
1283           // Currently in Firefox srcObjects are MediaStreams. However, by spec a srcObject
1284           // can be either a MediaStream, MediaSource or Blob. In case of future changes
1285           // we do not want to pause MediaStream srcObjects and we want to maintain current
1286           // behavior for non-MediaStream srcObjects.
1287           if (video && video.srcObject instanceof MediaStream) {
1288             break;
1289           }
1290         }
1291         this.pause();
1292         break;
1293       }
1294       case "PictureInPicture:Mute": {
1295         this.mute();
1296         break;
1297       }
1298       case "PictureInPicture:Unmute": {
1299         this.unmute();
1300         break;
1301       }
1302       case "PictureInPicture:KeyDown": {
1303         this.keyDown(message.data);
1304         break;
1305       }
1306     }
1307   }
1309   /**
1310    * Keeps an eye on the originating video's document. If it ever
1311    * goes away, this will cause the Picture-in-Picture window for any
1312    * of its content to go away as well.
1313    */
1314   trackOriginatingVideo(originatingVideo) {
1315     let originatingWindow = originatingVideo.ownerGlobal;
1316     if (originatingWindow) {
1317       originatingWindow.addEventListener("pagehide", this);
1318       originatingVideo.addEventListener("play", this);
1319       originatingVideo.addEventListener("pause", this);
1320       originatingVideo.addEventListener("volumechange", this);
1321       originatingVideo.addEventListener("resize", this);
1323       let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
1324       chromeEventHandler.addEventListener(
1325         "MozDOMFullscreen:Request",
1326         this,
1327         true
1328       );
1329       chromeEventHandler.addEventListener(
1330         "MozStopPictureInPicture",
1331         this,
1332         true
1333       );
1334     }
1335   }
1337   /**
1338    * Stops tracking the originating video's document. This should
1339    * happen once the Picture-in-Picture window goes away (or is about
1340    * to go away), and we no longer care about hearing when the originating
1341    * window's document unloads.
1342    */
1343   untrackOriginatingVideo(originatingVideo) {
1344     let originatingWindow = originatingVideo.ownerGlobal;
1345     if (originatingWindow) {
1346       originatingWindow.removeEventListener("pagehide", this);
1347       originatingVideo.removeEventListener("play", this);
1348       originatingVideo.removeEventListener("pause", this);
1349       originatingVideo.removeEventListener("volumechange", this);
1350       originatingVideo.removeEventListener("resize", this);
1352       let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
1353       chromeEventHandler.removeEventListener(
1354         "MozDOMFullscreen:Request",
1355         this,
1356         true
1357       );
1358       chromeEventHandler.removeEventListener(
1359         "MozStopPictureInPicture",
1360         this,
1361         true
1362       );
1363     }
1364   }
1366   /**
1367    * Runs in an instance of PictureInPictureChild for the
1368    * player window's content, and not the originating video
1369    * content. Sets up the player so that it clones the originating
1370    * video. If anything goes wrong during set up, a message is
1371    * sent to the parent to close the Picture-in-Picture window.
1372    *
1373    * @param videoRef {ContentDOMReference}
1374    *    A reference to the video element that a Picture-in-Picture window
1375    *    is being created for
1376    * @return {Promise}
1377    * @resolves {undefined} Once the player window has been set up
1378    * properly, or a pre-existing Picture-in-Picture window has gone
1379    * away due to an unexpected error.
1380    */
1381   async setupPlayer(videoRef) {
1382     const video = await ContentDOMReference.resolve(videoRef);
1384     this.weakVideo = Cu.getWeakReference(video);
1385     let originatingVideo = this.getWeakVideo();
1386     if (!originatingVideo) {
1387       // If the video element has gone away before we've had a chance to set up
1388       // Picture-in-Picture for it, tell the parent to close the Picture-in-Picture
1389       // window.
1390       await this.closePictureInPicture({ reason: "setup-failure" });
1391       return;
1392     }
1394     let loadPromise = new Promise(resolve => {
1395       this.contentWindow.addEventListener("load", resolve, {
1396         once: true,
1397         mozSystemGroup: true,
1398         capture: true,
1399       });
1400     });
1401     this.contentWindow.location.reload();
1402     await loadPromise;
1404     // We're committed to adding the video to this window now. Ensure we track
1405     // the content window before we do so, so that the toggle actor can
1406     // distinguish this new video we're creating from web-controlled ones.
1407     this.weakPlayerContent = Cu.getWeakReference(this.contentWindow);
1408     gPlayerContents.add(this.contentWindow);
1410     let doc = this.document;
1411     let playerVideo = doc.createElement("video");
1413     doc.body.style.overflow = "hidden";
1414     doc.body.style.margin = "0";
1416     // Force the player video to assume maximum height and width of the
1417     // containing window
1418     playerVideo.style.height = "100vh";
1419     playerVideo.style.width = "100vw";
1420     playerVideo.style.backgroundColor = "#000";
1422     doc.body.appendChild(playerVideo);
1424     originatingVideo.cloneElementVisually(playerVideo);
1426     let shadowRoot = originatingVideo.openOrClosedShadowRoot;
1427     if (originatingVideo.getTransformToViewport().a == -1) {
1428       shadowRoot.firstChild.setAttribute("flipped", true);
1429       playerVideo.style.transform = "scaleX(-1)";
1430     }
1432     this.trackOriginatingVideo(originatingVideo);
1434     this.contentWindow.addEventListener(
1435       "unload",
1436       () => {
1437         let video = this.getWeakVideo();
1438         if (video) {
1439           this.untrackOriginatingVideo(video);
1440           video.stopCloningElementVisually();
1441         }
1442         this.weakVideo = null;
1443       },
1444       { once: true }
1445     );
1446   }
1448   play() {
1449     let video = this.getWeakVideo();
1450     if (video) {
1451       video.play();
1452     }
1453   }
1455   pause() {
1456     let video = this.getWeakVideo();
1457     if (video) {
1458       video.pause();
1459     }
1460   }
1462   mute() {
1463     let video = this.getWeakVideo();
1464     if (video) {
1465       video.muted = true;
1466     }
1467   }
1469   unmute() {
1470     let video = this.getWeakVideo();
1471     if (video) {
1472       video.muted = false;
1473     }
1474   }
1476   /**
1477    * This checks if a given keybinding has been disabled for the specific site
1478    * currently being viewed.
1479    */
1480   isKeyEnabled(key) {
1481     const video = this.getWeakVideo();
1482     if (!video) {
1483       return false;
1484     }
1485     const { documentURI } = video.ownerDocument;
1486     if (!documentURI) {
1487       return true;
1488     }
1489     for (let [override, { keyboardControls }] of gSiteOverrides) {
1490       if (keyboardControls !== undefined && override.matches(documentURI)) {
1491         if (keyboardControls === KEYBOARD_CONTROLS.NONE) {
1492           return false;
1493         }
1494         return keyboardControls & key;
1495       }
1496     }
1497     return true;
1498   }
1500   /**
1501    * This reuses the keyHandler logic in the VideoControlsWidget
1502    * https://searchfox.org/mozilla-central/rev/cfd1cc461f1efe0d66c2fdc17c024a203d5a2fd8/toolkit/content/widgets/videocontrols.js#1687-1810.
1503    * There are future plans to eventually combine the two implementations.
1504    */
1505   /* eslint-disable complexity */
1506   keyDown({ altKey, shiftKey, metaKey, ctrlKey, keyCode }) {
1507     let video = this.getWeakVideo();
1508     if (!video) {
1509       return;
1510     }
1512     var keystroke = "";
1513     if (altKey) {
1514       keystroke += "alt-";
1515     }
1516     if (shiftKey) {
1517       keystroke += "shift-";
1518     }
1519     if (this.contentWindow.navigator.platform.startsWith("Mac")) {
1520       if (metaKey) {
1521         keystroke += "accel-";
1522       }
1523       if (ctrlKey) {
1524         keystroke += "control-";
1525       }
1526     } else {
1527       if (metaKey) {
1528         keystroke += "meta-";
1529       }
1530       if (ctrlKey) {
1531         keystroke += "accel-";
1532       }
1533     }
1535     switch (keyCode) {
1536       case this.contentWindow.KeyEvent.DOM_VK_UP:
1537         keystroke += "upArrow";
1538         break;
1539       case this.contentWindow.KeyEvent.DOM_VK_DOWN:
1540         keystroke += "downArrow";
1541         break;
1542       case this.contentWindow.KeyEvent.DOM_VK_LEFT:
1543         keystroke += "leftArrow";
1544         break;
1545       case this.contentWindow.KeyEvent.DOM_VK_RIGHT:
1546         keystroke += "rightArrow";
1547         break;
1548       case this.contentWindow.KeyEvent.DOM_VK_HOME:
1549         keystroke += "home";
1550         break;
1551       case this.contentWindow.KeyEvent.DOM_VK_END:
1552         keystroke += "end";
1553         break;
1554       case this.contentWindow.KeyEvent.DOM_VK_SPACE:
1555         keystroke += "space";
1556         break;
1557     }
1559     const isVideoStreaming = video.duration == +Infinity;
1560     var oldval, newval;
1562     try {
1563       switch (keystroke) {
1564         case "space" /* Toggle Play / Pause */:
1565           if (!this.isKeyEnabled(KEYBOARD_CONTROLS.PLAY_PAUSE)) {
1566             return;
1567           }
1568           if (video.paused || video.ended) {
1569             video.play();
1570           } else {
1571             video.pause();
1572           }
1573           break;
1574         case "downArrow" /* Volume decrease */:
1575           if (!this.isKeyEnabled(KEYBOARD_CONTROLS.VOLUME)) {
1576             return;
1577           }
1578           oldval = video.volume;
1579           video.volume = oldval < 0.1 ? 0 : oldval - 0.1;
1580           video.muted = false;
1581           break;
1582         case "upArrow" /* Volume increase */:
1583           if (!this.isKeyEnabled(KEYBOARD_CONTROLS.VOLUME)) {
1584             return;
1585           }
1586           oldval = video.volume;
1587           video.volume = oldval > 0.9 ? 1 : oldval + 0.1;
1588           video.muted = false;
1589           break;
1590         case "accel-downArrow" /* Mute */:
1591           if (!this.isKeyEnabled(KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
1592             return;
1593           }
1594           video.muted = true;
1595           break;
1596         case "accel-upArrow" /* Unmute */:
1597           if (!this.isKeyEnabled(KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
1598             return;
1599           }
1600           video.muted = false;
1601           break;
1602         case "leftArrow": /* Seek back 15 seconds */
1603         case "accel-leftArrow" /* Seek back 10% */:
1604           if (isVideoStreaming || !this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) {
1605             return;
1606           }
1608           oldval = video.currentTime;
1609           if (keystroke == "leftArrow") {
1610             newval = oldval - 15;
1611           } else {
1612             newval = oldval - video.duration / 10;
1613           }
1614           video.currentTime = newval >= 0 ? newval : 0;
1615           break;
1616         case "rightArrow": /* Seek forward 15 seconds */
1617         case "accel-rightArrow" /* Seek forward 10% */:
1618           if (isVideoStreaming || !this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) {
1619             return;
1620           }
1622           oldval = video.currentTime;
1623           var maxtime = video.duration;
1624           if (keystroke == "rightArrow") {
1625             newval = oldval + 15;
1626           } else {
1627             newval = oldval + maxtime / 10;
1628           }
1629           video.currentTime = newval <= maxtime ? newval : maxtime;
1630           break;
1631         case "home" /* Seek to beginning */:
1632           if (!this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) {
1633             return;
1634           }
1635           if (!isVideoStreaming) {
1636             video.currentTime = 0;
1637           }
1638           break;
1639         case "end" /* Seek to end */:
1640           if (!this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) {
1641             return;
1642           }
1643           if (!isVideoStreaming && video.currentTime != video.duration) {
1644             video.currentTime = video.duration;
1645           }
1646           break;
1647         default:
1648       }
1649     } catch (e) {
1650       /* ignore any exception from setting video.currentTime */
1651     }
1652   }