PictureInPictureChild.jsm 37.3 KB
Newer Older
1
2
3
4
5
6
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";

7
var EXPORTED_SYMBOLS = ["PictureInPictureChild", "PictureInPictureToggleChild"];
8

9
10
11
12
13
14
15
16
17
18
ChromeUtils.defineModuleGetter(
  this,
  "DeferredTask",
  "resource://gre/modules/DeferredTask.jsm"
);
ChromeUtils.defineModuleGetter(
  this,
  "Services",
  "resource://gre/modules/Services.jsm"
);
19
20
21

const TOGGLE_ENABLED_PREF =
  "media.videocontrols.picture-in-picture.video-toggle.enabled";
22
23
const TOGGLE_TESTING_PREF =
  "media.videocontrols.picture-in-picture.video-toggle.testing";
24
const MOUSEMOVE_PROCESSING_DELAY_MS = 50;
25
const TOGGLE_HIDING_TIMEOUT_MS = 2000;
26

27
28
// A weak reference to the most recent <video> in this content
// process that is being viewed in Picture-in-Picture.
29
var gWeakVideo = null;
30
31
// A weak reference to the content window of the most recent
// Picture-in-Picture window for this content process.
32
var gWeakPlayerContent = null;
33
34
35
36
// To make it easier to write tests, we have a process-global
// WeakSet of all <video> elements that are being tracked for
// mouseover
var gWeakIntersectingVideosForTesting = new WeakSet();
37
38
39
40
41
42

/**
 * The PictureInPictureToggleChild is responsible for displaying the overlaid
 * Picture-in-Picture toggle over top of <video> elements that the mouse is
 * hovering.
 */
43
class PictureInPictureToggleChild extends JSWindowActorChild {
44
45
46
47
48
49
50
51
52
  constructor(dispatcher) {
    super(dispatcher);
    // We need to maintain some state about various things related to the
    // Picture-in-Picture toggles - however, for now, the same
    // PictureInPictureToggleChild might be re-used for different documents.
    // We keep the state stashed inside of this WeakMap, keyed on the document
    // itself.
    this.weakDocStates = new WeakMap();
    this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF);
53
    this.toggleTesting = Services.prefs.getBoolPref(TOGGLE_TESTING_PREF, false);
54
55
56
57
58
59
60
61

    // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's
    // directly, so we create a new function here instead to act as our
    // nsIObserver, which forwards the notification to the observe method.
    this.observerFunction = (subject, topic, data) => {
      this.observe(subject, topic, data);
    };
    Services.prefs.addObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
62
63
  }

64
  willDestroy() {
65
    this.removeMouseButtonListeners();
66
    Services.prefs.removeObserver(TOGGLE_ENABLED_PREF, this.observerFunction);
67
68
69
70
71
72
73
74
75
  }

  observe(subject, topic, data) {
    if (topic == "nsPref:changed" && data == TOGGLE_ENABLED_PREF) {
      this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF);

      if (this.toggleEnabled) {
        // We have enabled the Picture-in-Picture toggle, so we need to make
        // sure we register all of the videos that might already be on the page.
76
77
        this.contentWindow.requestIdleCallback(() => {
          let videos = this.document.querySelectorAll("video");
78
79
80
81
82
83
          for (let video of videos) {
            this.registerVideo(video);
          }
        });
      }
    }
84
85
  }

86
87
  /**
   * Returns the state for the current document referred to via
88
   * this.document. If no such state exists, creates it, stores it
89
90
91
   * and returns it.
   */
  get docState() {
92
    let state = this.weakDocStates.get(this.document);
93
94
95
96
97
98
99
100
101
102
    if (!state) {
      state = {
        // A reference to the IntersectionObserver that's monitoring for videos
        // to become visible.
        intersectionObserver: null,
        // A WeakSet of videos that are supposedly visible, according to the
        // IntersectionObserver.
        weakVisibleVideos: new WeakSet(),
        // The number of videos that are supposedly visible, according to the
        // IntersectionObserver
103
        visibleVideosCount: 0,
104
105
106
107
108
        // The DeferredTask that we'll arm every time a mousemove event occurs
        // on a page where we have one or more visible videos.
        mousemoveDeferredTask: null,
        // A weak reference to the last video we displayed the toggle over.
        weakOverVideo: null,
109
110
111
112
113
114
115
        // True if the user is in the midst of clicking the toggle.
        isClickingToggle: false,
        // Set to the original target element on pointerdown if the user is clicking
        // the toggle - this way, we can determine if a "click" event will need to be
        // suppressed ("click" events don't fire if a "mouseup" occurs on a different
        // element from the "pointerdown" / "mousedown" event).
        clickedElement: null,
116
117
118
        // This is a DeferredTask to hide the toggle after a period of mouse
        // inactivity.
        hideToggleDeferredTask: null,
119
120
121
122
        // If we reach a point where we're tracking videos for mouse movements,
        // then this will be true. If there are no videos worth tracking, then
        // this is false.
        isTrackingVideos: false,
123
      };
124
      this.weakDocStates.set(this.document, state);
125
126
127
128
129
    }

    return state;
  }

