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/. */
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",
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",
26 XPCOMUtils.defineLazyPreferenceGetter(
28 "DISPLAY_TEXT_TRACKS_PREF",
29 "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
32 XPCOMUtils.defineLazyPreferenceGetter(
34 "IMPROVED_CONTROLS_ENABLED_PREF",
35 "media.videocontrols.picture-in-picture.improved-video-controls.enabled",
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
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();
73 XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
74 return console.createInstance({
75 prefix: "PictureInPictureChild",
76 maxLogLevel: Services.prefs.getBoolPref(
77 "media.videocontrols.picture-in-picture.log",
86 * Creates and returns an instance of the PictureInPictureChildVideoWrapper class responsible
87 * for applying site-specific wrapper methods around the original video.
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
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);
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(
114 export class PictureInPictureLauncherChild extends JSWindowActorChild {
116 switch (event.type) {
117 case "MozTogglePictureInPicture": {
118 if (event.isTrusted) {
119 this.togglePictureInPicture(event.target);
126 receiveMessage(message) {
127 switch (message.name) {
128 case "PictureInPicture:KeyToggle": {
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.
141 * @param {Element} video The <video> element to view in a Picture
145 * @resolves {undefined} Once the new Picture-in-Picture window
146 * has been requested.
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",
158 detail: { reason: "context-menu" },
161 video.dispatchEvent(stopPipEvent);
165 if (!PictureInPictureChild.videoWrapper) {
166 PictureInPictureChild.videoWrapper = applyWrapper(
167 PictureInPictureChild,
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)
181 // Scrubber is hidden if undefined, so only set it to something else
182 // if the timestamp is not undefined.
184 timestamp === undefined
186 : PictureInPictureChild.videoWrapper.getCurrentTime(video) /
187 PictureInPictureChild.videoWrapper.getDuration(video);
190 // All other requests to toggle PiP should open a new PiP
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,
199 ccEnabled: lazy.DISPLAY_TEXT_TRACKS_PREF,
200 webVTTSubtitles: !!video.textTracks?.length,
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.
215 let doc = this.document;
217 let video = doc.activeElement;
218 if (!HTMLVideoElement.isInstance(video)) {
219 let listOfVideos = [...doc.querySelectorAll("video")].filter(
220 video => !isNaN(video.duration)
222 // Get the first non-paused video, otherwise the longest video. This
223 // fallback is designed to skip over "preview"-style videos on sidebars.
225 listOfVideos.filter(v => !v.paused)[0] ||
226 listOfVideos.sort((a, b) => b.duration - a.duration)[0];
229 this.togglePictureInPicture(video);
236 * The PictureInPictureToggleChild is responsible for displaying the overlaid
237 * Picture-in-Picture toggle over top of <video> elements that the mouse is
240 export class PictureInPictureToggleChild extends JSWindowActorChild {
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
248 this.weakDocStates = new WeakMap();
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);
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);
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();
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;
285 observe(subject, topic, data) {
286 if (topic != "nsPref:changed") {
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);
307 * Returns the state for the current document referred to via
308 * this.document. If no such state exists, creates it, stores it
312 if (this.isDestroyed || !this.document) {
316 let state = this.weakDocStates.get(this.document);
318 let visibilityThresholdPref = Services.prefs.getFloatPref(
319 TOGGLE_VISIBILITY_THRESHOLD_PREF,
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.
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
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
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,
361 this.weakDocStates.set(this.document, state);
368 * Returns the video that the user was last hovering with the mouse if it
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.
375 let { weakOverVideo } = this.docState;
377 // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
378 // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
380 return weakOverVideo.get();
389 if (!event.isTrusted) {
390 // We don't care about synthesized events that might be coming from
395 // Don't capture events from Picture-in-Picture content windows
396 if (gPlayerContents.has(this.contentWindow)) {
400 switch (event.type) {
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();
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.
417 lazy.gSiteOverrides = PictureInPictureToggleChild.getSiteOverrides();
419 // Ignore resulting TypeError if gSiteOverrides is still unloaded
420 if (!(e instanceof TypeError)) {
427 case "UAWidgetSetupOrChange": {
429 this.toggleEnabled &&
430 this.contentWindow.HTMLVideoElement.isInstance(event.target) &&
431 event.target.ownerDocument == this.document
433 this.registerVideo(event.target);
437 case "contextmenu": {
438 if (this.toggleEnabled) {
439 this.checkContextMenu(event);
444 this.onMouseOut(event);
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);
460 this.onMouseButtonEvent(event);
463 case "pointerdown": {
464 this.onPointerDown(event);
468 this.onMouseMove(event);
472 this.onPageShow(event);
476 this.onPageHide(event);
483 * Adds a <video> to the IntersectionObserver so that we know when it becomes
486 * @param {Element} video The <video> element to register.
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(
495 threshold: [0.0, 0.5],
500 state.intersectionObserver.observe(video);
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.
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.
514 worthTracking(intersectionEntry) {
515 return intersectionEntry.isIntersecting;
519 * Called by the IntersectionObserver once a video crosses one of the
520 * thresholds dictated by the IntersectionObserver configuration.
522 * @param {Array<IntersectionEntry>} A collection of one or more
523 * IntersectionEntry's for <video> elements that might have entered or exited
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;
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);
546 } else if (state.weakVisibleVideos.has(video)) {
547 state.weakVisibleVideos.delete(video);
548 state.visibleVideosCount--;
549 if (this.toggleTesting) {
550 gWeakIntersectingVideosForTesting.delete(video);
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
560 if (!oldVisibleVideosCount && state.visibleVideosCount) {
561 if (this.toggleTesting || !this.contentWindow) {
562 this.beginTrackingMouseOverVideos();
564 this.contentWindow.requestIdleCallback(() => {
565 this.beginTrackingMouseOverVideos();
568 } else if (oldVisibleVideosCount && !state.visibleVideosCount) {
569 if (this.toggleTesting || !this.contentWindow) {
570 this.stopTrackingMouseOverVideos();
572 this.contentWindow.requestIdleCallback(() => {
573 this.stopTrackingMouseOverVideos();
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, {
591 this.contentWindow.windowRoot.addEventListener("mousedown", this, {
594 this.contentWindow.windowRoot.addEventListener("mouseup", this, {
597 this.contentWindow.windowRoot.addEventListener("pointerup", this, {
600 this.contentWindow.windowRoot.addEventListener("click", this, {
603 this.contentWindow.windowRoot.addEventListener("mouseout", this, {
606 this.contentWindow.windowRoot.addEventListener("touchstart", this, {
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) {
618 this.contentWindow.windowRoot.removeEventListener("pointerdown", this, {
621 this.contentWindow.windowRoot.removeEventListener("mousedown", this, {
624 this.contentWindow.windowRoot.removeEventListener("mouseup", this, {
627 this.contentWindow.windowRoot.removeEventListener("pointerup", this, {
630 this.contentWindow.windowRoot.removeEventListener("click", this, {
633 this.contentWindow.windowRoot.removeEventListener("mouseout", this, {
636 this.contentWindow.windowRoot.removeEventListener("touchstart", this, {
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
652 * This sort of thing will not be necessary once bug 1539652 is fixed.
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);
661 this.document.addEventListener("mousemove", this, {
662 mozSystemGroup: true,
665 this.contentWindow.addEventListener("pageshow", this, {
666 mozSystemGroup: true,
668 this.contentWindow.addEventListener("pagehide", this, {
669 mozSystemGroup: true,
671 this.addMouseButtonListeners();
672 state.isTrackingVideos = true;
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.
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) {
689 state.mousemoveDeferredTask.disarm();
690 this.document.removeEventListener("mousemove", this, {
691 mozSystemGroup: true,
694 if (this.contentWindow) {
695 this.contentWindow.removeEventListener("pageshow", this, {
696 mozSystemGroup: true,
698 this.contentWindow.removeEventListener("pagehide", this, {
699 mozSystemGroup: true,
702 this.removeMouseButtonListeners();
703 let oldOverVideo = this.getWeakOverVideo();
705 this.onMouseLeaveVideo(oldOverVideo);
707 state.isTrackingVideos = false;
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.
716 * @param {Event} event The pageshow event fired when completing a tab tear
720 let state = this.docState;
721 if (state.isTrackingVideos) {
722 this.addMouseButtonListeners();
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.
732 * @param {Event} event The pagehide event fired when starting a tab tear
736 let state = this.docState;
737 if (state.isTrackingVideos) {
738 this.removeMouseButtonListeners();
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
751 * @param {Event} event The mousemove event.
753 onPointerDown(event) {
754 // The toggle ignores non-primary mouse clicks.
755 if (event.button != 0) {
759 let video = this.getWeakOverVideo();
764 let shadowRoot = video.openOrClosedShadowRoot;
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.
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(
790 /* aOnlyVisible = */ true,
791 state.toggleVisibilityThreshold
794 for (let element of elements) {
795 if (element == video || element.containingShadowRoot == shadowRoot) {
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);
817 startPictureInPicture(event, video, toggle) {
818 Services.telemetry.keyedScalarAdd(
819 "pictureinpicture.opened_method",
825 firstTimeToggle: (!Services.prefs.getBoolPref(
826 "media.videocontrols.picture-in-picture.video-toggle.has-used"
829 Services.telemetry.recordEvent(
837 let pipEvent = new this.contentWindow.CustomEvent(
838 "MozTogglePictureInPicture",
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);
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).
858 * @param {Event} event A mousedown, pointerup, mouseup or click event.
860 onMouseButtonEvent(event) {
861 // The toggle ignores non-primary mouse clicks.
862 if (event.button != 0) {
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.
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);
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"
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;
897 * Called on mouseout events to determine whether or not the mouse has
900 * @param {Event} event The mouseout 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();
912 this.onMouseLeaveVideo(video);
917 * Called for each mousemove event when we're tracking those events to
918 * determine if the cursor is hovering over a <video>.
920 * @param {Event} event The mousemove event.
923 let state = this.docState;
925 if (state.hideToggleDeferredTask) {
926 state.hideToggleDeferredTask.disarm();
927 state.hideToggleDeferredTask.arm();
930 state.lastMouseMoveEvent = event;
931 state.mousemoveDeferredTask.arm();
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.
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(
959 /* aOnlyVisible = */ true
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:",
968 lazy.logConsole.debug(
969 "Supported <video> element:",
970 state.weakVisibleVideos.has(element)
972 lazy.logConsole.debug(
973 "PiP window is open:",
974 element.isCloningElementVisually
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);
988 let oldOverVideo = this.getWeakOverVideo();
990 this.onMouseLeaveVideo(oldOverVideo);
995 * Called once it has been determined that the mouse is overtop of a video
996 * that is in the viewport.
998 * @param {Element} video The video the mouse is over.
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);
1008 shadowRoot.firstChild.removeAttribute("flipped");
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.
1017 // We also clear the hover state on the old video we were hovering,
1018 // if there was one.
1019 this.onMouseLeaveVideo(oldOverVideo);
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
1033 let siteOverrides = this.toggleTesting
1034 ? PictureInPictureToggleChild.getSiteOverrides()
1035 : lazy.gSiteOverrides;
1037 let visibilityThresholdPref = Services.prefs.getFloatPref(
1038 TOGGLE_VISIBILITY_THRESHOLD_PREF,
1042 if (!this.videoWrapper) {
1043 this.videoWrapper = applyWrapper(this, video);
1046 // Do we have any toggle overrides? If so, try to apply them.
1047 for (let [override, { policy, visibilityThreshold }] of siteOverrides) {
1049 (policy || visibilityThreshold) &&
1050 override.matches(this.document.documentURI)
1052 state.togglePolicy = this.videoWrapper?.shouldHideToggle(video)
1053 ? lazy.TOGGLE_POLICIES.HIDDEN
1054 : policy || lazy.TOGGLE_POLICIES.DEFAULT;
1055 state.toggleVisibilityThreshold =
1056 visibilityThreshold || visibilityThresholdPref;
1061 state.checkedPolicyDocumentURI = this.document.documentURI;
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.
1068 state.togglePolicy != lazy.TOGGLE_POLICIES.DEFAULT &&
1069 !(state.togglePolicy == lazy.TOGGLE_POLICIES.BOTTOM && video.controls)
1071 toggle.setAttribute(
1073 lazy.TOGGLE_POLICY_STRINGS[state.togglePolicy]
1076 toggle.removeAttribute("policy");
1079 const nimbusExperimentVariables = lazy.NimbusFeatures.pictureinpicture.getAllVariables(
1080 { defaultValues: { title: null, message: false, showIconOnly: false } }
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";
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
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);
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);
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);
1128 state.weakOverVideo = Cu.getWeakReference(video);
1129 controlsOverlay.classList.add("hovering");
1132 state.togglePolicy != lazy.TOGGLE_POLICIES.HIDDEN &&
1133 !toggle.hasAttribute("hidden")
1135 Services.telemetry.scalarAdd("pictureinpicture.saw_toggle", 1);
1136 const hasUsedPiP = Services.prefs.getBoolPref(
1137 "media.videocontrols.picture-in-picture.video-toggle.has-used"
1140 firstTime: (!hasUsedPiP).toString(),
1142 Services.telemetry.recordEvent(
1149 // only record if this is the first time seeing the toggle
1151 lazy.NimbusFeatures.pictureinpicture.recordExposureEvent();
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);
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
1165 * @param {Element} toggle The Picture-in-Picture toggle to check.
1166 * @param {MouseEvent} event A MouseEvent to test.
1168 checkHoverToggle(toggle, event) {
1169 toggle.classList.toggle("hovering", this.isMouseOverToggle(toggle, event));
1173 * Called once it has been determined that the mouse is no longer overlapping
1174 * a video that we'd previously called onMouseOverVideo with.
1176 * @param {Element} video The video that the mouse left.
1178 onMouseLeaveVideo(video) {
1179 let state = this.docState;
1180 let shadowRoot = video.openOrClosedShadowRoot;
1183 let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
1184 let toggle = this.getToggleElement(shadowRoot);
1185 controlsOverlay.classList.remove("hovering");
1186 toggle.classList.remove("hovering");
1189 state.weakOverVideo = null;
1191 if (!this.toggleTesting) {
1192 state.hideToggleDeferredTask.disarm();
1193 state.mousemoveDeferredTask.disarm();
1196 state.hideToggleDeferredTask = null;
1200 * Given a reference to a Picture-in-Picture toggle element, determines
1201 * if a MouseEvent event is occurring within its bounds.
1203 * @param {Element} toggle The Picture-in-Picture toggle.
1204 * @param {MouseEvent} event A MouseEvent to test.
1208 isMouseOverToggle(toggle, event) {
1209 let toggleRect = toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing(
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
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)
1226 toggleRect.expandToContain(childRect);
1229 // If the toggle has no dimensions, we're definitely not over it.
1230 if (!toggleRect.width || !toggleRect.height) {
1234 let { clientX, clientY } = event;
1237 clientX >= toggleRect.left &&
1238 clientX <= toggleRect.right &&
1239 clientY >= toggleRect.top &&
1240 clientY <= toggleRect.bottom
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.
1249 * @param {MouseEvent} event A contextmenu event.
1251 checkContextMenu(event) {
1252 let video = this.getWeakOverVideo();
1257 let shadowRoot = video.openOrClosedShadowRoot;
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,
1270 event.stopImmediatePropagation();
1271 event.preventDefault();
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.
1279 * @param {Element} shadowRoot The shadowRoot of the video element.
1280 * @returns {Element} The toggle element.
1282 getToggleElement(shadowRoot) {
1283 return shadowRoot.getElementById("pictureInPictureToggle");
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.
1290 static isTracking(video) {
1291 return gWeakIntersectingVideosForTesting.has(video);
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).
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.
1305 static getSiteOverrides() {
1307 let patterns = Services.cpmm.sharedData.get(
1308 "PictureInPicture:SiteOverrides"
1310 for (let pattern in patterns) {
1311 let matcher = new MatchPattern(pattern);
1312 result.push([matcher, patterns[pattern]]);
1318 export class PictureInPictureChild extends JSWindowActorChild {
1319 #subtitlesEnabled = false;
1320 // A weak reference to this PiP window's video element
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") {
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"
1343 // Enable or disable text track support
1344 if (isTextTrackPrefEnabled) {
1345 this.setupTextTracks(originatingVideo);
1347 this.removeTextTracks(originatingVideo);
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.
1359 createTextTracksStyleSheet() {
1360 let headStyleElement = this.document.createElement("link");
1361 headStyleElement.setAttribute("rel", "stylesheet");
1362 headStyleElement.setAttribute(
1364 "chrome://global/skin/pictureinpicture/texttracks.css"
1366 headStyleElement.setAttribute("type", "text/css");
1367 return headStyleElement;
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.
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.
1379 * @param originatingVideo {Element|null}
1380 * The <video> being displayed in Picture-in-Picture mode, or null if that <video> no longer exists.
1382 setupTextTracks(originatingVideo) {
1383 const isWebVTTSupported = !!originatingVideo.textTracks?.length;
1385 if (!isWebVTTSupported) {
1386 this.setUpCaptionChangeListener(originatingVideo);
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);
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);
1408 * Toggle the visibility of the subtitles in the PiP window
1410 toggleTextTracks() {
1411 let textTracks = this.document.getElementById("texttracks");
1412 textTracks.style.display =
1413 textTracks.style.display === "none" ? "" : "none";
1417 * Removes existing text tracks on the Picture in Picture window.
1419 * If the originating video supports WebVTT, clear references to active
1420 * tracks and cues. No longer listen for any track or cue changes.
1422 * @param originatingVideo {Element|null}
1423 * The <video> being displayed in Picture-in-Picture mode, or null if that <video> no longer exists.
1425 removeTextTracks(originatingVideo) {
1426 const isWebVTTSupported = !!originatingVideo.textTracks;
1428 if (!isWebVTTSupported) {
1432 // No longer listen for changes to tracks and active cues
1433 originatingVideo.textTracks.removeEventListener("change", this);
1434 this._currentWebVTTTrack?.removeEventListener(
1438 this._currentWebVTTTrack = null;
1439 this.updateWebVTTTextTracksDisplay(null);
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.
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
1453 moveTextTracks(data) {
1456 isVideoControlsShowing,
1457 playerBottomControlsDOMRect,
1460 let textTracks = this.document.getElementById("texttracks");
1461 const originatingWindow = this.getWeakVideo().ownerGlobal;
1462 const isReducedMotionEnabled = originatingWindow.matchMedia(
1463 "(prefers-reduced-motion: reduce)"
1465 const textTracksFontScale = this.document
1466 .querySelector(":root")
1467 .style.getPropertyValue("--font-scale");
1469 if (isFullscreen || isReducedMotionEnabled) {
1470 textTracks.removeAttribute("overlap-video-controls");
1474 if (isVideoControlsShowing) {
1475 let playerVideoRect = textTracks.parentElement.getBoundingClientRect();
1477 playerVideoRect.bottom - textTracksFontScale * playerVideoRect.height >
1478 playerBottomControlsDOMRect.top;
1481 const root = this.document.querySelector(":root");
1482 if (isScrubberShowing) {
1483 root.style.setProperty("--player-controls-scrubber-height", "30px");
1485 root.style.setProperty("--player-controls-scrubber-height", "0px");
1487 textTracks.setAttribute("overlap-video-controls", true);
1489 textTracks.removeAttribute("overlap-video-controls");
1492 textTracks.removeAttribute("overlap-video-controls");
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.
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) {
1514 if (!this.isSubtitlesEnabled) {
1515 this.isSubtitlesEnabled = true;
1516 this.sendAsyncMessage("PictureInPicture:EnableSubtitlesButton");
1519 let allCuesArray = [...textTrackCues];
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;
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);
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.
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.
1545 * If line is an integer or percentage, we want to order cues according to numeric value.
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
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
1558 getOrderedWebVTTCues(allCuesArray) {
1559 if (!allCuesArray || allCuesArray.length <= 1) {
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();
1573 * Returns a reference to the PiP's <video> element being displayed in Picture-in-Picture
1576 * @return {Element} The <video> being displayed in Picture-in-Picture mode, or null
1577 * if that <video> no longer exists.
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
1584 return this.weakVideo.get();
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>.
1596 * @return {Window} The inner window of the about:blank player <xul:browser>, or
1597 * null if that window has been closed.
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
1604 return this.weakPlayerContent.get();
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.
1616 * @param {Element} video The <video> element to check.
1620 inPictureInPicture(video) {
1621 return this.getWeakVideo() === video;
1624 static videoIsPlaying(video) {
1625 return !!(!video.paused && !video.ended && video.readyState > 2);
1628 static videoIsMuted(video) {
1629 return this.videoWrapper.isMuted(video);
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 });
1642 // The originating video's content document has unloaded,
1643 // so close Picture-in-Picture.
1644 this.closePictureInPicture({ reason: "pagehide" });
1647 case "MozDOMFullscreen:Request": {
1648 this.closePictureInPicture({ reason: "fullscreen" });
1652 this.sendAsyncMessage("PictureInPicture:Playing");
1656 this.sendAsyncMessage("PictureInPicture:Paused");
1659 case "volumechange": {
1660 let video = this.getWeakVideo();
1662 // Just double-checking that we received the event for the right
1664 if (video !== event.target) {
1665 lazy.logConsole.error(
1666 "PictureInPictureChild received volumechange for " +
1672 if (this.constructor.videoIsMuted(video)) {
1673 this.sendAsyncMessage("PictureInPicture:Muting");
1675 this.sendAsyncMessage("PictureInPicture:Unmuting");
1680 let video = event.target;
1681 if (this.inPictureInPicture(video)) {
1682 this.sendAsyncMessage("PictureInPicture:Resize", {
1683 videoHeight: video.videoHeight,
1684 videoWidth: video.videoWidth,
1687 this.setupTextTracks(video);
1691 this.isSubtitlesEnabled = false;
1692 if (this.emptiedTimeout) {
1693 clearTimeout(this.emptiedTimeout);
1694 this.emptiedTimeout = null;
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" });
1704 }, EMPTIED_TIMEOUT_MS);
1708 // Clear currently stored track data (webvtt support) before reading
1710 if (this._currentWebVTTTrack) {
1711 this._currentWebVTTTrack.removeEventListener(
1715 this._currentWebVTTTrack = null;
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);
1729 this._currentWebVTTTrack.addEventListener(
1733 const cues = this._currentWebVTTTrack.activeCues;
1734 this.updateWebVTTTextTracksDisplay(cues);
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(
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",
1764 * Tells the parent to close a pre-existing Picture-in-Picture
1769 * @resolves {undefined} Once the pre-existing Picture-in-Picture
1770 * window has unloaded.
1772 async closePictureInPicture({ reason }) {
1773 let video = this.getWeakVideo();
1775 this.untrackOriginatingVideo(video);
1777 this.sendAsyncMessage("PictureInPicture:Close", {
1781 let playerContent = this.getWeakPlayerContent();
1782 if (playerContent) {
1783 if (!playerContent.closed) {
1784 await new Promise(resolve => {
1785 playerContent.addEventListener("unload", resolve, {
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;
1798 receiveMessage(message) {
1799 switch (message.name) {
1800 case "PictureInPicture:SetupPlayer": {
1801 const { videoRef } = message.data;
1802 this.setupPlayer(videoRef);
1805 case "PictureInPicture:Play": {
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)) {
1824 case "PictureInPicture:Mute": {
1828 case "PictureInPicture:Unmute": {
1832 case "PictureInPicture:SeekForward":
1833 case "PictureInPicture:SeekBackward": {
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;
1841 const maxtime = this.videoWrapper.getDuration(video);
1842 selectedTime = currentTime + SEEK_TIME_SECS;
1843 selectedTime = selectedTime <= maxtime ? selectedTime : maxtime;
1845 this.videoWrapper.setCurrentTime(video, selectedTime);
1848 case "PictureInPicture:KeyDown": {
1849 this.keyDown(message.data);
1852 case "PictureInPicture:EnterFullscreen":
1853 case "PictureInPicture:ExitFullscreen": {
1854 let textTracks = this.document.getElementById("texttracks");
1856 this.moveTextTracks(message.data);
1860 case "PictureInPicture:ShowVideoControls":
1861 case "PictureInPicture:HideVideoControls": {
1862 let textTracks = this.document.getElementById("texttracks");
1864 this.moveTextTracks(message.data);
1868 case "PictureInPicture:ToggleTextTracks": {
1869 this.toggleTextTracks();
1872 case "PictureInPicture:ChangeFontSizeTextTracks": {
1873 this.setTextTrackFontSize();
1876 case "PictureInPicture:SetVideoTime": {
1877 const { scrubberPosition, wasPlaying } = message.data;
1878 this.setVideoTime(scrubberPosition, wasPlaying);
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
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);
1896 * Updates this._currentWebVTTTrack if an active track is found
1897 * for the originating video.
1898 * @param {TextTrackList} textTrackList list of text tracks
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;
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
1917 setTextTrackFontSize() {
1918 const fontSize = Services.prefs.getStringPref(
1919 TEXT_TRACK_FONT_SIZE,
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");
1928 root.style.setProperty("--font-scale", "0.06");
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.
1937 trackOriginatingVideo(originatingVideo) {
1938 this.observerFunction = (subject, topic, data) => {
1939 this.observe(subject, topic, data);
1941 Services.prefs.addObserver(
1942 "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
1943 this.observerFunction
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);
1960 let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
1961 chromeEventHandler.addEventListener(
1962 "MozDOMFullscreen:Request",
1966 chromeEventHandler.addEventListener(
1967 "MozStopPictureInPicture",
1974 setUpCaptionChangeListener(originatingVideo) {
1975 if (this.videoWrapper) {
1976 this.videoWrapper.setCaptionContainerObserver(originatingVideo, this);
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.
1986 untrackOriginatingVideo(originatingVideo) {
1987 Services.prefs.removeObserver(
1988 "media.videocontrols.picture-in-picture.display-text-tracks.enabled",
1989 this.observerFunction
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);
2006 let chromeEventHandler = originatingWindow.docShell.chromeEventHandler;
2007 chromeEventHandler.removeEventListener(
2008 "MozDOMFullscreen:Request",
2012 chromeEventHandler.removeEventListener(
2013 "MozStopPictureInPicture",
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.
2027 * @param videoRef {ContentDOMReference}
2028 * A reference to the video element that a Picture-in-Picture window
2029 * is being created for
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.
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
2044 await this.closePictureInPicture({ reason: "setup-failure" });
2048 this.videoWrapper = applyWrapper(this, originatingVideo);
2050 let loadPromise = new Promise(resolve => {
2051 this.contentWindow.addEventListener("load", resolve, {
2053 mozSystemGroup: true,
2057 this.contentWindow.location.reload();
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)";
2102 this.onCueChange = this.onCueChange.bind(this);
2103 this.trackOriginatingVideo(originatingVideo);
2105 this.contentWindow.addEventListener(
2108 let video = this.getWeakVideo();
2110 this.untrackOriginatingVideo(video);
2111 video.stopCloningElementVisually();
2113 this.weakVideo = null;
2120 let video = this.getWeakVideo();
2121 if (video && this.videoWrapper) {
2122 this.videoWrapper.play(video);
2127 let video = this.getWeakVideo();
2128 if (video && this.videoWrapper) {
2129 this.videoWrapper.pause(video);
2134 let video = this.getWeakVideo();
2135 if (video && this.videoWrapper) {
2136 this.videoWrapper.setMuted(video, true);
2141 let video = this.getWeakVideo();
2142 if (video && this.videoWrapper) {
2143 this.videoWrapper.setMuted(video, false);
2148 if (!lazy.DISPLAY_TEXT_TRACKS_PREF) {
2149 this.updateWebVTTTextTracksDisplay(null);
2151 const cues = this._currentWebVTTTrack.activeCues;
2152 this.updateWebVTTTextTracksDisplay(cues);
2157 * This checks if a given keybinding has been disabled for the specific site
2158 * currently being viewed.
2160 isKeyDisabled(key) {
2161 const video = this.getWeakVideo();
2165 const { documentURI } = video.ownerDocument;
2169 for (let [override, { disabledKeyboardControls }] of lazy.gSiteOverrides) {
2171 disabledKeyboardControls !== undefined &&
2172 override.matches(documentURI)
2174 if (disabledKeyboardControls === lazy.KEYBOARD_CONTROLS.ALL) {
2177 return !!(disabledKeyboardControls & key);
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.
2188 /* eslint-disable complexity */
2189 keyDown({ altKey, shiftKey, metaKey, ctrlKey, keyCode }) {
2190 let video = this.getWeakVideo();
2197 keystroke += "alt-";
2200 keystroke += "shift-";
2202 if (this.contentWindow.navigator.platform.startsWith("Mac")) {
2204 keystroke += "accel-";
2207 keystroke += "control-";
2211 keystroke += "meta-";
2214 keystroke += "accel-";
2219 case this.contentWindow.KeyEvent.DOM_VK_UP:
2220 keystroke += "upArrow";
2222 case this.contentWindow.KeyEvent.DOM_VK_DOWN:
2223 keystroke += "downArrow";
2225 case this.contentWindow.KeyEvent.DOM_VK_LEFT:
2226 keystroke += "leftArrow";
2228 case this.contentWindow.KeyEvent.DOM_VK_RIGHT:
2229 keystroke += "rightArrow";
2231 case this.contentWindow.KeyEvent.DOM_VK_HOME:
2232 keystroke += "home";
2234 case this.contentWindow.KeyEvent.DOM_VK_END:
2237 case this.contentWindow.KeyEvent.DOM_VK_SPACE:
2238 keystroke += "space";
2240 case this.contentWindow.KeyEvent.DOM_VK_W:
2245 const isVideoStreaming = this.videoWrapper.isLive(video);
2249 switch (keystroke) {
2250 case "space" /* Toggle Play / Pause */:
2251 if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.PLAY_PAUSE)) {
2256 this.videoWrapper.getPaused(video) ||
2257 this.videoWrapper.getEnded(video)
2259 this.videoWrapper.play(video);
2261 this.videoWrapper.pause(video);
2265 case "accel-w" /* Close video */:
2266 if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.CLOSE)) {
2270 this.closePictureInPicture({ reason: "close-player-shortcut" });
2272 case "downArrow" /* Volume decrease */:
2273 if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME)) {
2276 oldval = this.videoWrapper.getVolume(video);
2277 this.videoWrapper.setVolume(video, oldval < 0.1 ? 0 : oldval - 0.1);
2278 this.videoWrapper.setMuted(video, false);
2280 case "upArrow" /* Volume increase */:
2281 if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.VOLUME)) {
2284 oldval = this.videoWrapper.getVolume(video);
2285 this.videoWrapper.setVolume(video, oldval > 0.9 ? 1 : oldval + 0.1);
2286 this.videoWrapper.setMuted(video, false);
2288 case "accel-downArrow" /* Mute */:
2289 if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
2292 this.videoWrapper.setMuted(video, true);
2294 case "accel-upArrow" /* Unmute */:
2295 if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.MUTE_UNMUTE)) {
2298 this.videoWrapper.setMuted(video, false);
2300 case "leftArrow": /* Seek back 5 seconds */
2301 case "accel-leftArrow" /* Seek back 10% */:
2303 this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) ||
2304 (isVideoStreaming &&
2305 this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK))
2310 oldval = this.videoWrapper.getCurrentTime(video);
2311 if (keystroke == "leftArrow") {
2312 newval = oldval - SEEK_TIME_SECS;
2314 newval = oldval - this.videoWrapper.getDuration(video) / 10;
2316 this.videoWrapper.setCurrentTime(video, newval >= 0 ? newval : 0);
2318 case "rightArrow": /* Seek forward 5 seconds */
2319 case "accel-rightArrow" /* Seek forward 10% */:
2321 this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK) ||
2322 (isVideoStreaming &&
2323 this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.LIVE_SEEK))
2328 oldval = this.videoWrapper.getCurrentTime(video);
2329 var maxtime = this.videoWrapper.getDuration(video);
2330 if (keystroke == "rightArrow") {
2331 newval = oldval + SEEK_TIME_SECS;
2333 newval = oldval + maxtime / 10;
2335 let selectedTime = newval <= maxtime ? newval : maxtime;
2336 this.videoWrapper.setCurrentTime(video, selectedTime);
2338 case "home" /* Seek to beginning */:
2339 if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) {
2342 if (!isVideoStreaming) {
2343 this.videoWrapper.setCurrentTime(video, 0);
2346 case "end" /* Seek to end */:
2347 if (this.isKeyDisabled(lazy.KEYBOARD_CONTROLS.SEEK)) {
2351 let duration = this.videoWrapper.getDuration(video);
2353 !isVideoStreaming &&
2354 this.videoWrapper.getCurrentTime(video) != duration
2356 this.videoWrapper.setCurrentTime(video, duration);
2362 /* ignore any exception from setting video.currentTime */
2366 get isSubtitlesEnabled() {
2367 return this.#subtitlesEnabled;
2370 set isSubtitlesEnabled(val) {
2372 Services.telemetry.recordEvent(
2378 webVTTSubtitles: (!!this.getWeakVideo().textTracks
2379 ?.length).toString(),
2383 this.sendAsyncMessage("PictureInPicture:DisableSubtitlesButton");
2385 this.#subtitlesEnabled = val;
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)
2406 class PictureInPictureChildVideoWrapper {
2409 #PictureInPictureChild;
2412 * Create a wrapper for the original <video>
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.
2423 constructor(videoWrapperScriptPath, video, pipChild) {
2424 this.#sandbox = videoWrapperScriptPath
2425 ? this.#createSandbox(videoWrapperScriptPath, video)
2427 this.#PictureInPictureChild = pipChild;
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.
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
2447 * @returns The expected output of the wrapper function.
2449 #callWrapperMethod({ name, args = [], fallback = () => {}, validateRetVal }) {
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.`
2462 if (!validateRetVal(retVal)) {
2463 lazy.logConsole.error(
2464 `Calling method ${name}() returned an unexpected value: ${retVal}. Returning null.`
2472 lazy.logConsole.error(
2473 `There was an error while calling ${name}(): `,
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.
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.
2492 #createSandbox(videoWrapperScriptPath, video) {
2493 const addonPolicy = WebExtensionPolicy.getByID(
2494 "pictureinpicture@mozilla.org"
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,
2508 Services.scriptloader.loadSubScript(wrapperScriptUrl, sandbox);
2510 Cu.nukeSandbox(sandbox);
2511 lazy.logConsole.error(
2512 "Error loading wrapper script for Picture-in-Picture",
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
2524 this.#siteWrapper = new sandbox.PictureInPictureVideoWrapper(
2532 return typeof val === "boolean";
2536 return typeof val === "number";
2540 * Destroys the sandbox for the site wrapper class
2543 if (this.#sandbox) {
2544 Cu.nukeSandbox(this.#sandbox);
2549 * Function to display the captions on the PiP window
2550 * @param text The captions to be shown on the PiP window
2552 updatePiPTextTracks(text) {
2553 if (!this.#PictureInPictureChild.isSubtitlesEnabled && text) {
2554 this.#PictureInPictureChild.isSubtitlesEnabled = true;
2555 this.#PictureInPictureChild.sendAsyncMessage(
2556 "PictureInPicture:EnableSubtitlesButton"
2559 let pipWindowTracksContainer = this.#PictureInPictureChild.document.getElementById(
2562 pipWindowTracksContainer.textContent = text;
2565 /* Video methods to be used for video controls from the PiP window. */
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
2575 return this.#callWrapperMethod({
2578 fallback: () => video.play(),
2579 validateRetVal: retVal => retVal == null,
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
2591 return this.#callWrapperMethod({
2594 fallback: () => video.pause(),
2595 validateRetVal: retVal => retVal == null,
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
2608 return this.#callWrapperMethod({
2611 fallback: () => video.paused,
2612 validateRetVal: retVal => this.#isBoolean(retVal),
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
2625 return this.#callWrapperMethod({
2628 fallback: () => video.ended,
2629 validateRetVal: retVal => this.#isBoolean(retVal),
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
2641 getDuration(video) {
2642 return this.#callWrapperMethod({
2643 name: "getDuration",
2645 fallback: () => video.duration,
2646 validateRetVal: retVal => this.#isNumber(retVal),
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
2658 getCurrentTime(video) {
2659 return this.#callWrapperMethod({
2660 name: "getCurrentTime",
2662 fallback: () => video.currentTime,
2663 validateRetVal: retVal => this.#isNumber(retVal),
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
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
2678 setCurrentTime(video, position, wasPlaying) {
2679 return this.#callWrapperMethod({
2680 name: "setCurrentTime",
2681 args: [video, position, wasPlaying],
2683 video.currentTime = position;
2685 validateRetVal: retVal => retVal == null,
2690 * Return hours, minutes, and seconds from seconds
2691 * @param {Number} aSeconds
2692 * The time in seconds
2693 * @returns {String} Timestamp string
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}`;
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
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)) {
2722 return `${this.timeFromSeconds(aCurrentTime)} / ${this.timeFromSeconds(
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
2731 * @param {HTMLVideoElement} video
2732 * The originating video source element
2733 * @returns {Number} Volume of the video between 0 (muted) and 1 (loudest)
2736 return this.#callWrapperMethod({
2739 fallback: () => video.volume,
2740 validateRetVal: retVal => this.#isNumber(retVal),
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
2748 * @param {HTMLVideoElement} video
2749 * The originating video source element
2750 * @param {Number} volume
2751 * Value between 0 (muted) and 1 (loudest)
2753 setVolume(video, volume) {
2754 return this.#callWrapperMethod({
2756 args: [video, volume],
2758 video.volume = volume;
2760 validateRetVal: retVal => retVal == null,
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
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
2774 return this.#callWrapperMethod({
2777 fallback: () => video.muted,
2778 validateRetVal: retVal => this.#isBoolean(retVal),
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
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
2791 setMuted(video, shouldMute) {
2792 return this.#callWrapperMethod({
2794 args: [video, shouldMute],
2796 video.muted = shouldMute;
2798 validateRetVal: retVal => retVal == null,
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
2812 setCaptionContainerObserver(video, callback) {
2813 return this.#callWrapperMethod({
2814 name: "setCaptionContainerObserver",
2818 this.updatePiPTextTracks(text);
2822 validateRetVal: retVal => retVal == null,
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
2834 shouldHideToggle(video) {
2835 return this.#callWrapperMethod({
2836 name: "shouldHideToggle",
2838 fallback: () => false,
2839 validateRetVal: retVal => this.#isBoolean(retVal),
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
2851 return this.#callWrapperMethod({
2854 fallback: () => video.duration === Infinity,
2855 validateRetVal: retVal => this.#isBoolean(retVal),