Finish off PiP

This commit is contained in:
wuggy 2026-09-13 12:18:07 -07:00
commit 55a53a2576
14 changed files with 293 additions and 21 deletions

View file

@ -5,3 +5,100 @@
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;
}
#player-container {
background: black;
}
#player-overlay {
-moz-window-dragging: drag;
}
.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 {
opacity: 1;
pointer-events: auto;
}
#top-controls {
padding: 8px;
background: linear-gradient(rgba(0, 0, 0, .55), transparent);
}
#playback-controls {
padding: 12px 10px 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, .85));
-moz-window-dragging: no-drag;
}
.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 {
display: block;
width: 100%;
margin: 0 0 6px;
cursor: pointer;
-moz-window-dragging: no-drag;
}
#seek:disabled {
opacity: .45;
cursor: default;
}
#time {
margin: 0 6px;
font-variant-numeric: tabular-nums;
}
#resize {
justify-self: end;
align-self: end;
width: 14px;
height: 14px;
cursor: se-resize;
-moz-window-dragging: no-drag;
opacity: 0;
}
#resize:hover {
opacity: 1;
}

View file

@ -11,6 +11,8 @@ var Player = {
browser: null,
source: null,
sourceTab: null,
seeking: false,
state: null,
init() {
if (this.browser) {
@ -53,21 +55,70 @@ var Player = {
mm.addMessageListener("PictureInPicture:Ready", ready);
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;
this.updateState(message.data);
});
browser.addEventListener("oop-browser-crashed", this.close);
mm.loadFrameScript("chrome://browser/content/pictureInPictureContent.js", false);
},
command(command) {
command(command, data = {}) {
if (this.browser) {
this.browser.messageManager.sendAsyncMessage("PictureInPicture:Command", {command});
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;
@ -104,10 +155,22 @@ window.addEventListener("unload", function onUnload(event) {
}
});
window.addEventListener("keydown", event => {
document.getElementById("player-overlay").setAttribute("keyboard", "true");
if (event.key == "Escape") {
window.close();
} else if (event.key == " " && event.target.localName != "button") {
} 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("mousemove", () => {
document.getElementById("player-overlay").removeAttribute("keyboard");
});

View file

@ -6,17 +6,35 @@
<?xml-stylesheet href="chrome://browser/content/pictureInPicture.css" type="text/css"?>
<!DOCTYPE window SYSTEM "chrome://browser/locale/browser.dtd">
<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns:html="http://www.w3.org/1999/xhtml"
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;">
title="&pictureInPicture.title;" width="480" height="270"
hidechrome="true"
persist="screenX screenY width height">
<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>
<stack flex="1">
<vbox id="player-container" flex="1"/>
<vbox id="player-overlay" flex="1">
<hbox class="pip-controls" id="top-controls" pack="end">
<button id="close" label="&#x00D7;" tooltiptext="&pictureInPicture.close;"
aria-label="&pictureInPicture.close;" oncommand="window.close();"/>
</hbox>
<spacer flex="1"/>
<vbox class="pip-controls" id="playback-controls">
<html:input id="seek" type="range" min="0" max="1" step="0.1" value="0"
disabled="disabled" aria-label="&pictureInPicture.seek;"
oninput="Player.seek(this.value);" onchange="Player.finishSeeking();"/>
<hbox align="center">
<button id="play" label="&mediaPlay.label;" oncommand="Player.command('playpause');"/>
<button id="mute" label="&mediaMute.label;" oncommand="Player.command('mute');"/>
<label id="time" value="0:00 / 0:00" flex="1" crop="end"/>
<button id="return" label="&pictureInPicture.return;" oncommand="Player.returnToTab();"/>
</hbox>
</vbox>
</vbox>
<resizer id="resize" dir="bottomright" right="0" bottom="0"/>
</stack>
<label id="pause-label" value="&mediaPause.label;" hidden="true"/>
<label id="unmute-label" value="&mediaUnmute.label;" hidden="true"/>
<label id="live-label" value="&pictureInPicture.live;" hidden="true"/>
</window>

View file

@ -12,7 +12,8 @@ var still = null;
var observer = null;
var sourceWindow = null;
var initialized = false;
var events = ["play", "pause", "volumechange", "ended", "seeked", "resize"];
var events = ["play", "pause", "volumechange", "ended", "seeking", "seeked",
"resize", "timeupdate", "durationchange", "progress"];
function update() {
if (!source) {
@ -26,8 +27,40 @@ function update() {
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});
let ranges = source.seekable;
sendAsyncMessage("PictureInPicture:State", {
paused: source.paused || source.ended,
muted: source.muted,
currentTime: source.currentTime,
duration: source.duration,
seekableStart: ranges.length ? ranges.start(0) : 0,
seekableEnd: ranges.length ? ranges.end(ranges.length - 1) : 0,
});
}
function seekTo(time) {
if (typeof time != "number" || !Number.isFinite(time)) {
return;
}
let ranges = source.seekable;
// Live streams may have no seekable range, or a moving DVR window.
// Pick the nearest valid point, including when there are gaps.
let position = null;
let distance = Infinity;
for (let i = 0; i < ranges.length; i++) {
let candidate = Math.max(ranges.start(i), Math.min(time, ranges.end(i)));
if (Math.abs(candidate - time) < distance) {
distance = Math.abs(candidate - time);
position = candidate;
}
}
if (position !== null) {
try {
source.currentTime = position;
} catch (error) {
Components.utils.reportError(error);
}
}
}
function cleanup() {
@ -82,7 +115,11 @@ addMessageListener("PictureInPicture:Init", message => {
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.
// Decoder capture redirects audio to the captured stream. Its samples
// already include the source's volume/mute settings. MediaStream sources
// keep their own audio output, so mute only that case to avoid an echo.
output.muted = !!source.srcObject;
output.volume = 1;
source.mozPictureInPicture = true;
stream = source.mozCaptureStream();
output.srcObject = stream;
@ -124,6 +161,9 @@ addMessageListener("PictureInPicture:Command", message => {
case "mute":
source.muted = !source.muted;
break;
case "seek":
seekTo(message.data.time);
break;
case "close":
cleanup();
break;