130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
  /**
   * Returns the video that the user was last hovering with the mouse if it
   * still exists.
   *
   * @return {Element} the <video> element that the user was last hovering,
   * or null if there was no such <video>, or the <video> no longer exists.
   */
  getWeakOverVideo() {
    let { weakOverVideo } = this.docState;
    if (weakOverVideo) {
      // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
      // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
      try {
        return weakOverVideo.get();
      } catch (e) {
        return null;
      }
    }
    return null;
  }

151
  handleEvent(event) {
152
153
154
155
156
157
    if (!event.isTrusted) {
      // We don't care about synthesized events that might be coming from
      // content JS.
      return;
    }

158
    switch (event.type) {
159
      case "UAWidgetSetupOrChange": {
160
161
        if (
          this.toggleEnabled &&
162
163
          event.target instanceof this.contentWindow.HTMLVideoElement &&
          event.target.ownerDocument == this.document
164
        ) {
165
166
167
168
          this.registerVideo(event.target);
        }
        break;
      }
169
170
171
172
173
174
      case "contextmenu": {
        if (this.toggleEnabled) {
          this.checkContextMenu(event);
        }
        break;
      }
175
176
177
178
      case "mouseout": {
        this.onMouseOut(event);
        break;
      }
179
180
181
182
183
184
185
186
187
      case "mousedown":
      case "pointerup":
      case "mouseup":
      case "click": {
        this.onMouseButtonEvent(event);
        break;
      }
      case "pointerdown": {
        this.onPointerDown(event);
188
189
190
191
192
193
        break;
      }
      case "mousemove": {
        this.onMouseMove(event);
        break;
      }
194
195
196
197
198
199
200
201
      case "pageshow": {
        this.onPageShow(event);
        break;
      }
      case "pagehide": {
        this.onPageHide(event);
        break;
      }
202
203
204
205
206
207
208
209
210
211
212
213
214
    }
  }

  /**
   * Adds a <video> to the IntersectionObserver so that we know when it becomes
   * visible.
   *
   * @param {Element} video The <video> element to register.
   */
  registerVideo(video) {
    let state = this.docState;
    if (!state.intersectionObserver) {
      let fn = this.onIntersection.bind(this);
215
216
217
218
219
220
      state.intersectionObserver = new this.contentWindow.IntersectionObserver(
        fn,
        {
          threshold: [0.0, 0.5],
        }
      );
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
    }

    state.intersectionObserver.observe(video);
  }

  /**
   * Called by the IntersectionObserver callback once a video becomes visible.
   * This adds some fine-grained checking to ensure that a sufficient amount of
   * the video is visible before we consider showing the toggles on it. For now,
   * that means that the entirety of the video must be in the viewport.
   *
   * @param {IntersectionEntry} intersectionEntry An IntersectionEntry passed to
   * the IntersectionObserver callback.
   * @return bool Whether or not we should start tracking mousemove events for
   * this registered video.
   */
  worthTracking(intersectionEntry) {
238
    return intersectionEntry.isIntersecting;
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
  }

  /**
   * Called by the IntersectionObserver once a video crosses one of the
   * thresholds dictated by the IntersectionObserver configuration.
   *
   * @param {Array<IntersectionEntry>} A collection of one or more
   * IntersectionEntry's for <video> elements that might have entered or exited
   * the viewport.
   */
  onIntersection(entries) {
    // The IntersectionObserver will also fire when a previously intersecting
    // element is removed from the DOM. We know, however, that the node is
    // still alive and referrable from the WeakSet because the
    // IntersectionObserverEntry holds a strong reference to the video.
    let state = this.docState;
255
    let oldVisibleVideosCount = state.visibleVideosCount;
256
257
258
259
260
    for (let entry of entries) {
      let video = entry.target;
      if (this.worthTracking(entry)) {
        if (!state.weakVisibleVideos.has(video)) {
          state.weakVisibleVideos.add(video);
261
          state.visibleVideosCount++;
262
263
264
          if (this.toggleTesting) {
            gWeakIntersectingVideosForTesting.add(video);
          }
265
266
267
        }
      } else if (state.weakVisibleVideos.has(video)) {
        state.weakVisibleVideos.delete(video);
268
        state.visibleVideosCount--;
269
270
271
        if (this.toggleTesting) {
          gWeakIntersectingVideosForTesting.delete(video);
        }
272
273
274
      }
    }

275
276
277
278
279
    // For testing, especially in debug or asan builds, we might not
    // run this idle callback within an acceptable time. While we're
    // testing, we'll bypass the idle callback performance optimization
    // and run our callbacks as soon as possible during the next idle
    // period.
280
    if (!oldVisibleVideosCount && state.visibleVideosCount) {
281
      if (this.toggleTesting) {
282
        this.beginTrackingMouseOverVideos();
283
      } else {
284
        this.contentWindow.requestIdleCallback(() => {
285
286
287
          this.beginTrackingMouseOverVideos();
        });
      }
288
    } else if (oldVisibleVideosCount && !state.visibleVideosCount) {
289
      if (this.toggleTesting) {
290
        this.stopTrackingMouseOverVideos();
291
      } else {
292
        this.contentWindow.requestIdleCallback(() => {
293
294
295
          this.stopTrackingMouseOverVideos();
        });
      }
296
297
298
    }
  }

