PictureInPicture.jsm 15.4 KB
Newer Older
1
2
3
4
5
6
/* 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
8
9
10
11
var EXPORTED_SYMBOLS = [
  "PictureInPicture",
  "PictureInPictureParent",
  "PictureInPictureToggleParent",
];
12

13
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
14
15
const { AppConstants } = ChromeUtils.import(
  "resource://gre/modules/AppConstants.jsm"
16
17
);

18
const PLAYER_URI = "chrome://global/content/pictureinpicture/player.xhtml";
19
var PLAYER_FEATURES =
20
  "chrome,titlebar=yes,alwaysontop,lockaspectratio,resizable";
21
22
/* Don't use dialog on Gtk as it adds extra border and titlebar to PIP window */
if (!AppConstants.MOZ_WIDGET_GTK) {
23
  PLAYER_FEATURES += ",dialog";
24
}
25
const WINDOW_TYPE = "Toolkit:PictureInPicture";
26
27
const TOGGLE_ENABLED_PREF =
  "media.videocontrols.picture-in-picture.video-toggle.enabled";
28

29
30
31
32
33
34
35
36
/**
 * If closing the Picture-in-Picture player window occurred for a reason that
 * we can easily detect (user clicked on the close button, originating tab unloaded,
 * user clicked on the unpip button), that will be stashed in gCloseReasons so that
 * we can note it in Telemetry when the window finally unloads.
 */
let gCloseReasons = new WeakMap();

37
38
39
40
41
42
/**
 * To differentiate windows in the Telemetry Event Log, each Picture-in-Picture
 * player window is given a unique ID.
 */
let gNextWindowID = 0;

43
44
45
46
47
48
49
50
51
52
53
54
55
56
class PictureInPictureToggleParent extends JSWindowActorParent {
  receiveMessage(aMessage) {
    let browsingContext = aMessage.target.browsingContext;
    let browser = browsingContext.top.embedderElement;
    switch (aMessage.name) {
      case "PictureInPicture:OpenToggleContextMenu": {
        let win = browser.ownerGlobal;
        PictureInPicture.openToggleContextMenu(win, aMessage.data);
        break;
      }
    }
  }
}

57
58
59
60
61
/**
 * This module is responsible for creating a Picture in Picture window to host
 * a clone of a video element running in web content.
 */

62
class PictureInPictureParent extends JSWindowActorParent {
63
  receiveMessage(aMessage) {
64
65
    let browsingContext = aMessage.target.browsingContext;
    let browser = browsingContext.top.embedderElement;
66
67
68
69

    switch (aMessage.name) {
      case "PictureInPicture:Request": {
        let videoData = aMessage.data;
70
        PictureInPicture.handlePictureInPictureRequest(browser, videoData);
71
72
        break;
      }
73
74
75
76
77
      case "PictureInPicture:Resize": {
        let videoData = aMessage.data;
        PictureInPicture.resizePictureInPictureWindow(videoData);
        break;
      }
78
79
80
81
      case "PictureInPicture:Close": {
        /**
         * Content has requested that its Picture in Picture window go away.
         */
82
        let reason = aMessage.data.reason;
83
        PictureInPicture.closePipWindow({ reason });
84
85
        break;
      }
86
      case "PictureInPicture:Playing": {
87
        let player = PictureInPicture.getWeakPipPlayer();
88
89
        if (player) {
          player.setIsPlayingState(true);
90
91
92
93
        }
        break;
      }
      case "PictureInPicture:Paused": {
94
        let player = PictureInPicture.getWeakPipPlayer();
95
96
        if (player) {
          player.setIsPlayingState(false);
97
98
99
        }
        break;
      }
100
      case "PictureInPicture:Muting": {
101
        let player = PictureInPicture.getWeakPipPlayer();
102
103
104
105
106
107
        if (player) {
          player.setIsMutedState(true);
        }
        break;
      }
      case "PictureInPicture:Unmuting": {
108
        let player = PictureInPicture.getWeakPipPlayer();
109
110
111
112
113
        if (player) {
          player.setIsMutedState(false);
        }
        break;
      }
114
    }
115
116
  }
}
117

