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