299
300
301
302
303
304
305
306
307
  addMouseButtonListeners() {
    // We want to try to cancel the mouse events from continuing
    // on into content if the user has clicked on the toggle, so
    // we don't use the mozSystemGroup here, and add the listener
    // to the parent target of the window, which in this case,
    // is the windowRoot. Since this event listener is attached to
    // part of the outer window, we need to also remove it in a
    // pagehide event listener in the event that the page unloads
    // before stopTrackingMouseOverVideos fires.
308
    this.contentWindow.windowRoot.addEventListener("pointerdown", this, {
309
310
      capture: true,
    });
311
    this.contentWindow.windowRoot.addEventListener("mousedown", this, {
312
313
      capture: true,
    });
314
    this.contentWindow.windowRoot.addEventListener("mouseup", this, {
315
316
      capture: true,
    });
317
    this.contentWindow.windowRoot.addEventListener("pointerup", this, {
318
319
      capture: true,
    });
320
321
322
323
    this.contentWindow.windowRoot.addEventListener("click", this, {
      capture: true,
    });
    this.contentWindow.windowRoot.addEventListener("mouseout", this, {
324
325
      capture: true,
    });
326
327
328
  }

  removeMouseButtonListeners() {
329
    this.contentWindow.windowRoot.removeEventListener("pointerdown", this, {
330
331
      capture: true,
    });
332
    this.contentWindow.windowRoot.removeEventListener("mousedown", this, {
333
334
      capture: true,
    });
335
    this.contentWindow.windowRoot.removeEventListener("mouseup", this, {
336
337
      capture: true,
    });
338
    this.contentWindow.windowRoot.removeEventListener("pointerup", this, {
339
340
      capture: true,
    });
341
    this.contentWindow.windowRoot.removeEventListener("click", this, {
342
343
      capture: true,
    });
344
    this.contentWindow.windowRoot.removeEventListener("mouseout", this, {
345
346
      capture: true,
    });
347
348
  }

