Warning, file /plasma/plasma-browser-integration/extension/content-script.js was not indexed or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).

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     // In some cases DOMContentLoaded won't fire, e.g. when watching a video file directly in the browser
0588     // it generates a "video player" page for you but won't fire the event
0589     registerAllPlayers();
0590 
0591     document.addEventListener("DOMContentLoaded", function() {
0592         registerAllPlayers();
0593 
0594         observer.observe(document, {
0595             childList: true,
0596             subtree: true
0597         });
0598     });
0599 
0600     document.addEventListener("fullscreenchange", () => {
0601         if (activePlayer) {
0602             sendPlayerInfo(activePlayer, "fullscreenchange", {
0603                 fullscreen: document.fullscreenElement !== null
0604             });
0605         }
0606     });
0607 }
0608 
0609 // This adds a shim for the Chrome media sessions API which is currently only supported on Android
0610 // Documentation: https://developers.google.com/web/updates/2017/02/media-session
0611 // Try it here: https://googlechrome.github.io/samples/media-session/video.html
0612 
0613 // Bug 379087: Only inject this stuff if we're a proper HTML page
0614 // otherwise we might end up messing up XML stuff
0615 // only if our documentElement is a "html" tag we'll do it
0616 // the rest is only set up in DOMContentLoaded which is only executed for proper pages anyway
0617 
0618 // tagName always returned "HTML" for me but I wouldn't trust it always being uppercase
0619 function loadMediaSessionsShim() {
0620     if (document.documentElement.tagName.toLowerCase() === "html") {
0621 
0622         window.addEventListener("pbiMprisMessage", (e) => {
0623             let data = e.detail || {};
0624 
0625             let action = data.action;
0626             let payload = data.payload;
0627 
0628             switch (action) {
0629             case "metadata":
0630                 playerMetadata = {};
0631 
0632                 if (typeof payload !== "object") {
0633                     return;
0634                 }
0635 
0636                 playerMetadata = payload;
0637                 sendMessage("mpris", "metadata", payload);
0638 
0639                 return;
0640 
0641             case "playbackState":
0642                 if (!["none", "paused", "playing"].includes(payload)) {
0643                     return;
0644                 }
0645 
0646                 playerPlaybackState = payload;
0647 
0648                 if (!activePlayer) {
0649                     return;
0650                 }
0651 
0652                 if (playerPlaybackState === "playing") {
0653                     playerPlaying(activePlayer);
0654                 } else if (playerPlaybackState === "paused") {
0655                     playerPaused(activePlayer);
0656                 }
0657 
0658                 return;
0659 
0660             case "callbacks":
0661                 if (Array.isArray(payload)) {
0662                     playerCallbacks = payload;
0663                 } else {
0664                     playerCallbacks = [];
0665                 }
0666                 sendMessage("mpris", "callbacks", playerCallbacks);
0667 
0668                 return;
0669             }
0670         });
0671 
0672         executePageAction({"action": "mediaSessionsRegister"});
0673     }
0674 }
0675 
0676 // PURPOSE / WEB SHARE API
0677 // ------------------------------------------------------------------------
0678 //
0679 var purposeLoaded = false;
0680 function loadPurpose() {
0681     if (purposeLoaded) {
0682         return;
0683     }
0684 
0685     purposeLoaded = true;
0686 
0687     // navigator.share must only be defined in secure (https) context
0688     if (!window.isSecureContext) {
0689         return;
0690     }
0691 
0692      window.addEventListener("pbiPurposeMessage", (e) => {
0693         const data = e.detail || {};
0694 
0695         const action = data.action;
0696         const payload = data.payload;
0697 
0698         if (action !== "share") {
0699             return;
0700         }
0701 
0702         sendMessage("purpose", "share", payload).then((response) => {
0703             executePageAction({"action": "purposeShare"});
0704         }, (err) => {
0705             // Deliberately not giving any more details about why it got rejected
0706             executePageAction({"action": "purposeReject"});
0707         }).finally(() => {
0708             executePageAction({"action": "purposeReset"});
0709         });;
0710     });
0711 
0712     executePageAction({"action": "purposeRegister"});
0713 }