410 lines
13 KiB
JavaScript
410 lines
13 KiB
JavaScript
/*
|
|
Copyright (C) 2017-2019 Kai Uwe Broulik <kde@privat.broulik.de>
|
|
|
|
This program is free software; you can redistribute it and/or
|
|
modify it under the terms of the GNU General Public License as
|
|
published by the Free Software Foundation; either version 3 of
|
|
the License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
let playerIds = [];
|
|
let supportedImageMimeTypes = [];
|
|
|
|
function currentPlayer() {
|
|
let playerId = playerIds[playerIds.length - 1];
|
|
if (!playerId) {
|
|
// Returning empty object instead of null so you can call player.id returning undefined instead of throwing
|
|
return {};
|
|
}
|
|
|
|
let segments = playerId.split("-");
|
|
return {
|
|
id: playerId,
|
|
tabId: parseInt(segments[0]),
|
|
frameId: parseInt(segments[1])
|
|
};
|
|
}
|
|
|
|
function playerIdFromSender(sender) {
|
|
return sender.tab.id + "-" + (sender.frameId || 0);
|
|
}
|
|
|
|
function playersOnTab(tabId) {
|
|
return playerIds.filter((playerId) => {
|
|
return playerId.startsWith(tabId + "-");
|
|
});
|
|
}
|
|
|
|
function sendPlayerTabMessage(player, action, payload) {
|
|
if (!player) {
|
|
return;
|
|
}
|
|
|
|
let message = {
|
|
subsystem: "mpris",
|
|
action: action
|
|
};
|
|
if (payload) {
|
|
message.payload = payload;
|
|
}
|
|
|
|
chrome.tabs.sendMessage(player.tabId, message, {
|
|
frameId: player.frameId
|
|
}, (resp) => {
|
|
const error = chrome.runtime.lastError;
|
|
// When player tab crashed, we get this error message.
|
|
// There's unfortunately no proper signal for this so we can really only know when we try to send a command
|
|
if (error && error.message === "Could not establish connection. Receiving end does not exist.") {
|
|
console.warn("Failed to send player command to tab", player.tabId, ", signalling player gone");
|
|
playerTabGone(player.tabId);
|
|
}
|
|
});
|
|
}
|
|
|
|
function playerTabGone(tabId) {
|
|
let players = playerIds;
|
|
players.forEach((playerId) => {
|
|
if (playerId.startsWith(tabId + "-")) {
|
|
playerGone(playerId);
|
|
}
|
|
});
|
|
}
|
|
|
|
function playerGone(playerId) {
|
|
let oldPlayer = currentPlayer();
|
|
|
|
var removedPlayerIdx = playerIds.indexOf(playerId);
|
|
if (removedPlayerIdx > -1) {
|
|
playerIds.splice(removedPlayerIdx, 1); // remove that player from the array
|
|
}
|
|
|
|
// If there is no more player on this tab, remove badge
|
|
const gonePlayerTabId = Number(playerId.split("-")[0]);
|
|
if (playersOnTab(gonePlayerTabId).length === 0) {
|
|
// Check whether that tab still exists before trying to clear the badge
|
|
chrome.tabs.get(gonePlayerTabId, (tab) => {
|
|
if (chrome.runtime.lastError /*silence error*/ || !tab) {
|
|
return;
|
|
}
|
|
|
|
chrome.action.setBadgeText({
|
|
text: "",
|
|
tabId: gonePlayerTabId // important to pass it as number!
|
|
});
|
|
|
|
try {
|
|
// Important to clear the color, too, so it reverts back to global badge setting
|
|
chrome.action.setBadgeBackgroundColor({
|
|
color: null,
|
|
tabId: gonePlayerTabId
|
|
});
|
|
} catch (e) {
|
|
// Silence warning about missing 'text' and 'color' in Chrome
|
|
}
|
|
});
|
|
}
|
|
|
|
let newPlayer = currentPlayer();
|
|
|
|
if (oldPlayer.id === newPlayer.id) {
|
|
return;
|
|
}
|
|
|
|
// all players gone :(
|
|
if (!newPlayer.id) {
|
|
sendPortMessage("mpris", "gone");
|
|
return;
|
|
}
|
|
|
|
// ask the now current player to identify to us
|
|
// we can't just pretend "playing" as the other player might be paused
|
|
sendPlayerTabMessage(newPlayer, "identify");
|
|
}
|
|
|
|
function hostSupportsFetchedArtwork() {
|
|
return supportedImageMimeTypes.length > 0;
|
|
}
|
|
|
|
function fetchPlayerArtwork(metadata, poster) {
|
|
let artworkUrl = "";
|
|
let artworkMimeType = "";
|
|
|
|
const player = currentPlayer();
|
|
if (!player.id) {
|
|
return artworkUrl;
|
|
}
|
|
|
|
if (metadata) {
|
|
const artwork = metadata.artwork || [];
|
|
// Basically MPrisPlugin::processMetadata.
|
|
let biggest = null;
|
|
for (let item of artwork) {
|
|
if (!item.src) {
|
|
continue;
|
|
}
|
|
|
|
if (item.type && !supportedImageMimeTypes.includes(item.type)) {
|
|
console.log("Not supported mime", item.type, "of", item.src);
|
|
continue;
|
|
}
|
|
|
|
if (item.sizes === "any") {
|
|
artworkUrl = item.src;
|
|
artworkMimeType = item.type;
|
|
break;
|
|
}
|
|
|
|
// "sizes" is a space-separated list of sizes, for some reason.
|
|
let sizes = (item.sizes || "").toLowerCase().split(" ");
|
|
for (let size of sizes) {
|
|
const sizeParts = size.split("x");
|
|
|
|
let actualSize = {width: NaN, height: NaN};
|
|
if (sizeParts.length == 2) {
|
|
actualSize.width = parseInt(sizeParts[0], 10);
|
|
actualSize.height = parseInt(sizeParts[1], 10);
|
|
}
|
|
|
|
if (biggest === null || (actualSize.width >= biggest.width && actualSize.height >= biggest.height)) {
|
|
artworkUrl = item.src;
|
|
artworkMimeType = item.type;
|
|
biggest = {width: actualSize.width, height: actualSize.height};
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
if (!artworkUrl) {
|
|
artworkUrl = poster || "";
|
|
}
|
|
|
|
let payload = {src: artworkUrl};
|
|
|
|
if (!artworkUrl) {
|
|
// Tell the browser that there's nothing more to wait for.
|
|
sendPortMessage("mpris", "artwork", payload);
|
|
return artworkUrl;
|
|
}
|
|
|
|
fetch(artworkUrl).then((response) => {
|
|
// Other player is current by now.
|
|
if (currentPlayer().id !== player.id) {
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
sendPortMessage("mpris", "artwork", payload);
|
|
return;
|
|
}
|
|
|
|
response.blob().then((blob) => {
|
|
let reader = new FileReader();
|
|
reader.onloadend = function() {
|
|
if (currentPlayer().id === player.id) {
|
|
payload.dataUrl = reader.result;
|
|
payload.mimeType = artworkMimeType;
|
|
|
|
sendPortMessage("mpris", "artwork", payload);
|
|
}
|
|
}
|
|
reader.readAsDataURL(blob);
|
|
}, (err) => {
|
|
console.warn("Failed to read response of", artworkUrl, "as blob", err);
|
|
if (currentPlayer().id === player.id) {
|
|
sendPortMessage("mpris", "artwork", payload);
|
|
}
|
|
});
|
|
|
|
}, (err) => {
|
|
console.warn("Failed to get artwork from", artworkUrl, err);
|
|
if (currentPlayer().id === player.id) {
|
|
sendPortMessage("mpris", "artwork", payload);
|
|
}
|
|
});
|
|
|
|
return artworkUrl;
|
|
}
|
|
|
|
// when tab is closed, tell the player is gone
|
|
// below we also have a "gone" signal listener from the content script
|
|
// which is invoked in the pagehide handler of the page
|
|
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
// Since we only get the tab id, search for all players from this tab and signal a "gone"
|
|
playerTabGone(tabId);
|
|
});
|
|
|
|
// There's no signal for when a tab process crashes (only in browser dev builds).
|
|
// We watch for the tab becoming inaudible and check if it's still around.
|
|
// With this heuristic we can at least mitigate MPRIS remaining stuck in a playing state.
|
|
chrome.tabs.onUpdated.addListener((tabId, changes) => {
|
|
if (!changes.hasOwnProperty("audible") || changes.audible === true) {
|
|
return;
|
|
}
|
|
|
|
// Now check if the tab is actually gone
|
|
chrome.scripting.executeScript({
|
|
target: {
|
|
tabId: tabId
|
|
},
|
|
func: () => {
|
|
return true;
|
|
}
|
|
}, (result) => {
|
|
const error = chrome.runtime.lastError;
|
|
if (error) {
|
|
// Chrome error in script_executor.cc "kRendererDestroyed"
|
|
if (error.message === "The tab was closed."
|
|
// chrome.scripting API with Manifest v3 gives this non-descript error.
|
|
|| error.message === "Cannot access contents of the page. Extension manifest must request permission to access the respective host.") {
|
|
console.warn("Player tab", tabId, "became inaudible and was considered crashed, signalling player gone");
|
|
playerTabGone(tabId);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// callbacks from host (Plasma) to our extension
|
|
addCallback("mpris", "supportedImageMimeTypes", (message) => {
|
|
supportedImageMimeTypes = message.mimeTypes;
|
|
});
|
|
|
|
addCallback("mpris", "raise", function (message) {
|
|
let player = currentPlayer();
|
|
if (player.tabId) {
|
|
raiseTab(player.tabId);
|
|
}
|
|
});
|
|
|
|
addCallback("mpris", ["play", "pause", "playPause", "stop", "next", "previous"], function (message, action) {
|
|
sendPlayerTabMessage(currentPlayer(), action);
|
|
});
|
|
|
|
addCallback("mpris", "setFullscreen", (message) => {
|
|
sendPlayerTabMessage(currentPlayer(), "setFullscreen", {
|
|
fullscreen: message.fullscreen
|
|
});
|
|
});
|
|
|
|
addCallback("mpris", "setVolume", function (message) {
|
|
sendPlayerTabMessage(currentPlayer(), "setVolume", {
|
|
volume: message.volume
|
|
});
|
|
});
|
|
|
|
addCallback("mpris", "setLoop", function (message) {
|
|
sendPlayerTabMessage(currentPlayer(), "setLoop", {
|
|
loop: message.loop
|
|
});
|
|
});
|
|
|
|
addCallback("mpris", "setPosition", function (message) {
|
|
sendPlayerTabMessage(currentPlayer(), "setPosition", {
|
|
position: message.position
|
|
});
|
|
})
|
|
|
|
addCallback("mpris", "setPlaybackRate", function (message) {
|
|
sendPlayerTabMessage(currentPlayer(), "setPlaybackRate", {
|
|
playbackRate: message.playbackRate
|
|
});
|
|
});
|
|
|
|
// callbacks from a browser tab to our extension
|
|
addRuntimeCallback("mpris", "playing", function (message, sender) {
|
|
// Before Firefox 67 it ran extensions in incognito mode by default.
|
|
// However, after the update the extension keeps running in incognito mode.
|
|
// So we keep disabling media controls for them to prevent accidental private
|
|
// information leak on lock screen or now playing auto status in a messenger
|
|
if (IS_FIREFOX && sender.tab.incognito) {
|
|
return;
|
|
}
|
|
|
|
let playerId = playerIdFromSender(sender);
|
|
|
|
let idx = playerIds.indexOf(playerId);
|
|
if (idx > -1) {
|
|
// Move it to the end of the list so it becomes current
|
|
playerIds.push(playerIds.splice(idx, 1)[0]);
|
|
} else {
|
|
playerIds.push(playerId);
|
|
}
|
|
|
|
var payload = message || {};
|
|
payload.tabTitle = sender.tab.title;
|
|
payload.url = sender.tab.url;
|
|
|
|
if (hostSupportsFetchedArtwork()) {
|
|
payload.pendingArtwork = fetchPlayerArtwork(payload.metadata, payload.poster);
|
|
}
|
|
|
|
sendPortMessage("mpris", "playing", payload);
|
|
|
|
// Add toolbar icon to make it obvious you now have controls to disable the player
|
|
chrome.action.setBadgeText({
|
|
text: "♪",
|
|
tabId: sender.tab.id
|
|
});
|
|
chrome.action.setBadgeBackgroundColor({
|
|
color: "#1d99f3", // Breeze "highlight" color
|
|
tabId: sender.tab.id
|
|
});
|
|
});
|
|
|
|
addRuntimeCallback("mpris", "gone", function (message, sender) {
|
|
playerGone(playerIdFromSender(sender));
|
|
});
|
|
|
|
addRuntimeCallback("mpris", "stopped", function (message, sender) {
|
|
// When player stopped, check if there's another one we could control now instead
|
|
let playerId = playerIdFromSender(sender);
|
|
if (currentPlayer().id === playerId) {
|
|
if (playerIds.length > 1) {
|
|
playerGone(playerId);
|
|
}
|
|
}
|
|
});
|
|
|
|
addRuntimeCallback("mpris", ["paused", "waiting", "canplay"], function (message, sender, action) {
|
|
if (currentPlayer().id === playerIdFromSender(sender)) {
|
|
sendPortMessage("mpris", action);
|
|
}
|
|
});
|
|
|
|
addRuntimeCallback("mpris", ["duration", "timeupdate", "seeking", "seeked", "ratechange", "volumechange", "titlechange", "fullscreenchange"], function (message, sender, action) {
|
|
if (currentPlayer().id === playerIdFromSender(sender)) {
|
|
sendPortMessage("mpris", action, message);
|
|
}
|
|
});
|
|
|
|
addRuntimeCallback("mpris", "metadata", function (message, sender) {
|
|
if (currentPlayer().id === playerIdFromSender(sender)) {
|
|
let payload = {
|
|
metadata: message
|
|
};
|
|
if (hostSupportsFetchedArtwork()) {
|
|
payload.pendingArtwork = fetchPlayerArtwork(payload, "");
|
|
}
|
|
|
|
sendPortMessage("mpris", "metadata", payload);
|
|
}
|
|
});
|
|
|
|
addRuntimeCallback("mpris", "callbacks", function (message, sender) {
|
|
if (currentPlayer().id === playerIdFromSender(sender)) {
|
|
sendPortMessage("mpris", "callbacks", {callbacks: message});
|
|
}
|
|
});
|
|
|
|
addRuntimeCallback("mpris", "hasTabPlayer", (message) => {
|
|
return Promise.resolve(playersOnTab(message.tabId));
|
|
});
|