349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
  /**
   * One of the challenges of displaying this toggle is that many sites put
   * things over top of <video> elements, like custom controls, or images, or
   * all manner of things that might intercept mouseevents that would normally
   * fire directly on the <video>. In order to properly detect when the mouse
   * is over top of one of the <video> elements in this situation, we currently
   * add a mousemove event handler to the entire document, and stash the most
   * recent mousemove that fires. At periodic intervals, that stashed mousemove
   * event is checked to see if it's hovering over one of our registered
   * <video> elements.
   *
   * This sort of thing will not be necessary once bug 1539652 is fixed.
   */
  beginTrackingMouseOverVideos() {
    let state = this.docState;
    if (!state.mousemoveDeferredTask) {
      state.mousemoveDeferredTask = new DeferredTask(() => {
        this.checkLastMouseMove();
      }, MOUSEMOVE_PROCESSING_DELAY_MS);
    }
369
    this.document.addEventListener("mousemove", this, {
370
371
372
      mozSystemGroup: true,
      capture: true,
    });
373
    this.contentWindow.addEventListener("pageshow", this, {
374
375
      mozSystemGroup: true,
    });
376
    this.contentWindow.addEventListener("pagehide", this, {
377
378
      mozSystemGroup: true,
    });
379
    this.addMouseButtonListeners();
380
    state.isTrackingVideos = true;
381
382
383
384
385
386
387
388
389
390
  }

  /**
   * If we no longer have any interesting videos in the viewport, we deregister
   * the mousemove and click listeners, and also remove any toggles that might
   * be on the page still.
   */
  stopTrackingMouseOverVideos() {
    let state = this.docState;
    state.mousemoveDeferredTask.disarm();
391
    this.document.removeEventListener("mousemove", this, {
392
393
394
      mozSystemGroup: true,
      capture: true,
    });
395
    this.contentWindow.removeEventListener("pageshow", this, {
396
397
      mozSystemGroup: true,
    });
398
    this.contentWindow.removeEventListener("pagehide", this, {
399
400
      mozSystemGroup: true,
    });
401
    this.removeMouseButtonListeners();
402
    let oldOverVideo = this.getWeakOverVideo();
403
404
405
    if (oldOverVideo) {
      this.onMouseLeaveVideo(oldOverVideo);
    }
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
    state.isTrackingVideos = false;
  }

  /**
   * This pageshow event handler will get called if and when we complete a tab
   * tear out or in. If we happened to be tracking videos before the tear
   * occurred, we re-add the mouse event listeners so that they're attached to
   * the right WindowRoot.
   *
   * @param {Event} event The pageshow event fired when completing a tab tear
   * out or in.
   */
  onPageShow(event) {
    let state = this.docState;
    if (state.isTrackingVideos) {
      this.addMouseButtonListeners();
    }
  }

  /**
   * This pagehide event handler will get called if and when we start a tab
   * tear out or in. If we happened to be tracking videos before the tear
   * occurred, we remove the mouse event listeners. We'll re-add them when the
   * pageshow event fires.
   *
   * @param {Event} event The pagehide event fired when starting a tab tear
   * out or in.
   */
  onPageHide(event) {
    let state = this.docState;
    if (state.isTrackingVideos) {
      this.removeMouseButtonListeners();
    }
439
440
  }

441
  /**
442
443
   * If we're tracking <video> elements, this pointerdown event handler is run anytime
   * a pointerdown occurs on the document. This function is responsible for checking
444
445
   * if the user clicked on the Picture-in-Picture toggle. It does this by first
   * checking if the video is visible beneath the point that was clicked. Then
446
447
448
   * it tests whether or not the pointerdown occurred within the rectangle of the
   * toggle. If so, the event's propagation is stopped, and Picture-in-Picture is
   * triggered.
449
450
451
   *
   * @param {Event} event The mousemove event.
   */
452
  onPointerDown(event) {
453
454
455
456
457
    // The toggle ignores non-primary mouse clicks.
    if (event.button != 0) {
      return;
    }

458
    let video = this.getWeakOverVideo();
459
460
461
462
463
464
465
466
467
468
    if (!video) {
      return;
    }

    let shadowRoot = video.openOrClosedShadowRoot;
    if (!shadowRoot) {
      return;
    }

    let { clientX, clientY } = event;
469
    let winUtils = this.contentWindow.windowUtils;
470
471
472
473
474
475
476
    // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
    // since document.elementsFromPoint always flushes layout. The 1's in that
    // function call are for the size of the rect that we want, which is 1x1.
    //
    // We pass the aOnlyVisible boolean argument to check that the video isn't
    // occluded by anything visible at the point of mousedown. If it is, we'll
    // ignore the mousedown.
477
478
479
480
481
482
483
484
485
486
487
    let elements = winUtils.nodesFromRect(
      clientX,
      clientY,
      1,
      1,
      1,
      1,
      true,
      false,
      true /* aOnlyVisible */
    );
488
489
490
491
492
493
    if (!Array.from(elements).includes(video)) {
      return;
    }

    let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
    if (this.isMouseOverToggle(toggle, event)) {
494
      let state = this.docState;
495
496
497
      state.isClickingToggle = true;
      state.clickedElement = Cu.getWeakReference(event.originalTarget);
      event.stopImmediatePropagation();
498

499
500
501
502
503
      Services.telemetry.keyedScalarAdd(
        "pictureinpicture.opened_method",
        "toggle",
        1
      );
504

505
506
507
508
509
510
      let pipEvent = new this.contentWindow.CustomEvent(
        "MozTogglePictureInPicture",
        {
          bubbles: true,
        }
      );
511
      video.dispatchEvent(pipEvent);
512

513
514
515
      // Since we've initiated Picture-in-Picture, we can go ahead and
      // hide the toggle now.
      this.onMouseLeaveVideo(video);
516
517
518
    }
  }

