1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7var EXPORTED_SYMBOLS = [
8  "PictureInPicture",
9  "PictureInPictureParent",
10  "PictureInPictureToggleParent",
11];
12
13const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
14const { AppConstants } = ChromeUtils.import(
15  "resource://gre/modules/AppConstants.jsm"
16);
17
18const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml";
19var PLAYER_FEATURES =
20  "chrome,titlebar=yes,alwaysontop,lockaspectratio,resizable";
21/* Don't use dialog on Gtk as it adds extra border and titlebar to PIP window */
22if (!AppConstants.MOZ_WIDGET_GTK) {
23  PLAYER_FEATURES += ",dialog";
24}
25const WINDOW_TYPE = "Toolkit:PictureInPicture";
26const TOGGLE_ENABLED_PREF =
27  "media.videocontrols.picture-in-picture.video-toggle.enabled";
28
29/**
30 * If closing the Picture-in-Picture player window occurred for a reason that
31 * we can easily detect (user clicked on the close button, originating tab unloaded,
32 * user clicked on the unpip button), that will be stashed in gCloseReasons so that
33 * we can note it in Telemetry when the window finally unloads.
34 */
35let gCloseReasons = new WeakMap();
36
37/**
38 * To differentiate windows in the Telemetry Event Log, each Picture-in-Picture
39 * player window is given a unique ID.
40 */
41let gNextWindowID = 0;
42
43class PictureInPictureToggleParent extends JSWindowActorParent {
44  receiveMessage(aMessage) {
45    let browsingContext = aMessage.target.browsingContext;
46    let browser = browsingContext.top.embedderElement;
47    switch (aMessage.name) {
48      case "PictureInPicture:OpenToggleContextMenu": {
49        let win = browser.ownerGlobal;
50        PictureInPicture.openToggleContextMenu(win, aMessage.data);
51        break;
52      }
53    }
54  }
55}
56
57/**
58 * This module is responsible for creating a Picture in Picture window to host
59 * a clone of a video element running in web content.
60 */
61
62class PictureInPictureParent extends JSWindowActorParent {
63  receiveMessage(aMessage) {
64    let browsingContext = aMessage.target.browsingContext;
65    let browser = browsingContext.top.embedderElement;
66
67    switch (aMessage.name) {
68      case "PictureInPicture:Request": {
69        let videoData = aMessage.data;
70        PictureInPicture.handlePictureInPictureRequest(browser, videoData);
71        break;
72      }
73      case "PictureInPicture:Resize": {
74        let videoData = aMessage.data;
75        PictureInPicture.resizePictureInPictureWindow(videoData);
76        break;
77      }
78      case "PictureInPicture:Close": {
79        /**
80         * Content has requested that its Picture in Picture window go away.
81         */
82        let reason = aMessage.data.reason;
83        PictureInPicture.closePipWindow({ reason });
84        break;
85      }
86      case "PictureInPicture:Playing": {
87        let player = PictureInPicture.getWeakPipPlayer();
88        if (player) {
89          player.setIsPlayingState(true);
90        }
91        break;
92      }
93      case "PictureInPicture:Paused": {
94        let player = PictureInPicture.getWeakPipPlayer();
95        if (player) {
96          player.setIsPlayingState(false);
97        }
98        break;
99      }
100      case "PictureInPicture:Muting": {
101        let player = PictureInPicture.getWeakPipPlayer();
102        if (player) {
103          player.setIsMutedState(true);
104        }
105        break;
106      }
107      case "PictureInPicture:Unmuting": {
108        let player = PictureInPicture.getWeakPipPlayer();
109        if (player) {
110          player.setIsMutedState(false);
111        }
112        break;
113      }
114    }
115  }
116}
117
118/**
119 * This module is responsible for creating a Picture in Picture window to host
120 * a clone of a video element running in web content.
121 */
122
123var PictureInPicture = {
124  /**
125   * Returns the player window if one exists and if it hasn't yet been closed.
126   *
127   * @return {DOM Window} the player window if it exists and is not in the
128   * process of being closed. Returns null otherwise.
129   */
130  getWeakPipPlayer() {
131    let weakRef = this._weakPipPlayer;
132    if (weakRef) {
133      let playerWin;
134
135      // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
136      // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
137      try {
138        playerWin = weakRef.get();
139      } catch (e) {
140        return null;
141      }
142
143      if (!playerWin || playerWin.closed) {
144        return null;
145      }
146
147      return playerWin;
148    }
149    return null;
150  },
151
152  /**
153   * Called when the browser UI handles the View:PictureInPicture command via
154   * the keyboard.
155   */
156  onCommand(event) {
157    let win = event.target.ownerGlobal;
158    let browser = win.gBrowser.selectedBrowser;
159    let actor = browser.browsingContext.currentWindowGlobal.getActor(
160      "PictureInPicture"
161    );
162    actor.sendAsyncMessage("PictureInPicture:KeyToggle");
163  },
164
165  async focusTabAndClosePip() {
166    let gBrowser = this.browser.ownerGlobal.gBrowser;
167    let tab = gBrowser.getTabForBrowser(this.browser);
168    gBrowser.selectedTab = tab;
169    await this.closePipWindow({ reason: "unpip" });
170  },
171
172  /**
173   * Remove attribute which enables pip icon in tab
174   */
175  clearPipTabIcon() {
176    let win = this.browser.ownerGlobal;
177    let tab = win.gBrowser.getTabForBrowser(this.browser);
178    if (tab) {
179      tab.removeAttribute("pictureinpicture");
180    }
181  },
182
183  /**
184   * Find and close any pre-existing Picture in Picture windows.
185   */
186  async closePipWindow({ reason }) {
187    // This uses an enumerator, but there really should only be one of
188    // these things.
189    for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
190      if (win.closed) {
191        continue;
192      }
193      let closedPromise = new Promise(resolve => {
194        win.addEventListener("unload", resolve, { once: true });
195      });
196      gCloseReasons.set(win, reason);
197      win.close();
198      await closedPromise;
199    }
200  },
201
202  /**
203   * A request has come up from content to open a Picture in Picture
204   * window.
205   *
206   * @param browser (xul:browser)
207   *   The browser that is requesting the Picture in Picture window.
208   *
209   * @param videoData (object)
210   *   An object containing the following properties:
211   *
212   *   videoHeight (int):
213   *     The preferred height of the video.
214   *
215   *   videoWidth (int):
216   *     The preferred width of the video.
217   *
218   * @returns Promise
219   *   Resolves once the Picture in Picture window has been created, and
220   *   the player component inside it has finished loading.
221   */
222  async handlePictureInPictureRequest(browser, videoData) {
223    // If there's a pre-existing PiP window, close it first.
224    await this.closePipWindow({ reason: "new-pip" });
225
226    let parentWin = browser.ownerGlobal;
227    this.browser = browser;
228    let win = await this.openPipWindow(parentWin, videoData);
229    this._weakPipPlayer = Cu.getWeakReference(win);
230    win.setIsPlayingState(videoData.playing);
231    win.setIsMutedState(videoData.isMuted);
232
233    // set attribute which shows pip icon in tab
234    let tab = parentWin.gBrowser.getTabForBrowser(browser);
235    tab.setAttribute("pictureinpicture", true);
236
237    win.setupPlayer(gNextWindowID.toString(), browser);
238    gNextWindowID++;
239  },
240
241  /**
242   * unload event has been called in player.js, cleanup our preserved
243   * browser object.
244   */
245  unload(window) {
246    TelemetryStopwatch.finish(
247      "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION",
248      window
249    );
250
251    let reason = gCloseReasons.get(window) || "other";
252    Services.telemetry.keyedScalarAdd(
253      "pictureinpicture.closed_method",
254      reason,
255      1
256    );
257
258    this.clearPipTabIcon();
259    delete this._weakPipPlayer;
260    delete this.browser;
261  },
262
263  /**
264   * Open a Picture in Picture window on the same screen as parentWin,
265   * sized based on the information in videoData.
266   *
267   * @param parentWin (chrome window)
268   *   The window hosting the browser that requested the Picture in
269   *   Picture window.
270   *
271   * @param videoData (object)
272   *   An object containing the following properties:
273   *
274   *   videoHeight (int):
275   *     The preferred height of the video.
276   *
277   *   videoWidth (int):
278   *     The preferred width of the video.
279   *
280   * @returns Promise
281   *   Resolves once the window has opened and loaded the player component.
282   */
283  async openPipWindow(parentWin, videoData) {
284    let { top, left, width, height } = this.fitToScreen(parentWin, videoData);
285
286    let features =
287      `${PLAYER_FEATURES},top=${top},left=${left},` +
288      `outerWidth=${width},outerHeight=${height}`;
289
290    let pipWindow = Services.ww.openWindow(
291      parentWin,
292      PLAYER_URI,
293      null,
294      features,
295      null
296    );
297
298    TelemetryStopwatch.start(
299      "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION",
300      pipWindow,
301      {
302        inSeconds: true,
303      }
304    );
305
306    return new Promise(resolve => {
307      pipWindow.addEventListener(
308        "load",
309        () => {
310          resolve(pipWindow);
311        },
312        { once: true }
313      );
314    });
315  },
316
317  /**
318   * Calculate the desired size and position for a Picture in Picture window
319   * for the provided window and videoData.
320   *
321   * @param windowOrPlayer (chrome window|player window)
322   *   The window hosting the browser that requested the Picture in
323   *   Picture window. If this is an existing player window then the returned
324   *   player size and position will be determined based on the existing
325   *   player window's size and position.
326   *
327   * @param videoData (object)
328   *   An object containing the following properties:
329   *
330   *   videoHeight (int):
331   *     The preferred height of the video.
332   *
333   *   videoWidth (int):
334   *     The preferred width of the video.
335   *
336   * @returns object
337   *   The size and position for the player window.
338   *
339   *   top (int):
340   *     The top position for the player window.
341   *
342   *   left (int):
343   *     The left position for the player window.
344   *
345   *   width (int):
346   *     The width of the player window.
347   *
348   *   height (int):
349   *     The height of the player window.
350   */
351  fitToScreen(windowOrPlayer, videoData) {
352    let { videoHeight, videoWidth } = videoData;
353    let isPlayerWindow = windowOrPlayer == this.getWeakPipPlayer();
354
355    // The Picture in Picture window will open on the same display as the
356    // originating window, and anchor to the bottom right.
357    let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
358      Ci.nsIScreenManager
359    );
360    let screen = screenManager.screenForRect(
361      windowOrPlayer.screenX,
362      windowOrPlayer.screenY,
363      1,
364      1
365    );
366
367    // Now that we have the right screen, let's see how much available
368    // real-estate there is for us to work with.
369    let screenLeft = {},
370      screenTop = {},
371      screenWidth = {},
372      screenHeight = {};
373    screen.GetAvailRectDisplayPix(
374      screenLeft,
375      screenTop,
376      screenWidth,
377      screenHeight
378    );
379    let fullLeft = {},
380      fullTop = {},
381      fullWidth = {},
382      fullHeight = {};
383    screen.GetRectDisplayPix(fullLeft, fullTop, fullWidth, fullHeight);
384
385    // We have to divide these dimensions by the CSS scale factor for the
386    // display in order for the video to be positioned correctly on displays
387    // that are not at a 1.0 scaling.
388    let scaleFactor = screen.contentsScaleFactor / screen.defaultCSSScaleFactor;
389    screenWidth.value *= scaleFactor;
390    screenHeight.value *= scaleFactor;
391    screenLeft.value =
392      (screenLeft.value - fullLeft.value) * scaleFactor + fullLeft.value;
393    screenTop.value =
394      (screenTop.value - fullTop.value) * scaleFactor + fullTop.value;
395
396    // If we have a player window, maintain the previous player window's size by
397    // clamping the new video's largest dimension to the player window's
398    // largest dimension.
399    //
400    // Otherwise the Picture in Picture window will be a maximum of a quarter of
401    // the screen height, and a third of the screen width.
402    let preferredSize;
403    if (isPlayerWindow) {
404      let prevWidth = windowOrPlayer.innerWidth;
405      let prevHeight = windowOrPlayer.innerHeight;
406      preferredSize = prevWidth >= prevHeight ? prevWidth : prevHeight;
407    }
408    const MAX_HEIGHT = preferredSize || screenHeight.value / 4;
409    const MAX_WIDTH = preferredSize || screenWidth.value / 3;
410
411    let width = videoWidth;
412    let height = videoHeight;
413    let aspectRatio = videoWidth / videoHeight;
414
415    if (
416      videoHeight > MAX_HEIGHT ||
417      videoWidth > MAX_WIDTH ||
418      (isPlayerWindow && videoHeight < MAX_HEIGHT && videoWidth < MAX_WIDTH)
419    ) {
420      // We're bigger than the max, or smaller than the previous player window.
421      // Take the largest dimension and clamp it to the associated max.
422      // Recalculate the other dimension to maintain aspect ratio.
423      if (videoWidth >= videoHeight) {
424        // We're clamping the width, so the height must be adjusted to match
425        // the original aspect ratio. Since aspect ratio is width over height,
426        // that means we need to _divide_ the MAX_WIDTH by the aspect ratio to
427        // calculate the appropriate height.
428        width = MAX_WIDTH;
429        height = Math.round(MAX_WIDTH / aspectRatio);
430      } else {
431        // We're clamping the height, so the width must be adjusted to match
432        // the original aspect ratio. Since aspect ratio is width over height,
433        // this means we need to _multiply_ the MAX_HEIGHT by the aspect ratio
434        // to calculate the appropriate width.
435        height = MAX_HEIGHT;
436        width = Math.round(MAX_HEIGHT * aspectRatio);
437      }
438    }
439
440    // Figure out where to position the window on screen. If we have a player
441    // window this will account for any change in video size. Otherwise the
442    // video will be positioned in the bottom right.
443
444    if (isPlayerWindow) {
445      // We might need to move the window to keep its positioning in a similar
446      // part of the screen.
447      //
448      // Find the distance from each edge of the screen of the old video, we'll
449      // keep the closest edge in the same spot.
450      let prevWidth = windowOrPlayer.innerWidth;
451      let prevHeight = windowOrPlayer.innerHeight;
452      let distanceLeft = windowOrPlayer.screenX;
453      let distanceRight =
454        screenWidth.value - windowOrPlayer.screenX - prevWidth;
455      let distanceTop = windowOrPlayer.screenY;
456      let distanceBottom =
457        screenHeight.value - windowOrPlayer.screenY - prevHeight;
458
459      let left = windowOrPlayer.screenX;
460      let top = windowOrPlayer.screenY;
461
462      if (distanceRight < distanceLeft) {
463        // Closer to the right edge than the left. Move the window right by
464        // the difference in the video widths.
465        left += prevWidth - width;
466      }
467
468      if (distanceBottom < distanceTop) {
469        // Closer to the bottom edge than the top. Move the window down by
470        // the difference in the video heights.
471        top += prevHeight - height;
472      }
473
474      return { top, left, width, height };
475    }
476
477    // Now that we have the dimensions of the video, we need to figure out how
478    // to position it in the bottom right corner. Since we know the width of the
479    // available rect, we need to subtract the dimensions of the window we're
480    // opening to get the top left coordinates that openWindow expects.
481    //
482    // In event that the user has multiple displays connected, we have to
483    // calculate the top-left coordinate of the new window in absolute
484    // coordinates that span the entire display space, since this is what the
485    // openWindow expects for its top and left feature values.
486    //
487    // The screenWidth and screenHeight values only tell us the available
488    // dimensions on the screen that the parent window is on. We add these to
489    // the screenLeft and screenTop values, which tell us where this screen is
490    // located relative to the "origin" in absolute coordinates.
491    let isRTL = Services.locale.isAppLocaleRTL;
492    let left = isRTL
493      ? screenLeft.value
494      : screenLeft.value + screenWidth.value - width;
495    let top = screenTop.value + screenHeight.value - height;
496
497    return { top, left, width, height };
498  },
499
500  resizePictureInPictureWindow(videoData) {
501    let win = this.getWeakPipPlayer();
502
503    if (!win) {
504      return;
505    }
506
507    let { top, left, width, height } = this.fitToScreen(win, videoData);
508    win.resizeTo(width, height);
509    win.moveTo(left, top);
510  },
511
512  openToggleContextMenu(window, data) {
513    let document = window.document;
514    let popup = document.getElementById("pictureInPictureToggleContextMenu");
515
516    // We synthesize a new MouseEvent to propagate the inputSource to the
517    // subsequently triggered popupshowing event.
518    let newEvent = document.createEvent("MouseEvent");
519    newEvent.initNSMouseEvent(
520      "contextmenu",
521      true,
522      true,
523      null,
524      0,
525      data.screenX,
526      data.screenY,
527      0,
528      0,
529      false,
530      false,
531      false,
532      false,
533      0,
534      null,
535      0,
536      data.mozInputSource
537    );
538    popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent);
539  },
540
541  hideToggle() {
542    Services.prefs.setBoolPref(TOGGLE_ENABLED_PREF, false);
543  },
544};
545