File indexing completed on 2024-04-28 05:32:49

0001 /*
0002     Copyright (C) 2017 Kai Uwe Broulik <kde@privat.broulik.de>
0003     Copyright (C) 2018 David Edmundson <davidedmundson@kde.org>
0004 
0005     This program is free software; you can redistribute it and/or
0006     modify it under the terms of the GNU General Public License as
0007     published by the Free Software Foundation; either version 3 of
0008     the License, or (at your option) any later version.
0009 
0010     This program is distributed in the hope that it will be useful,
0011     but WITHOUT ANY WARRANTY; without even the implied warranty of
0012     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
0013     GNU General Public License for more details.
0014 
0015     You should have received a copy of the GNU General Public License
0016     along with this program.  If not, see <http://www.gnu.org/licenses/>.
0017  */
0018 
0019 var callbacks = {};
0020 
0021 function addCallback(subsystem, action, callback)
0022 {
0023     if (!callbacks[subsystem]) {
0024         callbacks[subsystem] = {};
0025     }
0026     callbacks[subsystem][action] = callback;
0027 }
0028 
0029 function initPageScript(cb) {
0030     // On reloads, unload the previous page-script.
0031     executePageAction({"action": "unload"});
0032 
0033     // The script is only run later, wait for that before sending events.
0034     window.addEventListener("pbiInited", cb, {"once": true});
0035 
0036     var element = document.createElement('script');
0037     element.src = chrome.runtime.getURL("page-script.js");
0038     (document.body || document.head || document.documentElement).prepend(element);
0039     // We need to remove the script tag after inserting or else websites relying on the order of items in
0040     // document.getElementsByTagName("script") will break (looking at you, Google Hangouts)
0041     element.parentNode.removeChild(element);
0042 }
0043 
0044 function executePageAction(args) {
0045     // The page script injection and communication mechanism
0046     // was inspired by https://github.com/x0a/uBO-YouTube
0047     if (IS_FIREFOX) {
0048         args = cloneInto(args, window);
0049     }
0050 
0051     window.dispatchEvent(new CustomEvent('pbiEvent', {detail: args}));
0052 }
0053 
0054 chrome.runtime.onMessage.addListener(function (message, sender) {
0055     // TODO do something with sender (check privilige or whatever)
0056 
0057     var subsystem = message.subsystem;
0058     var action = message.action;
0059 
0060     if (!subsystem || !action) {
0061         return;
0062     }
0063 
0064     if (callbacks[subsystem] && callbacks[subsystem][action]) {
0065         callbacks[subsystem][action](message.payload);
0066     }
0067 });
0068 
0069 initPageScript(() => {
0070     SettingsUtils.get().then((items) => {
0071         if (items.breezeScrollBars.enabled) {
0072             loadBreezeScrollBars();
0073         }
0074 
0075         const mpris = items.mpris;
0076         if (mpris.enabled) {
0077             const origin = window.location.origin;
0078 
0079             const websiteSettings = mpris.websiteSettings || {};
0080 
0081             let mprisAllowed = true;
0082             if (typeof MPRIS_WEBSITE_SETTINGS[origin] === "boolean") {
0083                 mprisAllowed = MPRIS_WEBSITE_SETTINGS[origin];
0084             }
0085             if (typeof websiteSettings[origin] === "boolean") {
0086                 mprisAllowed = websiteSettings[origin];
0087             }
0088 
0089             if (mprisAllowed) {
0090                 loadMpris();
0091                 if (items.mprisMediaSessions.enabled) {
0092                     loadMediaSessionsShim();
0093                 }
0094             }
0095         }
0096 
0097         if (items.purpose.enabled) {
0098             sendMessage("settings", "getSubsystemStatus").then((status) => {
0099                 if (status && status.purpose) {
0100                     loadPurpose();
0101                 }
0102             }, (err) => {
0103                 // No warning, can also happen when port isn't connected for unsupported OS
0104                 console.log("Failed to get subsystem status for purpose", err);
0105             });
0106         }
0107     });
0108 });
0109 
0110 // BREEZE SCROLL BARS
0111 // ------------------------------------------------------------------------
0112 //
0113 function loadBreezeScrollBars() {
0114     if (IS_FIREFOX) {
0115         return;
0116     }
0117 
0118     if (!document.head) {
0119         return;
0120     }
0121 
0122     // You cannot access cssRules for <link rel="stylesheet" ..> on a different domain.
0123     // Since our chrome-extension:// URL for a stylesheet would be, this can
0124     // lead to problems in e.g modernizr, so include the <style> inline instead.
0125     // "Failed to read the 'cssRules' property from 'CSSStyleSheet': Cannot access rules"
0126     var styleTag = document.createElement("style");
0127     styleTag.appendChild(document.createTextNode(`
0128 html::-webkit-scrollbar {
0129     /* we'll add padding as "border" in the thumb*/
0130     height: 20px;
0131     width: 20px;
0132     background: white;
0133 }
0134 
0135 html::-webkit-scrollbar-track {
0136     border-radius: 20px;
0137     border: 7px solid white; /* FIXME why doesn't "transparent" work here?! */
0138     background-color: white;
0139     width: 6px !important; /* 20px scrollbar - 2 * 7px border */
0140     box-sizing: content-box;
0141 }
0142 html::-webkit-scrollbar-track:hover {
0143     background-color: #BFC0C2;
0144 }
0145 
0146 html::-webkit-scrollbar-thumb {
0147     background-color: #3DAEE9; /* default blue breeze color */
0148     border: 7px solid transparent;
0149     border-radius: 20px;
0150     background-clip: content-box;
0151     width: 6px !important; /* 20px scrollbar - 2 * 7px border */
0152     box-sizing: content-box;
0153     min-height: 30px;
0154 }
0155 html::-webkit-scrollbar-thumb:window-inactive {
0156    background-color: #949699; /* when window is inactive it's gray */
0157 }
0158 html::-webkit-scrollbar-thumb:hover {
0159     background-color: #93CEE9; /* hovered is a lighter blue */
0160 }
0161 
0162 html::-webkit-scrollbar-corner {
0163     background-color: white; /* FIXME why doesn't "transparent" work here?! */
0164 }
0165     `));
0166 
0167     document.head.appendChild(styleTag);
0168 }
0169 
0170 
0171 // MPRIS
0172 // ------------------------------------------------------------------------
0173 //
0174 
0175 var activePlayer;
0176 // When a player has no duration yet, we'll wait for it becoming known
0177 // to determine whether to ignore it (short sound) or make it active
0178 var pendingActivePlayer;
0179 var playerMetadata = {};
0180 var playerCallbacks = [];
0181 
0182 // Playback state communicated via media sessions api
0183 var playerPlaybackState = "";
0184 
0185 var players = new WeakSet();
0186 
0187 var pendingSeekingUpdate = 0;
0188 
0189 var titleTagObserver = null;
0190 var oldPageTitle = "";
0191 
0192 addCallback("mpris", "play", function () {
0193     playerPlay();
0194 });
0195 
0196 addCallback("mpris", "pause", function () {
0197     playerPause();
0198 });
0199 
0200 addCallback("mpris", "playPause", function () {
0201     if (activePlayer) {
0202         if (activePlayer.paused) { // TODO take into account media sessions playback state
0203             playerPlay();
0204         } else {
0205             playerPause();
0206         }
0207     }
0208 });
0209 
0210 addCallback("mpris", "stop", function () {
0211     // When available, use the "stop" media sessions action
0212     if (playerCallbacks.indexOf("stop") > -1) {
0213         executePageAction({"action": "mpris", "mprisCallbackName": "stop"});
0214         return;
0215     }
0216 
0217     // otherwise since there's no "stop" on the player, simulate it be rewinding and reloading
0218     if (activePlayer) {
0219         activePlayer.pause();
0220         activePlayer.currentTime = 0;
0221         // calling load() now as is suggested in some "how to fake video Stop" code snippets
0222         // utterly breaks stremaing sites
0223         //activePlayer.load();
0224 
0225         // needs to be delayed slightly otherwise we pause(), then send "stopped", and only after that
0226         // the "paused" signal is handled and we end up in Paused instead of Stopped state
0227         setTimeout(function() {
0228             sendMessage("mpris", "stopped");
0229         }, 1);
0230         return;
0231     }
0232 });
0233 
0234 addCallback("mpris", "next", function () {
0235     if (playerCallbacks.indexOf("nexttrack") > -1) {
0236         executePageAction({"action": "mpris", "mprisCallbackName": "nexttrack"});
0237     }
0238 });
0239 
0240 addCallback("mpris", "previous", function () {
0241     if (playerCallbacks.indexOf("previoustrack") > -1) {
0242         executePageAction({"action": "mpris", "mprisCallbackName": "previoustrack"});
0243     }
0244 });
0245 
0246 addCallback("mpris", "setFullscreen", (message) => {
0247     if (activePlayer) {
0248         if (message.fullscreen) {
0249             activePlayer.requestFullscreen();
0250         } else {
0251             document.exitFullscreen();
0252         }
0253     }
0254 });
0255 
0256 addCallback("mpris", "setPosition", function (message) {
0257     if (activePlayer) {
0258         activePlayer.currentTime = message.position;
0259     }
0260 });
0261 
0262 addCallback("mpris", "setPlaybackRate", function (message) {
0263     if (activePlayer) {
0264         activePlayer.playbackRate = message.playbackRate;
0265     }
0266 });
0267 
0268 addCallback("mpris", "setVolume", function (message) {
0269     if (activePlayer) {
0270         activePlayer.volume = message.volume;
0271         activePlayer.muted = (message.volume == 0.0);
0272     }
0273 });
0274 
0275 addCallback("mpris", "setLoop", function (message) {
0276     if (activePlayer) {
0277         activePlayer.loop = message.loop;
0278     }
0279 });
0280 
0281 addCallback("mpris", "identify", function (message) {
0282     if (activePlayer) {
0283         // We don't have a dedicated "send player info" callback, so we instead send a "playing"
0284         // and if we're paused, we'll send a "paused" event right after
0285         // TODO figure out a way how to add this to the host without breaking compat
0286 
0287         var paused = activePlayer.paused;
0288         playerPlaying(activePlayer);
0289         if (paused) {
0290             playerPaused(activePlayer);
0291         }
0292     }
0293 });
0294 
0295 function playerPlaying(player) {
0296     setPlayerActive(player);
0297 }
0298 
0299 function playerPaused(player) {
0300     sendPlayerInfo(player, "paused");
0301 }
0302 
0303 function setPlayerActive(player) {
0304     pendingActivePlayer = player;
0305 
0306     if (isNaN(player.duration)) {
0307         // Ignore this player for now until we know a duration
0308         // In durationchange event handler we'll check for this and end up here again
0309         return;
0310     }
0311 
0312     // Ignore short sounds, they are most likely a chat notification sound
0313     // A stream has a duration of Infinity
0314     // Note that "NaN" is also not finite but we already returned earlier for that
0315     if (isFinite(player.duration) && player.duration > 0 && player.duration < 8) {
0316         return;
0317     }
0318 
0319     pendingActivePlayer = undefined;
0320     activePlayer = player;
0321 
0322     // when playback starts, send along metadata
0323     // a website might have set Media Sessions metadata prior to playing
0324     // and then we would have ignored the metadata signal because there was no player
0325     sendMessage("mpris", "playing", {
0326         mediaSrc: player.currentSrc || player.src,
0327         pageTitle: document.title,
0328         poster: player.poster,
0329         duration: player.duration,
0330         currentTime: player.currentTime,
0331         playbackRate: player.playbackRate,
0332         volume: player.volume,
0333         muted: player.muted,
0334         loop: player.loop,
0335         metadata: playerMetadata,
0336         callbacks: playerCallbacks,
0337         fullscreen: document.fullscreenElement !== null,
0338         canSetFullscreen: player.tagName.toLowerCase() === "video"
0339     });
0340 
0341     if (!titleTagObserver) {
0342 
0343         // Observe changes to the <title> tag in case it is updated after the player has started playing
0344         let titleTag = document.querySelector("head > title");
0345         if (titleTag) {
0346             oldPageTitle = titleTag.innerText;
0347 
0348             titleTagObserver = new MutationObserver((mutations) => {
0349                 mutations.forEach((mutation) => {
0350                     const pageTitle = mutation.target.textContent;
0351                     if (pageTitle && oldPageTitle !== pageTitle) {
0352                         sendMessage("mpris", "titlechange", {
0353                             pageTitle: pageTitle
0354                         });
0355                     }
0356                     oldPageTitle = pageTitle;
0357                 });
0358             });
0359 
0360             titleTagObserver.observe(titleTag, {
0361                 childList: true, // text content is technically a child node
0362                 subtree: true,
0363                 characterData: true
0364             });
0365         }
0366     }
0367 }
0368 
0369 function sendPlayerGone() {
0370     activePlayer = undefined;
0371     pendingActivePlayer = undefined;
0372     playerMetadata = {};
0373     playerCallbacks = [];
0374     sendMessage("mpris", "gone");
0375 
0376     if (titleTagObserver) {
0377         titleTagObserver.disconnect();
0378         titleTagObserver = null;
0379     }
0380 }
0381 
0382 function sendPlayerInfo(player, event, payload) {
0383     if (player != activePlayer) {
0384         return;
0385     }
0386 
0387     sendMessage("mpris", event, payload);
0388 }
0389 
0390 function registerPlayer(player) {
0391     if (players.has(player)) {
0392         //console.log("Already know", player);
0393         return;
0394     }
0395 
0396     // auto-playing player, become active right away
0397     if (!player.paused) {
0398         playerPlaying(player);
0399     }
0400     player.addEventListener("play", function () {
0401         playerPlaying(player);
0402     });
0403 
0404     player.addEventListener("pause", function () {
0405         playerPaused(player);
0406     });
0407 
0408     // what about "stalled" event?
0409     player.addEventListener("waiting", function () {
0410         sendPlayerInfo(player, "waiting");
0411     });
0412 
0413     // playlist is now empty or being reloaded, stop player
0414     // e.g. when using Ajax page navigation and the user nagivated away
0415     player.addEventListener("emptied", function () {
0416         // When the player is emptied but the website tells us it's just "paused"
0417         // keep it around (Bug 402324: Soundcloud does this)
0418         if (player === activePlayer && playerPlaybackState === "paused") {
0419             return;
0420         }
0421 
0422         // could have its own signal but for compat it's easier just to pretend to have stopped
0423         sendPlayerInfo(player, "stopped");
0424     });
0425 
0426     // opposite of "waiting", we finished buffering enough
0427     // only if we are playing, though, should we set playback state back to playing
0428     player.addEventListener("canplay", function () {
0429         if (!player.paused) {
0430             sendPlayerInfo(player, "canplay");
0431         }
0432     });
0433 
0434     player.addEventListener("timeupdate", function () {
0435         sendPlayerInfo(player, "timeupdate", {
0436             currentTime: player.currentTime
0437         });
0438     });
0439 
0440     player.addEventListener("ratechange", function () {
0441         sendPlayerInfo(player, "ratechange", {
0442             playbackRate: player.playbackRate
0443         });
0444     });
0445 
0446     // TODO use player.seekable for determining whether we can seek?
0447     player.addEventListener("durationchange", function () {
0448         // Deferred active due to unknown duration
0449         if (pendingActivePlayer == player) {
0450             setPlayerActive(pendingActivePlayer);
0451             return;
0452         }
0453 
0454         sendPlayerInfo(player, "duration", {
0455             duration: player.duration
0456         });
0457     });
0458 
0459     player.addEventListener("seeking", function () {
0460         if (pendingSeekingUpdate) {
0461             return;
0462         }
0463 
0464         // Compress "seeking" signals, this is invoked continuously as the user drags the slider
0465         pendingSeekingUpdate = setTimeout(function() {
0466             pendingSeekingUpdate = 0;
0467         }, 250);
0468 
0469         sendPlayerInfo(player, "seeking", {
0470             currentTime: player.currentTime
0471         });
0472     });
0473 
0474     player.addEventListener("seeked", function () {
0475         sendPlayerInfo(player, "seeked", {
0476             currentTime: player.currentTime
0477         });
0478     });
0479 
0480     player.addEventListener("volumechange", function () {
0481         sendPlayerInfo(player, "volumechange", {
0482             volume: player.volume,
0483             muted: player.muted
0484         });
0485     });
0486 
0487     players.add(player);
0488 }
0489 
0490 function findAllPlayersFromNode(node) {
0491     if (typeof node.getElementsByTagName !== "function") {
0492         return [];
0493     }
0494 
0495     return [...node.getElementsByTagName("video"), ...node.getElementsByTagName("audio")];
0496 }
0497 
0498 
0499 function registerAllPlayers() {
0500     var players = findAllPlayersFromNode(document);
0501     players.forEach(registerPlayer);
0502 }
0503 
0504 function playerPlay() {
0505     // if a media sessions callback is registered, it takes precedence over us manually messing with the player
0506     if (playerCallbacks.indexOf("play") > -1) {
0507         executePageAction({"action": "mpris", "mprisCallbackName": "play"});
0508     } else if (activePlayer) {
0509         activePlayer.play();
0510     }
0511 }
0512 
0513 function playerPause() {
0514     if (playerCallbacks.indexOf("pause") > -1) {
0515         executePageAction({"action": "mpris", "mprisCallbackName": "pause"});
0516     } else if (activePlayer) {
0517         activePlayer.pause();
0518     }
0519 }
0520 
0521 function loadMpris() {
0522     // TODO figure out somehow when a <video> tag is added dynamically and autoplays
0523     // as can happen on Ajax-heavy pages like YouTube
0524     // could also be done if we just look for the "audio playing in this tab" and only then check for player?
0525     // cf. "checkPlayer" event above
0526 
0527     var observer = new MutationObserver(function (mutations) {
0528         let nodesRemoved = false;
0529         mutations.forEach(function (mutation) {
0530             mutation.addedNodes.forEach(function (node) {
0531                 if (typeof node.matches !== "function") {
0532                     return;
0533                 }
0534 
0535                 // Check whether the node itself or any of its children is a player
0536                 var players = findAllPlayersFromNode(node);
0537                 if (node.matches("video,audio")) {
0538                     players.unshift(node);
0539                 }
0540 
0541                 players.forEach(function (player) {
0542                     registerPlayer(player);
0543                 });
0544             });
0545 
0546             mutation.removedNodes.forEach((node) => {
0547                 if (typeof node.matches !== "function") {
0548                     return;
0549                 }
0550 
0551                 // Check whether the node itself or any of its children is the current player
0552                 const players = findAllPlayersFromNode(node);
0553                 if (node.matches("video,audio")) {
0554                     players.unshift(node);
0555                 }
0556 
0557                 for (let player of players) {
0558                     if (player !== activePlayer) {
0559                         continue;
0560                     }
0561 
0562                     // If the player is still in the visible DOM, don't consider it gone
0563                     if (document.body.contains(player)) {
0564                         continue;
0565                     }
0566 
0567                     // If the player got temporarily added by us, don't consider it gone
0568                     if (player.dataset.pbiPausedForDomRemoval === "true") {
0569                         continue;
0570                     }
0571 
0572                     sendPlayerGone();
0573                     break;
0574                 }
0575             });
0576         });
0577     });
0578 
0579     window.addEventListener("pagehide", function () {
0580         // about to navigate to a different page, tell our extension that the player will be gone shortly
0581         // we listen for tab closed in the extension but we don't for navigating away as URL change doesn't
0582         // neccesarily mean a navigation.
0583         // NOTE beforeunload is not emitted for iframes!
0584         sendPlayerGone();
0585     });
0586 
0587     function documentReady() {
0588         registerAllPlayers();
0589 
0590         observer.observe(document, {
0591             childList: true,
0592             subtree: true
0593         });
0594     }
0595 
0596     // In some cases DOMContentLoaded won't fire, e.g. when watching a video file directly in the browser
0597     // it generates a "video player" page for you but won't fire the event.
0598     // Also, make sure to install the mutation observer if this codepath is executed after the page is already ready.
0599     if (["interactive", "complete"].includes(document.readyState)) {
0600         documentReady();
0601     } else {
0602         registerAllPlayers(); // in case the document isn't ready but the event also doesn't fire...
0603         document.addEventListener("DOMContentLoaded", documentReady);
0604     }
0605 
0606     document.addEventListener("fullscreenchange", () => {
0607         if (activePlayer) {
0608             sendPlayerInfo(activePlayer, "fullscreenchange", {
0609                 fullscreen: document.fullscreenElement !== null
0610             });
0611         }
0612     });
0613 }
0614 
0615 // This adds a shim for the Chrome media sessions API which is currently only supported on Android
0616 // Documentation: https://developers.google.com/web/updates/2017/02/media-session
0617 // Try it here: https://googlechrome.github.io/samples/media-session/video.html
0618 
0619 // Bug 379087: Only inject this stuff if we're a proper HTML page
0620 // otherwise we might end up messing up XML stuff
0621 // only if our documentElement is a "html" tag we'll do it
0622 // the rest is only set up in DOMContentLoaded which is only executed for proper pages anyway
0623 
0624 // tagName always returned "HTML" for me but I wouldn't trust it always being uppercase
0625 function loadMediaSessionsShim() {
0626     if (document.documentElement.tagName.toLowerCase() === "html") {
0627 
0628         window.addEventListener("pbiMprisMessage", (e) => {
0629             let data = e.detail || {};
0630 
0631             let action = data.action;
0632             let payload = data.payload;
0633 
0634             switch (action) {
0635             case "metadata":
0636                 playerMetadata = {};
0637 
0638                 if (typeof payload !== "object") {
0639                     return;
0640                 }
0641 
0642                 playerMetadata = payload;
0643                 sendMessage("mpris", "metadata", payload);
0644 
0645                 return;
0646 
0647             case "playbackState":
0648                 if (!["none", "paused", "playing"].includes(payload)) {
0649                     return;
0650                 }
0651 
0652                 playerPlaybackState = payload;
0653 
0654                 if (!activePlayer) {
0655                     return;
0656                 }
0657 
0658                 if (playerPlaybackState === "playing") {
0659                     playerPlaying(activePlayer);
0660                 } else if (playerPlaybackState === "paused") {
0661                     playerPaused(activePlayer);
0662                 }
0663 
0664                 return;
0665 
0666             case "callbacks":
0667                 if (Array.isArray(payload)) {
0668                     playerCallbacks = payload;
0669                 } else {
0670                     playerCallbacks = [];
0671                 }
0672                 sendMessage("mpris", "callbacks", playerCallbacks);
0673 
0674                 return;
0675             }
0676         });
0677 
0678         executePageAction({"action": "mediaSessionsRegister"});
0679     }
0680 }
0681 
0682 // PURPOSE / WEB SHARE API
0683 // ------------------------------------------------------------------------
0684 //
0685 var purposeLoaded = false;
0686 function loadPurpose() {
0687     if (purposeLoaded) {
0688         return;
0689     }
0690 
0691     purposeLoaded = true;
0692 
0693     // navigator.share must only be defined in secure (https) context
0694     if (!window.isSecureContext) {
0695         return;
0696     }
0697 
0698      window.addEventListener("pbiPurposeMessage", (e) => {
0699         const data = e.detail || {};
0700 
0701         const action = data.action;
0702         const payload = data.payload;
0703 
0704         if (action !== "share") {
0705             return;
0706         }
0707 
0708         sendMessage("purpose", "share", payload).then((response) => {
0709             executePageAction({"action": "purposeShare"});
0710         }, (err) => {
0711             // Deliberately not giving any more details about why it got rejected
0712             executePageAction({"action": "purposeReject"});
0713         }).finally(() => {
0714             executePageAction({"action": "purposeReset"});
0715         });;
0716     });
0717 
0718     executePageAction({"action": "purposeRegister"});
0719 }