519
520
521
522
523
524
525
526
527
528
529
  /**
   * Called for mousedown, pointerup, mouseup and click events. If we
   * detected that the user is clicking on the Picture-in-Picture toggle,
   * these events are cancelled in the capture-phase before they reach
   * content. The state for suppressing these events is cleared on the
   * click event (unless the mouseup occurs on a different element from
   * the mousedown, in which case, the state is cleared on mouseup).
   *
   * @param {Event} event A mousedown, pointerup, mouseup or click event.
   */
  onMouseButtonEvent(event) {
530
531
532
533
534
    // The toggle ignores non-primary mouse clicks.
    if (event.button != 0) {
      return;
    }

535
536
537
538
539
540
541
542
543
544
545
546
547
    let state = this.docState;
    if (state.isClickingToggle) {
      event.stopImmediatePropagation();

      // If this is a mouseup event, check to see if we have a record of what
      // the original target was on pointerdown. If so, and if it doesn't match
      // the mouseup original target, that means we won't get a click event, and
      // we can clear the "clicking the toggle" state right away.
      //
      // Otherwise, we wait for the click event to do that.
      let isMouseUpOnOtherElement =
        event.type == "mouseup" &&
        (!state.clickedElement ||
548
          state.clickedElement.get() != event.originalTarget);
549
550
551
552
553
554
555
556
557
558

      if (isMouseUpOnOtherElement || event.type == "click") {
        // The click is complete, so now we reset the state so that
        // we stop suppressing these events.
        state.isClickingToggle = false;
        state.clickedElement = null;
      }
    }
  }

559
560
561
562
563
564
565
566
567
568
569
  /**
   * Called on mouseout events to determine whether or not the mouse has
   * exited the window.
   *
   * @param {Event} event The mouseout event.
   */
  onMouseOut(event) {
    if (!event.relatedTarget) {
      // For mouseout events, if there's no relatedTarget (which normally
      // maps to the element that the mouse entered into) then this means that
      // we left the window.
570
      let video = this.getWeakOverVideo();
571
572
573
574
575
576
577
578
      if (!video) {
        return;
      }

      this.onMouseLeaveVideo(video);
    }
  }

579
580
581
582
583
584
585
586
  /**
   * Called for each mousemove event when we're tracking those events to
   * determine if the cursor is hovering over a <video>.
   *
   * @param {Event} event The mousemove event.
   */
  onMouseMove(event) {
    let state = this.docState;
587
588
589
590
591
592

    if (state.hideToggleDeferredTask) {
      state.hideToggleDeferredTask.disarm();
      state.hideToggleDeferredTask.arm();
    }

593
594
595
596
597
598
599
600
601
602
603
604
605
606
    state.lastMouseMoveEvent = event;
    state.mousemoveDeferredTask.arm();
  }

  /**
   * Called by the DeferredTask after MOUSEMOVE_PROCESSING_DELAY_MS
   * milliseconds. Checked to see if that mousemove happens to be overtop of
   * any interesting <video> elements that we want to display the toggle
   * on. If so, puts the toggle on that video.
   */
  checkLastMouseMove() {
    let state = this.docState;
    let event = state.lastMouseMoveEvent;
    let { clientX, clientY } = event;
607
    let winUtils = this.contentWindow.windowUtils;
608
609
610
    // We use winUtils.nodesFromRect instead of document.elementsFromPoint,
    // since document.elementsFromPoint always flushes layout. The 1's in that
    // function call are for the size of the rect that we want, which is 1x1.
611
612
613
614
615
616
617
618
619
    let elements = winUtils.nodesFromRect(
      clientX,
      clientY,
      1,
      1,
      1,
      1,
      true,
      false,
620
      true
621
    );
622
623

    for (let element of elements) {
624
625
626
627
      if (
        state.weakVisibleVideos.has(element) &&
        !element.isCloningElementVisually
      ) {
628
        this.onMouseOverVideo(element, event);
629
630
631
632
        return;
      }
    }

633
    let oldOverVideo = this.getWeakOverVideo();
634
635
636
637
638
639
640
641
642
643
644
    if (oldOverVideo) {
      this.onMouseLeaveVideo(oldOverVideo);
    }
  }

  /**
   * Called once it has been determined that the mouse is overtop of a video
   * that is in the viewport.
   *
   * @param {Element} video The video the mouse is over.
   */