118
119
120
121
122
123
/**
 * This module is responsible for creating a Picture in Picture window to host
 * a clone of a video element running in web content.
 */

var PictureInPicture = {
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
  /**
   * Returns the player window if one exists and if it hasn't yet been closed.
   *
   * @return {DOM Window} the player window if it exists and is not in the
   * process of being closed. Returns null otherwise.
   */
  getWeakPipPlayer() {
    let weakRef = this._weakPipPlayer;
    if (weakRef) {
      let playerWin;

      // Bug 800957 - Accessing weakrefs at the wrong time can cause us to
      // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE
      try {
        playerWin = weakRef.get();
      } catch (e) {
        return null;
      }

      if (!playerWin.closed) {
        return playerWin;
      }
    }
    return null;
  },

150
151
152
153
154
155
156
  /**
   * Called when the browser UI handles the View:PictureInPicture command via
   * the keyboard.
   */
  onCommand(event) {
    let win = event.target.ownerGlobal;
    let browser = win.gBrowser.selectedBrowser;
157
158
159
160
    let actor = browser.browsingContext.currentWindowGlobal.getActor(
      "PictureInPicture"
    );
    actor.sendAsyncMessage("PictureInPicture:KeyToggle");
161
162
  },

163
  async focusTabAndClosePip() {
164
165
166
    let gBrowser = this.browser.ownerGlobal.gBrowser;
    let tab = gBrowser.getTabForBrowser(this.browser);
    gBrowser.selectedTab = tab;
167
    await this.closePipWindow({ reason: "unpip" });
168
169
  },

170
171
172
173
174
175
176
177
178
179
180
  /**
   * Remove attribute which enables pip icon in tab
   */
  clearPipTabIcon() {
    let win = this.browser.ownerGlobal;
    let tab = win.gBrowser.getTabForBrowser(this.browser);
    if (tab) {
      tab.removeAttribute("pictureinpicture");
    }
  },

181
182
183
  /**
   * Find and close any pre-existing Picture in Picture windows.
   */
184
  async closePipWindow({ reason }) {
185
186
187
188
189
190
    // This uses an enumerator, but there really should only be one of
    // these things.
    for (let win of Services.wm.getEnumerator(WINDOW_TYPE)) {
      if (win.closed) {
        continue;
      }
191
      let closedPromise = new Promise(resolve => {
192
        win.addEventListener("unload", resolve, { once: true });
193
      });
194
      gCloseReasons.set(win, reason);
195
      win.close();
196
      await closedPromise;
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
    }
  },

  /**
   * A request has come up from content to open a Picture in Picture
   * window.
   *
   * @param browser (xul:browser)
   *   The browser that is requesting the Picture in Picture window.
   *
   * @param videoData (object)
   *   An object containing the following properties:
   *
   *   videoHeight (int):
   *     The preferred height of the video.
   *
   *   videoWidth (int):
   *     The preferred width of the video.
   *
   * @returns Promise
   *   Resolves once the Picture in Picture window has been created, and
   *   the player component inside it has finished loading.
   */
  async handlePictureInPictureRequest(browser, videoData) {
221
    // If there's a pre-existing PiP window, close it first.
222
    await this.closePipWindow({ reason: "new-pip" });
223

224
    let parentWin = browser.ownerGlobal;
225
    this.browser = browser;
226
    let win = await this.openPipWindow(parentWin, videoData);
227
    this._weakPipPlayer = Cu.getWeakReference(win);
228
    win.setIsPlayingState(videoData.playing);
229
    win.setIsMutedState(videoData.isMuted);
230

231
232
233
    // set attribute which shows pip icon in tab
    let tab = parentWin.gBrowser.getTabForBrowser(browser);
    tab.setAttribute("pictureinpicture", true);
234

235
    win.setupPlayer(gNextWindowID.toString(), browser);
236
    gNextWindowID++;
237
238
  },

239
240
241
242
  /**
   * unload event has been called in player.js, cleanup our preserved
   * browser object.
   */
243
  unload(window) {
244
245
246
247
    TelemetryStopwatch.finish(
      "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION",
      window
    );
248
249

    let reason = gCloseReasons.get(window) || "other";
250
251
252
253
254
    Services.telemetry.keyedScalarAdd(
      "pictureinpicture.closed_method",
      reason,
      1
    );
255

256
    this.clearPipTabIcon();
257
    delete this._weakPipPlayer;
258
259
260
    delete this.browser;
  },

261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
  /**
   * Open a Picture in Picture window on the same screen as parentWin,
   * sized based on the information in videoData.
   *
   * @param parentWin (chrome window)
   *   The window hosting the browser that requested the Picture in
   *   Picture window.
   *
   * @param videoData (object)
   *   An object containing the following properties:
   *
   *   videoHeight (int):
   *     The preferred height of the video.
   *
   *   videoWidth (int):
   *     The preferred width of the video.
   *
   * @returns Promise
   *   Resolves once the window has opened and loaded the player component.
   */
  async openPipWindow(parentWin, videoData) {
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
    let { top, left, width, height } = this.fitToScreen(parentWin, videoData);

    let features =
      `${PLAYER_FEATURES},top=${top},left=${left},` +
      `outerWidth=${width},outerHeight=${height}`;

    let pipWindow = Services.ww.openWindow(
      parentWin,
      PLAYER_URI,
      null,
      features,
      null
    );

    TelemetryStopwatch.start(
      "FX_PICTURE_IN_PICTURE_WINDOW_OPEN_DURATION",
      pipWindow,
      {
        inSeconds: true,
      }
    );

    return new Promise(resolve => {
      pipWindow.addEventListener(
        "load",
        () => {
          resolve(pipWindow);
        },
        { once: true }
      );
    });
  },

  /**
   * Calculate the desired size and position for a Picture in Picture window
   * for the provided window and videoData.
   *
   * @param windowOrPlayer (chrome window|player window)
   *   The window hosting the browser that requested the Picture in
   *   Picture window. If this is an existing player window then the returned
   *   player size and position will be determined based on the existing
   *   player window's size and position.
   *
   * @param videoData (object)
   *   An object containing the following properties:
   *
   *   videoHeight (int):
   *     The preferred height of the video.
   *
   *   videoWidth (int):
   *     The preferred width of the video.
   *
   * @returns object
   *   The size and position for the player window.
   *
   *   top (int):
   *     The top position for the player window.
   *
   *   left (int):
   *     The left position for the player window.
   *
   *   width (int):
   *     The width of the player window.
   *
   *   height (int):
   *     The height of the player window.
   */
  fitToScreen(windowOrPlayer, videoData) {
350
    let { videoHeight, videoWidth } = videoData;
351
    let isPlayerWindow = windowOrPlayer == this.getWeakPipPlayer();
352
353
354

    // The Picture in Picture window will open on the same display as the
    // originating window, and anchor to the bottom right.
355
356
357
358
    let screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
      Ci.nsIScreenManager
    );
    let screen = screenManager.screenForRect(
359
360
      windowOrPlayer.screenX,
      windowOrPlayer.screenY,
361
362
363
      1,
      1
    );
364
365
366

    // Now that we have the right screen, let's see how much available
    // real-estate there is for us to work with.
367
368
369
370
371
372
373
374
375
376
    let screenLeft = {},
      screenTop = {},
      screenWidth = {},
      screenHeight = {};
    screen.GetAvailRectDisplayPix(
      screenLeft,
      screenTop,
      screenWidth,
      screenHeight
    );
377
378
379
380
381
    let fullLeft = {},
      fullTop = {},
      fullWidth = {},
      fullHeight = {};
    screen.GetRectDisplayPix(fullLeft, fullTop, fullWidth, fullHeight);
382

383
384
385
    // We have to divide these dimensions by the CSS scale factor for the
    // display in order for the video to be positioned correctly on displays
    // that are not at a 1.0 scaling.
386
387
388
389
390
391
392
    let scaleFactor = screen.contentsScaleFactor / screen.defaultCSSScaleFactor;
    screenWidth.value *= scaleFactor;
    screenHeight.value *= scaleFactor;
    screenLeft.value =
      (screenLeft.value - fullLeft.value) * scaleFactor + fullLeft.value;
    screenTop.value =
      (screenTop.value - fullTop.value) * scaleFactor + fullTop.value;
393

394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
    // If we have a player window, maintain the previous player window's size by
    // clamping the new video's largest dimension to the player window's
    // largest dimension.
    //
    // Otherwise the Picture in Picture window will be a maximum of a quarter of
    // the screen height, and a third of the screen width.
    let preferredSize;
    if (isPlayerWindow) {
      let prevWidth = windowOrPlayer.innerWidth;
      let prevHeight = windowOrPlayer.innerHeight;
      preferredSize = prevWidth >= prevHeight ? prevWidth : prevHeight;
    }
    const MAX_HEIGHT = preferredSize || screenHeight.value / 4;
    const MAX_WIDTH = preferredSize || screenWidth.value / 3;

    let width = videoWidth;
    let height = videoHeight;
    let aspectRatio = videoWidth / videoHeight;

    if (
      videoHeight > MAX_HEIGHT ||
      videoWidth > MAX_WIDTH ||
      (isPlayerWindow && videoHeight < MAX_HEIGHT && videoWidth < MAX_WIDTH)
    ) {
      // We're bigger than the max, or smaller than the previous player window.
      // Take the largest dimension and clamp it to the associated max.
      // Recalculate the other dimension to maintain aspect ratio.
421
422
423
424
425
      if (videoWidth >= videoHeight) {
        // We're clamping the width, so the height must be adjusted to match
        // the original aspect ratio. Since aspect ratio is width over height,
        // that means we need to _divide_ the MAX_WIDTH by the aspect ratio to
        // calculate the appropriate height.
426
427
        width = MAX_WIDTH;
        height = Math.round(MAX_WIDTH / aspectRatio);
428
429
430
431
432
      } else {
        // We're clamping the height, so the width must be adjusted to match
        // the original aspect ratio. Since aspect ratio is width over height,
        // this means we need to _multiply_ the MAX_HEIGHT by the aspect ratio
        // to calculate the appropriate width.
433
434
        height = MAX_HEIGHT;
        width = Math.round(MAX_HEIGHT * aspectRatio);
435
436
437
438
439
440
441
      }
    }

    // Now that we have the dimensions of the video, we need to figure out how
    // to position it in the bottom right corner. Since we know the width of the
    // available rect, we need to subtract the dimensions of the window we're
    // opening to get the top left coordinates that openWindow expects.
442
443
444
445
446
447
448
449
450
451
    //
    // In event that the user has multiple displays connected, we have to
    // calculate the top-left coordinate of the new window in absolute
    // coordinates that span the entire display space, since this is what the
    // openWindow expects for its top and left feature values.
    //
    // The screenWidth and screenHeight values only tell us the available
    // dimensions on the screen that the parent window is on. We add these to
    // the screenLeft and screenTop values, which tell us where this screen is
    // located relative to the "origin" in absolute coordinates.
452
    let isRTL = Services.locale.isAppLocaleRTL;
453
    let left = isRTL
454
      ? screenLeft.value
455
456
      : screenLeft.value + screenWidth.value - width;
    let top = screenTop.value + screenHeight.value - height;
457

458
459
    return { top, left, width, height };
  },
460

461
462
  resizePictureInPictureWindow(videoData) {
    let win = this.getWeakPipPlayer();
463

464
465
466
467
468
469
    if (!win) {
      return;
    }

    let { width, height } = this.fitToScreen(win, videoData);
    win.resizeTo(width, height);
470
  },
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503

  openToggleContextMenu(window, data) {
    let document = window.document;
    let popup = document.getElementById("pictureInPictureToggleContextMenu");

    // We synthesize a new MouseEvent to propagate the inputSource to the
    // subsequently triggered popupshowing event.
    let newEvent = document.createEvent("MouseEvent");
    newEvent.initNSMouseEvent(
      "contextmenu",
      true,
      true,
      null,
      0,
      data.screenX,
      data.screenY,
      0,
      0,
      false,
      false,
      false,
      false,
      0,
      null,
      0,
      data.mozInputSource
    );
    popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent);
  },

  hideToggle() {
    Services.prefs.setBoolPref(TOGGLE_ENABLED_PREF, false);
  },
504
};