PiP initial commit

This commit is contained in:
wuggy 2026-09-13 10:51:36 -07:00
commit 9a1d4edaa9
14 changed files with 377 additions and 1 deletions

View file

@ -159,6 +159,9 @@
label="&mediaHideControls.label;"
accesskey="&mediaHideControls.accesskey;"
oncommand="gContextMenu.mediaCommand('hidecontrols');"/>
<menuitem id="context-video-pictureinpicture"
label="&pictureInPicture.title;"
oncommand="gContextMenu.pictureInPicture();"/>
<menuitem id="context-video-fullscreen"
accesskey="&videoFullScreen.accesskey;"
label="&videoFullScreen.label;"

View file

@ -681,6 +681,17 @@ addMessageListener("ContextMenu:MediaCommand", (message) => {
() => {
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;

View file

@ -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;

View file

@ -0,0 +1,87 @@
/* 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";
var Player = {
browser: null,
source: null,
sourceTab: null,
init() {
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;
mm.addMessageListener("PictureInPicture:Ready", () => {
mm.sendAsyncMessage("PictureInPicture:Init", {id: args.id});
});
mm.addMessageListener("PictureInPicture:Close", this.close);
mm.addMessageListener("PictureInPicture:State", message => {
document.getElementById("play").label = message.data.paused ?
this.playLabel : document.getElementById("pause-label").value;
document.getElementById("mute").label = message.data.muted ?
document.getElementById("unmute-label").value : this.muteLabel;
});
browser.addEventListener("oop-browser-crashed", this.close);
mm.loadFrameScript("chrome://browser/content/pictureInPictureContent.js", false);
},
command(command) {
if (this.browser) {
this.browser.messageManager.sendAsyncMessage("PictureInPicture:Command", {command});
}
},
returnToTab() {
if (this.source && this.source.isConnected) {
let win = this.source.ownerGlobal;
win.gBrowser.selectedTab = this.sourceTab;
win.focus();
}
window.close();
},
destroy() {
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;
},
};
window.addEventListener("load", () => Player.init(), {once: true});
window.addEventListener("unload", () => Player.destroy(), {once: true});
window.addEventListener("keydown", event => {
if (event.key == "Escape") {
window.close();
} else if (event.key == " " && event.target.localName != "button") {
event.preventDefault();
Player.command("playpause");
}
});

View file

@ -0,0 +1,21 @@
<?xml version="1.0"?>
<!-- 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/. -->
<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
<!DOCTYPE window SYSTEM "chrome://browser/locale/browser.dtd">
<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
id="picture-in-picture" windowtype="Browser:PictureInPicture"
title="&pictureInPicture.title;" width="480" height="318"
persist="screenX screenY width height" style="min-width: 240px; min-height: 160px;">
<script type="application/javascript" src="chrome://browser/content/pictureInPicture.js"/>
<vbox id="player-container" flex="1" style="background: black;"/>
<hbox align="center" pack="center">
<button id="play" label="&mediaPlay.label;" oncommand="Player.command('playpause');"/>
<button id="mute" label="&mediaMute.label;" oncommand="Player.command('mute');"/>
<button id="return" label="&pictureInPicture.return;" oncommand="Player.returnToTab();"/>
<button id="close" label="&pictureInPicture.close;" oncommand="window.close();"/>
</hbox>
<label id="pause-label" value="&mediaPause.label;" hidden="true"/>
<label id="unmute-label" value="&mediaUnmute.label;" hidden="true"/>
</window>

View file

@ -0,0 +1,129 @@
/* 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";
var {PictureInPicture} = Components.utils.import(
"resource:///modules/PictureInPicture.jsm", {});
var source = null;
var stream = null;
var output = null;
var still = null;
var observer = null;
var sourceWindow = null;
var events = ["play", "pause", "volumechange", "ended", "seeked", "resize"];
function update() {
if (!source) {
return;
}
// Capture streams do not produce frames while their source is paused.
// Keep a snapshot for that case, without changing the source's play state.
still.hidden = !source.paused && !source.ended;
if (!still.hidden && source.readyState >= 2) {
still.width = source.videoWidth;
still.height = source.videoHeight;
still.getContext("2d").drawImage(source, 0, 0, still.width, still.height);
}
sendAsyncMessage("PictureInPicture:State",
{paused: source.paused || source.ended, muted: source.muted});
}
function cleanup() {
if (observer) {
observer.disconnect();
observer = null;
}
if (source) {
for (let name of events) {
source.removeEventListener(name, update);
}
source.removeEventListener("emptied", closePlayer);
source.removeEventListener("error", closePlayer);
sourceWindow.removeEventListener("pagehide", closePlayer);
source.mozPictureInPicture = false;
source = sourceWindow = null;
}
if (output) {
output.srcObject = null;
output = null;
}
if (stream) {
for (let track of stream.getTracks()) {
track.stop();
}
stream = null;
}
still = null;
}
function closePlayer() {
cleanup();
sendAsyncMessage("PictureInPicture:Close");
}
addMessageListener("PictureInPicture:Init", message => {
try {
source = PictureInPicture.takeSource(message.data.id);
sourceWindow = source.ownerGlobal;
let doc = content.document;
doc.documentElement.style.cssText = "height:100%;background:black";
doc.body.style.cssText = "margin:0;height:100%;overflow:hidden";
output = doc.createElement("video");
still = doc.createElement("canvas");
for (let element of [output, still]) {
element.style.cssText = "position:absolute;width:100%;height:100%;object-fit:contain";
doc.body.appendChild(element);
}
output.muted = true; // Audio continues to come from the originating video.
source.mozPictureInPicture = true;
stream = source.mozCaptureStream();
output.srcObject = stream;
output.play().catch(error => {
Components.utils.reportError(error);
closePlayer();
});
for (let name of events) {
source.addEventListener(name, update);
}
source.addEventListener("emptied", closePlayer);
source.addEventListener("error", closePlayer);
sourceWindow.addEventListener("pagehide", closePlayer);
observer = new sourceWindow.MutationObserver(() => {
if (source && !source.isConnected) {
closePlayer();
}
});
observer.observe(source.ownerDocument, {childList: true, subtree: true});
update();
} catch (error) {
Components.utils.reportError(error);
closePlayer();
}
});
addMessageListener("PictureInPicture:Command", message => {
if (!source) {
return;
}
switch (message.data.command) {
case "playpause":
if (source.paused || source.ended) {
source.play().catch(Components.utils.reportError);
} else {
source.pause();
}
break;
case "mute":
source.muted = !source.muted;
break;
case "close":
cleanup();
break;
}
});
// Frame-script unload also covers a parent window closing before an async
// close command can be delivered.
addEventListener("unload", cleanup);
sendAsyncMessage("PictureInPicture:Ready");

View file

@ -2,6 +2,9 @@
# 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.jar:
content/browser/pictureInPicture.xul (content/pictureInPicture.xul)
content/browser/pictureInPicture.js (content/pictureInPicture.js)
content/browser/pictureInPictureContent.js (content/pictureInPictureContent.js)
% content browser %content/browser/ contentaccessible=yes
#ifdef XP_MACOSX
% overlay chrome://mozapps/content/downloads/downloads.xul chrome://browser/content/downloadManagerOverlay.xul

View file

@ -777,3 +777,7 @@ you can use these alternative items. Otherwise, their values should be empty. -
<!ENTITY emeLearnMoreContextMenu.label "Learn more about DRM…">
<!ENTITY emeLearnMoreContextMenu.accesskey "D">
<!ENTITY pictureInPicture.title "Picture-in-Picture">
<!ENTITY pictureInPicture.return "Return to tab">
<!ENTITY pictureInPicture.close "Close">

View file

@ -0,0 +1,84 @@
/* 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";
this.EXPORTED_SYMBOLS = ["PictureInPicture"];
const {utils: Cu} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Timer.jsm");
// Each content process has its own module instance. Only a weak reference to
// the source crosses between frame scripts; DOM objects never cross processes.
this.PictureInPicture = {
source: null,
window: null,
request: 0,
register(video, id) {
if (!video || video.localName != "video" || video.error ||
!video.videoWidth || !video.videoHeight || video.mediaKeys) {
throw new Error("This video is not available for Picture-in-Picture");
}
this.source = {id, video: Cu.getWeakReference(video)};
return {id, width: video.videoWidth, height: video.videoHeight};
},
takeSource(id) {
if (!this.source || this.source.id != id) {
throw new Error("Picture-in-Picture source process is unavailable");
}
let video = this.source.video.get();
this.source = null;
if (!video || !video.isConnected) {
throw new Error("Picture-in-Picture source has been removed");
}
return video;
},
open(browser, video) {
let id = Services.uuid.generateUUID().toString();
let request = ++this.request;
let mm = browser.messageManager;
let timer;
let ready = message => {
if (message.data.id != id) {
return;
}
mm.removeMessageListener("PictureInPicture:Prepared", ready);
clearTimeout(timer);
if (request != this.request || !browser.isConnected) {
return;
}
if (message.data.error) {
Cu.reportError(message.data.error);
return;
}
this.openWindow(browser, message.data);
};
mm.addMessageListener("PictureInPicture:Prepared", ready);
timer = setTimeout(() => {
mm.removeMessageListener("PictureInPicture:Prepared", ready);
}, 10000);
mm.sendAsyncMessage("ContextMenu:MediaCommand",
{command: "pictureinpicture", data: id},
{element: video});
},
openWindow(browser, data) {
if (this.window && !this.window.closed) {
this.window.close();
}
let screen = browser.ownerGlobal.screen;
let width = Math.min(480, screen.availWidth);
let height = Math.min(Math.round(width * data.height / data.width) + 48,
Math.round(screen.availHeight * 0.8));
this.window = browser.ownerGlobal.openDialog(
"chrome://browser/content/pictureInPicture.xul", "",
"chrome,dialog=no,resizable,alwaysRaised,width=" + width +
",height=" + height,
{browser, id: data.id});
return this.window;
},
};

View file

@ -22,6 +22,7 @@ EXTRA_JS_MODULES += [
'NetworkPrioritizer.jsm',
'offlineAppCache.jsm',
'PermissionUI.jsm',
'PictureInPicture.jsm',
'PluginContent.jsm',
'ProcessHangMonitor.jsm',
'ReaderParent.jsm',

View file

@ -5183,6 +5183,15 @@ bool HTMLMediaElement::IsActive() const
return ownerDoc && ownerDoc->IsActive() && ownerDoc->IsVisible();
}
void HTMLMediaElement::SetMozPictureInPicture(bool aEnabled)
{
MOZ_ASSERT(NS_IsMainThread());
mPictureInPicture = aEnabled;
if (mDecoder && !mDecoder->IsShutdown()) {
mDecoder->NotifyOwnerActivityChanged(!IsHidden());
}
}
bool HTMLMediaElement::IsHidden() const
{
nsIDocument* ownerDoc;

View file

@ -211,6 +211,9 @@ public:
virtual bool IsHidden() const final override;
bool MozPictureInPicture() const { return mPictureInPicture; }
void SetMozPictureInPicture(bool aEnabled);
// Called by the media decoder and the video frame to get the
// ImageContainer containing the video data.
virtual VideoFrameContainer* GetVideoFrameContainer() final override;
@ -1622,6 +1625,9 @@ protected:
// Info about the played media.
MediaInfo mMediaInfo;
// Set only by privileged Picture-in-Picture UI; cleared when it closes.
bool mPictureInPicture = false;
// True if the media has encryption information.
bool mIsEncrypted;

View file

@ -1250,7 +1250,11 @@ MediaDecoder::SetElementVisibility(bool aIsVisible)
{
MOZ_ASSERT(NS_IsMainThread());
mElementVisible = aIsVisible;
mIsVisible = !mForcedHidden && mElementVisible;
// A floating player remains visible when its source tab is in the
// background or the original video is scrolled out of view.
HTMLMediaElement* element = mOwner ? mOwner->GetMediaElement() : nullptr;
mIsVisible = (element && element->MozPictureInPicture()) ||
(!mForcedHidden && mElementVisible);
}
void

View file

@ -46,6 +46,10 @@ partial interface HTMLVideoElement {
// True if the video has an audio track available.
readonly attribute boolean mozHasAudio;
// Browser UI only: keep the source decoder active for a floating player.
[ChromeOnly]
attribute boolean mozPictureInPicture;
// True if the video should use a screen wake lock.
[Pref="dom.wakelock.enabled", Func="Navigator::HasWakeLockSupport"]
attribute boolean mozUseScreenWakeLock;