645
  onMouseOverVideo(video, event) {
646
    let oldOverVideo = this.getWeakOverVideo();
647
648
649
650
651
652
653
654
655
656
657
658
    let shadowRoot = video.openOrClosedShadowRoot;

    // It seems from automated testing that if it's still very early on in the
    // lifecycle of a <video> element, it might not yet have a shadowRoot,
    // in which case, we can bail out here early.
    if (!shadowRoot) {
      if (oldOverVideo) {
        // We also clear the hover state on the old video we were hovering,
        // if there was one.
        this.onMouseLeaveVideo(oldOverVideo);
      }

659
660
661
      return;
    }

662
    let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
663
664
665
666
667
668
669
670
671
672
    let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
    controlsOverlay.removeAttribute("hidetoggle");

    // The hideToggleDeferredTask we create here is for automatically hiding
    // the toggle after a period of no mousemove activity for
    // TOGGLE_HIDING_TIMEOUT_MS. If the mouse moves, then the DeferredTask
    // timer is reset.
    //
    // We disable the toggle hiding timeout during testing to reduce
    // non-determinism from timers when testing the toggle.
673
    let state = this.docState;
674
675
676
677
678
    if (!state.hideToggleDeferredTask && !this.toggleTesting) {
      state.hideToggleDeferredTask = new DeferredTask(() => {
        controlsOverlay.setAttribute("hidetoggle", true);
      }, TOGGLE_HIDING_TIMEOUT_MS);
    }
679
680
681
682
683
684
685
686
687
688
689
690
691
692

    if (oldOverVideo) {
      if (oldOverVideo == video) {
        // If we're still hovering the old video, we might have entered or
        // exited the toggle region.
        this.checkHoverToggle(toggle, event);
        return;
      }

      // We had an old video that we were hovering, and we're not hovering
      // it anymore. Let's leave it.
      this.onMouseLeaveVideo(oldOverVideo);
    }

693
    state.weakOverVideo = Cu.getWeakReference(video);
694
    controlsOverlay.classList.add("hovering");
695
696
697
698
699
700
701
702

    // Now that we're hovering the video, we'll check to see if we're
    // hovering the toggle too.
    this.checkHoverToggle(toggle, event);
  }

  /**
   * Checks if a mouse event is happening over a toggle element. If it is,
703
704
   * sets the hovering class on it. Otherwise, it clears the hovering
   * class.
705
706
707
708
709
   *
   * @param {Element} toggle The Picture-in-Picture toggle to check.
   * @param {MouseEvent} event A MouseEvent to test.
   */
  checkHoverToggle(toggle, event) {
710
    toggle.classList.toggle("hovering", this.isMouseOverToggle(toggle, event));
711
  }
712

