diff --git a/browser/base/content/browser-context.inc b/browser/base/content/browser-context.inc index 526f0f69a6..bbdb0139fd 100644 --- a/browser/base/content/browser-context.inc +++ b/browser/base/content/browser-context.inc @@ -159,6 +159,9 @@ label="&mediaHideControls.label;" accesskey="&mediaHideControls.accesskey;" oncommand="gContextMenu.mediaCommand('hidecontrols');"/> + { () => { let media = message.objects.element; switch (message.data.command) { + case "pictureinpicture": { + let {PictureInPicture} = Cu.import("resource:///modules/PictureInPicture.jsm", {}); + let data; + try { + data = PictureInPicture.register(media, message.data.data); + } catch (error) { + data = {id: message.data.data, error: error.message}; + } + sendAsyncMessage("PictureInPicture:Prepared", data); + break; + } case "play": media.play(); break; diff --git a/browser/base/content/nsContextMenu.js b/browser/base/content/nsContextMenu.js index 5a47c1269f..61740a1599 100644 --- a/browser/base/content/nsContextMenu.js +++ b/browser/base/content/nsContextMenu.js @@ -442,6 +442,11 @@ nsContextMenu.prototype = { this.showItem("context-media-loop", onMedia); this.showItem("context-media-showcontrols", onMedia && !this.target.controls); this.showItem("context-media-hidecontrols", this.target.controls && (this.onVideo || (this.onAudio && !this.inSyntheticDoc))); + this.showItem("context-video-pictureinpicture", this.onVideo); + this.setItemAttr("context-video-pictureinpicture", "disabled", + !this.onVideo || this.target.error || + !this.target.videoWidth || !this.target.videoHeight || + !!this.target.mediaKeys); this.showItem("context-video-fullscreen", this.onVideo && this.target.ownerDocument.fullscreenElement == null); this.showItem("context-media-eme-learnmore", this.onDRMMedia); this.showItem("context-media-eme-separator", this.onDRMMedia); @@ -1682,6 +1687,11 @@ nsContextMenu.prototype = { this.browser.messageManager.sendAsyncMessage("SwitchDocumentDirection"); }, + pictureInPicture: function() { + let {PictureInPicture} = Cu.import("resource:///modules/PictureInPicture.jsm", {}); + PictureInPicture.open(this.browser, this.target); + }, + mediaCommand : function(command, data) { let mm = this.browser.messageManager; let win = this.browser.ownerGlobal; diff --git a/browser/base/content/pictureInPicture.css b/browser/base/content/pictureInPicture.css new file mode 100644 index 0000000000..ca99293f6c --- /dev/null +++ b/browser/base/content/pictureInPicture.css @@ -0,0 +1,191 @@ +/* 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/. */ + +browser[remote="true"] { + -moz-binding: url("chrome://global/content/bindings/remote-browser.xml#remote-browser"); +} + +#picture-in-picture { + min-width: 300px; + min-height: 170px; + background: black; + color: white; + overflow: hidden; +} + +#player-stack { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + overflow: hidden; + -moz-user-select: none; +} + +#player-container, +#player-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + min-width: 0; + min-height: 0; +} + +#player-container { + background: black; +} + +#player-container > browser { + display: -moz-box; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + border: 0; +} + +#player-overlay { + display: flex; + flex-direction: column; + -moz-window-dragging: no-drag; +} + +#drag-area { + flex: 1; + min-height: 0; + cursor: move; +} + +.pip-controls { + opacity: 0; + pointer-events: none; + transition: opacity 150ms ease; +} + +#player-overlay:hover .pip-controls, +#player-overlay[keyboard] .pip-controls, +#player-overlay[seeking] .pip-controls, +#player-stack[dragging] .pip-controls { + opacity: 1; + pointer-events: auto; +} + +#top-controls { + display: flex; + flex-shrink: 0; + justify-content: flex-end; + padding: 8px; + background: linear-gradient(rgba(0, 0, 0, .55), transparent); +} + +#playback-controls { + flex-shrink: 0; + padding: 12px 10px 8px; + background: linear-gradient(transparent, rgba(0, 0, 0, .85)); + -moz-window-dragging: no-drag; +} + +#button-row { + display: flex; + align-items: center; + min-width: 0; +} + +.pip-controls button { + -moz-appearance: none; + -moz-window-dragging: no-drag; + min-width: 0; + margin: 2px; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 4px; + background: rgba(30, 30, 30, .8); + color: white; + text-shadow: none; +} + +.pip-controls button:hover, +.pip-controls button:focus { + border-color: white; + background: #444; +} + +#close { + font-size: 22px; + width: 32px; + height: 32px; + padding: 0; +} + +#seek { + box-sizing: border-box; + display: block; + width: 100%; + margin: 0 0 6px; + cursor: pointer; + -moz-window-dragging: no-drag; +} + +#seek:disabled { + opacity: .45; + cursor: default; +} + +#time { + flex: 1; + min-width: 0; + margin: 0 6px; + font-variant-numeric: tabular-nums; +} + +.resize-edge, +.resize-corner { + position: absolute; + z-index: 2; + -moz-window-dragging: no-drag; +} + +.resize-edge[data-resize="n"], +.resize-edge[data-resize="s"] { + left: 14px; + right: 14px; + height: 6px; + cursor: ns-resize; +} + +.resize-edge[data-resize="e"], +.resize-edge[data-resize="w"] { + top: 14px; + bottom: 14px; + width: 6px; + cursor: ew-resize; +} + +.resize-corner { + width: 14px; + height: 14px; +} + +[data-resize*="n"] { top: 0; } +[data-resize*="s"] { bottom: 0; } +[data-resize*="e"] { right: 0; } +[data-resize*="w"] { left: 0; } +[data-resize="nw"], [data-resize="se"] { cursor: nwse-resize; } +[data-resize="ne"], [data-resize="sw"] { cursor: nesw-resize; } + +@media (min-width: 700px) { + #playback-controls { + padding: 20px 20px 14px; + } + .pip-controls button { + padding: 8px 12px; + font-size: 14px; + } + #time { + font-size: 14px; + } +} diff --git a/browser/base/content/pictureInPicture.js b/browser/base/content/pictureInPicture.js new file mode 100644 index 0000000000..aaaf3d0114 --- /dev/null +++ b/browser/base/content/pictureInPicture.js @@ -0,0 +1,250 @@ +/* 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"; + +// The browser XBL bindings resolve these names in their owning window. +const {utils: Cu, results: Cr} = Components; +Cu.import("resource://gre/modules/Services.jsm"); + +var Player = { + browser: null, + source: null, + sourceTab: null, + seeking: false, + state: null, + drag: null, + + startDrag(event) { + if (event.button != 0 || + event.target.closest("button, input, #playback-controls")) { + return; + } + let stack = document.getElementById("player-stack"); + if (!stack.contains(event.target)) { + return; + } + event.preventDefault(); + this.drag = { + direction: event.target.getAttribute("data-resize") || "", + pointerX: event.screenX, + pointerY: event.screenY, + x: window.screenX, + y: window.screenY, + width: window.outerWidth, + height: window.outerHeight, + }; + // Capture keeps the gesture working after the pointer leaves the window. + stack.setCapture(true); + stack.setAttribute("dragging", "true"); + }, + + moveDrag(event) { + let drag = this.drag; + if (!drag) { + return; + } + if (!(event.buttons & 1)) { + this.endDrag(); + return; + } + event.preventDefault(); + let dx = event.screenX - drag.pointerX; + let dy = event.screenY - drag.pointerY; + if (!drag.direction) { + window.moveTo(drag.x + dx, drag.y + dy); + return; + } + let west = drag.direction.includes("w"); + let east = drag.direction.includes("e"); + let north = drag.direction.includes("n"); + let south = drag.direction.includes("s"); + let width = Math.max(300, drag.width + (west ? -dx : east ? dx : 0)); + let height = Math.max(170, drag.height + (north ? -dy : south ? dy : 0)); + window.resizeTo(width, height); + // Anchor the opposite edge, using the actual size in case the platform + // imposed a constraint (for example, at a monitor boundary). + if (west || north) { + window.moveTo(west ? drag.x + drag.width - window.outerWidth : drag.x, + north ? drag.y + drag.height - window.outerHeight : drag.y); + } + }, + + endDrag() { + if (this.drag) { + this.drag = null; + let stack = document.getElementById("player-stack"); + stack.releaseCapture(); + stack.removeAttribute("dragging"); + } + }, + + init() { + if (this.browser) { + return; + } + let args = window.arguments[0]; + this.source = args.browser; + if (!this.source || !this.source.isConnected) { + window.close(); + return; + } + this.sourceTab = this.source.ownerGlobal.gBrowser.getTabForBrowser(this.source); + this.close = () => window.close(); + this.sourceTab.addEventListener("TabClose", this.close); + this.source.addEventListener("oop-browser-crashed", this.close); + + this.playLabel = document.getElementById("play").label; + this.muteLabel = document.getElementById("mute").label; + let browser = this.browser = document.createElement("browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("flex", "1"); + browser.setAttribute("disablehistory", "true"); + browser.setAttribute("remote", this.source.isRemoteBrowser ? "true" : "false"); + if (this.source.isRemoteBrowser) { + browser.setAttribute("remoteType", this.source.getAttribute("remoteType")); + } + // The process-local source reference requires this process affinity. + browser.relatedBrowser = this.source; + document.getElementById("player-container").appendChild(browser); + let mm = browser.messageManager; + let initialized = false; + let ready = () => { + if (initialized) { + return; + } + initialized = true; + mm.removeMessageListener("PictureInPicture:Ready", ready); + mm.sendAsyncMessage("PictureInPicture:Init", {id: args.id}); + }; + mm.addMessageListener("PictureInPicture:Ready", ready); + mm.addMessageListener("PictureInPicture:Close", this.close); + mm.addMessageListener("PictureInPicture:State", message => { + this.updateState(message.data); + }); + browser.addEventListener("oop-browser-crashed", this.close); + mm.loadFrameScript("chrome://browser/content/pictureInPictureContent.js", false); + }, + + command(command, data = {}) { + if (this.browser) { + this.browser.messageManager.sendAsyncMessage("PictureInPicture:Command", + Object.assign({command}, data)); + } + }, + + formatTime(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) { + return "0:00"; + } + seconds = Math.floor(seconds); + let hours = Math.floor(seconds / 3600); + let minutes = Math.floor(seconds / 60) % 60; + let pad = value => value < 10 ? "0" + value : String(value); + return (hours ? hours + ":" + pad(minutes) : String(minutes)) + + ":" + pad(seconds % 60); + }, + + updateTime(position) { + let duration = this.state && this.state.duration; + let total = Number.isFinite(duration) ? this.formatTime(duration) : + document.getElementById("live-label").value; + document.getElementById("time").value = this.formatTime(position) + " / " + total; + }, + + updateState(state) { + this.state = state; + document.getElementById("play").label = state.paused ? + this.playLabel : document.getElementById("pause-label").value; + document.getElementById("mute").label = state.muted ? + document.getElementById("unmute-label").value : this.muteLabel; + if (!this.seeking) { + let seek = document.getElementById("seek"); + seek.disabled = !(state.seekableEnd > state.seekableStart); + seek.min = state.seekableStart; + seek.max = state.seekableEnd || 1; + seek.value = state.currentTime; + this.updateTime(state.currentTime); + } + }, + + seek(value) { + let position = Number(value); + if (!Number.isFinite(position) || document.getElementById("seek").disabled) { + return; + } + this.seeking = true; + document.getElementById("player-overlay").setAttribute("seeking", "true"); + this.updateTime(position); + this.command("seek", {time: position}); + }, + + finishSeeking() { + this.seeking = false; + document.getElementById("player-overlay").removeAttribute("seeking"); + }, + + returnToTab() { + if (this.source && this.source.isConnected) { + let win = this.source.ownerGlobal; + win.gBrowser.selectedTab = this.sourceTab; + win.focus(); + } + window.close(); + }, + + destroy() { + this.endDrag(); + if (this.sourceTab) { + this.sourceTab.removeEventListener("TabClose", this.close); + this.source.removeEventListener("oop-browser-crashed", this.close); + } + if (this.browser) { + this.command("close"); + } + this.source = this.sourceTab = null; + }, +}; + +// Only consume the listener for the player document's own load, rather than +// a load from the embedded browser. +window.addEventListener("load", function onLoad(event) { + if (event.target == document) { + window.removeEventListener("load", onLoad); + Player.init(); + } +}); +window.addEventListener("unload", function onUnload(event) { + if (event.target == document) { + window.removeEventListener("unload", onUnload); + Player.destroy(); + } +}); +window.addEventListener("keydown", event => { + document.getElementById("player-overlay").setAttribute("keyboard", "true"); + if (event.key == "Escape") { + window.close(); + } else if (event.target.localName == "input" || event.target.localName == "button") { + return; + } else if (event.key == " ") { + event.preventDefault(); + Player.command("playpause"); + } else if (event.key == "ArrowLeft" || event.key == "ArrowRight") { + event.preventDefault(); + if (Player.state) { + Player.command("seek", {time: Player.state.currentTime + + (event.key == "ArrowLeft" ? -5 : 5)}); + } + } +}); +window.addEventListener("mousedown", event => Player.startDrag(event)); +window.addEventListener("mousemove", event => { + Player.moveDrag(event); + document.getElementById("player-overlay").removeAttribute("keyboard"); +}); +window.addEventListener("mouseup", () => Player.endDrag()); +window.addEventListener("blur", event => { + if (event.target == window) { + Player.endDrag(); + } +}); diff --git a/browser/base/content/pictureInPicture.xul b/browser/base/content/pictureInPicture.xul new file mode 100644 index 0000000000..cca7a7050a --- /dev/null +++ b/browser/base/content/pictureInPicture.xul @@ -0,0 +1,47 @@ + + + + + + +