File indexing completed on 2024-05-19 13:22:26
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 }