713
714
715
716
717
718
719
  /**
   * Called once it has been determined that the mouse is no longer overlapping
   * a video that we'd previously called onMouseOverVideo with.
   *
   * @param {Element} video The video that the mouse left.
   */
  onMouseLeaveVideo(video) {
720
721
722
723
724
725
    let state = this.docState;
    let shadowRoot = video.openOrClosedShadowRoot;

    if (shadowRoot) {
      let controlsOverlay = shadowRoot.querySelector(".controlsOverlay");
      let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
726
727
      controlsOverlay.classList.remove("hovering");
      toggle.classList.remove("hovering");
728
729
730
    }

    state.weakOverVideo = null;
731
732
733

    if (!this.toggleTesting) {
      state.hideToggleDeferredTask.disarm();
734
      state.mousemoveDeferredTask.disarm();
735
736
    }

737
    state.hideToggleDeferredTask = null;
738
739
740
741
742
743
744
745
746
747
748
749
  }

  /**
   * Given a reference to a Picture-in-Picture toggle element, determines
   * if a MouseEvent event is occurring within its bounds.
   *
   * @param {Element} toggle The Picture-in-Picture toggle.
   * @param {MouseEvent} event A MouseEvent to test.
   *
   * @return {Boolean}
   */
  isMouseOverToggle(toggle, event) {
750
751
752
    let toggleRect = toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing(
      toggle
    );
753
754
755
756
757
758

    // If the toggle has no dimensions, we're definitely not over it.
    if (!toggleRect.width || !toggleRect.height) {
      return false;
    }

759
    let { clientX, clientY } = event;
760

761
762
763
764
765
766
    return (
      clientX >= toggleRect.left &&
      clientX <= toggleRect.right &&
      clientY >= toggleRect.top &&
      clientY <= toggleRect.bottom
    );
767
  }
768

769
770
771
772
773
774
775
776
  /**
   * Checks a contextmenu event to see if the mouse is currently over the
   * Picture-in-Picture toggle. If so, sends a message to the parent process
   * to open up the Picture-in-Picture toggle context menu.
   *
   * @param {MouseEvent} event A contextmenu event.
   */
  checkContextMenu(event) {
777
    let video = this.getWeakOverVideo();
778
779
780
781
782
783
784
785
786
787
788
789
790
791
    if (!video) {
      return;
    }

    let shadowRoot = video.openOrClosedShadowRoot;
    if (!shadowRoot) {
      return;
    }

    let toggle = shadowRoot.getElementById("pictureInPictureToggleButton");
    if (this.isMouseOverToggle(toggle, event)) {
      event.stopImmediatePropagation();
      event.preventDefault();

792
      this.sendAsyncMessage("PictureInPicture:OpenToggleContextMenu", {
793
794
795
796
797
798
799
        screenX: event.screenX,
        screenY: event.screenY,
        mozInputSource: event.mozInputSource,
      });
    }
  }

800
801
802
803
804
805
806
  /**
   * This is a test-only function that returns true if a video is being tracked
   * for mouseover events after having intersected the viewport.
   */
  static isTracking(video) {
    return gWeakIntersectingVideosForTesting.has(video);
  }
807
}
808

809
class PictureInPictureChild extends JSWindowActorChild {
810
  static videoIsPlaying(video) {
811
812
813
814
815
816
    return !!(
      video.currentTime > 0 &&
      !video.paused &&
      !video.ended &&
      video.readyState > 2
    );
817
818
  }

819
820
821
822
  static videoIsMuted(video) {
    return video.muted;
  }

823
824
825
  handleEvent(event) {
    switch (event.type) {
      case "MozTogglePictureInPicture": {
826
827
828
        if (event.isTrusted) {
          this.togglePictureInPicture(event.target);
        }
829
830
        break;
      }
831
      case "MozStopPictureInPicture": {
832
        if (event.isTrusted && event.target === this.getWeakVideo()) {
833
834
835
836
          this.closePictureInPicture({ reason: "video-el-remove" });
        }
        break;
      }
837
838
839
      case "pagehide": {
        // The originating video's content document has unloaded,
        // so close Picture-in-Picture.
840
        this.closePictureInPicture({ reason: "pagehide" });
841
842
        break;
      }
843
      case "play": {
844
        this.sendAsyncMessage("PictureInPicture:Playing");
845
846
847
        break;
      }
      case "pause": {
848
        this.sendAsyncMessage("PictureInPicture:Paused");
849
850
        break;
      }
851
      case "volumechange": {
852
853
854
855
856
857
858
859
860
861
862
863
864
        let video = this.getWeakVideo();

        // Just double-checking that we received the event for the right
        // video element.
        if (video !== event.target) {
          Cu.reportError(
            "PictureInPictureChild received volumechange for " +
              "the wrong video!"
          );
          return;
        }

        if (video.muted) {
865
866
867
868
869
870
          this.sendAsyncMessage("PictureInPicture:Muting");
        } else {
          this.sendAsyncMessage("PictureInPicture:Unmuting");
        }
        break;
      }
871
872
873
874
875
876
877
878
879
880
      case "resize": {
        let video = event.target;
        if (this.inPictureInPicture(video)) {
          this.sendAsyncMessage("PictureInPicture:Resize", {
            videoHeight: video.videoHeight,
            videoWidth: video.videoWidth,
          });
        }
        break;
      }
881
882
883
    }
  }

884
885
886
887
888
889
890
891
  /**
   * Returns a reference to the <video> element being displayed in Picture-in-Picture
   * mode.
   *
   * @return {Element} The <video> being displayed in Picture-in-Picture mode, or null
   * if that <video> no longer exists.
   */
  getWeakVideo() {
892
    if (gWeakVideo) {
893
894
895
896
897
898
899
      // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
      // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
      try {
        return gWeakVideo.get();
      } catch (e) {
        return null;
      }
900
901
902
903
    }
    return null;
  }

904
905
906
907
908
909
910
911
  /**
   * Returns a reference to the inner window of the about:blank document that is
   * cloning the originating <video> in the always-on-top player <xul:browser>.
   *
   * @return {Window} The inner window of the about:blank player <xul:browser>, or
   * null if that window has been closed.
   */
  getWeakPlayerContent() {
912
    if (gWeakPlayerContent) {
913
914
915
916
917
918
919
      // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
      // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
      try {
        return gWeakPlayerContent.get();
      } catch (e) {
        return null;
      }
920
921
922
923
    }
    return null;
  }

924
925
926
927
928
929
930
931
932
933
934
935
936
  /**
   * Tells the parent to open a Picture-in-Picture window hosting
   * a clone of the passed video. If we know about a pre-existing
   * Picture-in-Picture window existing, this tells the parent to
   * close it before opening the new one.
   *
   * @param {Element} video The <video> element to view in a Picture
   * in Picture window.
   *
   * @return {Promise}
   * @resolves {undefined} Once the new Picture-in-Picture window
   * has been requested.
   */
937
  async togglePictureInPicture(video) {
938
939
940
941
942
943
    // We don't allow viewing <video> elements with MediaStreams
    // in Picture-in-Picture for now due to bug 1592539.
    if (video.srcObject) {
      return;
    }

944
    if (this.inPictureInPicture(video)) {
945
946
947
948
949
      // The only way we could have entered here for the same video is if
      // we are toggling via the context menu, since we hide the inline
      // Picture-in-Picture toggle when a video is being displayed in
      // Picture-in-Picture.
      await this.closePictureInPicture({ reason: "contextmenu" });
950
    } else {
951
      if (this.getWeakVideo()) {
952
953
954
        // There's a pre-existing Picture-in-Picture window for a video
        // in this content process. Send a message to the parent to close
        // the Picture-in-Picture window.
955
        await this.closePictureInPicture({ reason: "new-pip" });
956
957
      }

958
      gWeakVideo = Cu.getWeakReference(video);
959
      this.sendAsyncMessage("PictureInPicture:Request", {
960
        isMuted: PictureInPictureChild.videoIsMuted(video),
961
        playing: PictureInPictureChild.videoIsPlaying(video),
962
963
964
        videoHeight: video.videoHeight,
        videoWidth: video.videoWidth,
      });
965
966
967
    }
  }

968
969
970
971
972
973
974
975
  /**
   * Returns true if the passed video happens to be the one that this
   * content process is running in a Picture-in-Picture window.
   *
   * @param {Element} video The <video> element to check.
   *
   * @return {Boolean}
   */
976
  inPictureInPicture(video) {
977
    return this.getWeakVideo() === video;
978
979
  }

980
981
982
983
984
985
986
987
988
  /**
   * Tells the parent to close a pre-existing Picture-in-Picture
   * window.
   *
   * @return {Promise}
   *
   * @resolves {undefined} Once the pre-existing Picture-in-Picture
   * window has unloaded.
   */
989
  async closePictureInPicture({ reason }) {
990
991
992
    let video = this.getWeakVideo();
    if (video) {
      this.untrackOriginatingVideo(video);
993
    }
994
    this.sendAsyncMessage("PictureInPicture:Close", {
995
      reason,
996
    });
997

998
999
1000
    let playerContent = this.getWeakPlayerContent();
    if (playerContent) {
      if (!playerContent.closed) {