update readme
[dotfiles/.git] / .config / BraveSoftware / Brave-Browser / Default / Extensions / cimiefiiaegbelhefglklhhakcgmhkai / 1.7.6_0 / content-script.js
1 /*
2     Copyright (C) 2017 Kai Uwe Broulik <kde@privat.broulik.de>
3     Copyright (C) 2018 David Edmundson <davidedmundson@kde.org>
4
5     This program is free software; you can redistribute it and/or
6     modify it under the terms of the GNU General Public License as
7     published by the Free Software Foundation; either version 3 of
8     the License, or (at your option) any later version.
9
10     This program is distributed in the hope that it will be useful,
11     but WITHOUT ANY WARRANTY; without even the implied warranty of
12     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13     GNU General Public License for more details.
14
15     You should have received a copy of the GNU General Public License
16     along with this program.  If not, see <http://www.gnu.org/licenses/>.
17  */
18
19 var callbacks = {};
20
21 // from https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
22 function generateGuid() {
23     return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
24         const r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
25         return v.toString(16);
26     });
27 }
28
29 function addCallback(subsystem, action, callback)
30 {
31     if (!callbacks[subsystem]) {
32         callbacks[subsystem] = {};
33     }
34     callbacks[subsystem][action] = callback;
35 }
36
37 function executeScript(script) {
38     var element = document.createElement('script');
39     element.innerHTML = '('+ script +')();';
40     (document.body || document.head || document.documentElement).appendChild(element);
41     // We need to remove the script tag after inserting or else websites relying on the order of items in
42     // document.getElementsByTagName("script") will break (looking at you, Google Hangouts)
43     element.parentNode.removeChild(element);
44 }
45
46 chrome.runtime.onMessage.addListener(function (message, sender) {
47     // TODO do something with sender (check privilige or whatever)
48
49     var subsystem = message.subsystem;
50     var action = message.action;
51
52     if (!subsystem || !action) {
53         return;
54     }
55
56     if (callbacks[subsystem] && callbacks[subsystem][action]) {
57         callbacks[subsystem][action](message.payload);
58     }
59 });
60
61 SettingsUtils.get().then((items) => {
62     if (items.breezeScrollBars.enabled) {
63         loadBreezeScrollBars();
64     }
65
66     const mpris = items.mpris;
67     if (mpris.enabled) {
68         const origin = window.location.origin;
69
70         const websiteSettings = mpris.websiteSettings || {};
71
72         let mprisAllowed = true;
73         if (typeof MPRIS_WEBSITE_SETTINGS[origin] === "boolean") {
74             mprisAllowed = MPRIS_WEBSITE_SETTINGS[origin];
75         }
76         if (typeof websiteSettings[origin] === "boolean") {
77             mprisAllowed = websiteSettings[origin];
78         }
79
80         if (mprisAllowed) {
81             loadMpris();
82             if (items.mprisMediaSessions.enabled) {
83                 loadMediaSessionsShim();
84             }
85         }
86     }
87
88     if (items.purpose.enabled) {
89         sendMessage("settings", "getSubsystemStatus").then((status) => {
90             if (status && status.purpose) {
91                 loadPurpose();
92             }
93         }, (err) => {
94             // No warning, can also happen when port isn't connected for unsupported OS
95             console.log("Failed to get subsystem status for purpose", err);
96         });
97     }
98 });
99
100 // BREEZE SCROLL BARS
101 // ------------------------------------------------------------------------
102 //
103 function loadBreezeScrollBars() {
104     if (IS_FIREFOX) {
105         return;
106     }
107
108     if (!document.head) {
109         return;
110     }
111
112     // You cannot access cssRules for <link rel="stylesheet" ..> on a different domain.
113     // Since our chrome-extension:// URL for a stylesheet would be, this can
114     // lead to problems in e.g modernizr, so include the <style> inline instead.
115     // "Failed to read the 'cssRules' property from 'CSSStyleSheet': Cannot access rules"
116     var styleTag = document.createElement("style");
117     styleTag.appendChild(document.createTextNode(`
118 html::-webkit-scrollbar {
119     /* we'll add padding as "border" in the thumb*/
120     height: 20px;
121     width: 20px;
122     background: white;
123 }
124
125 html::-webkit-scrollbar-track {
126     border-radius: 20px;
127     border: 7px solid white; /* FIXME why doesn't "transparent" work here?! */
128     background-color: white;
129     width: 6px !important; /* 20px scrollbar - 2 * 7px border */
130     box-sizing: content-box;
131 }
132 html::-webkit-scrollbar-track:hover {
133     background-color: #BFC0C2;
134 }
135
136 html::-webkit-scrollbar-thumb {
137     background-color: #3DAEE9; /* default blue breeze color */
138     border: 7px solid transparent;
139     border-radius: 20px;
140     background-clip: content-box;
141     width: 6px !important; /* 20px scrollbar - 2 * 7px border */
142     box-sizing: content-box;
143     min-height: 30px;
144 }
145 html::-webkit-scrollbar-thumb:window-inactive {
146    background-color: #949699; /* when window is inactive it's gray */
147 }
148 html::-webkit-scrollbar-thumb:hover {
149     background-color: #93CEE9; /* hovered is a lighter blue */
150 }
151
152 html::-webkit-scrollbar-corner {
153     background-color: white; /* FIXME why doesn't "transparent" work here?! */
154 }
155     `));
156
157     document.head.appendChild(styleTag);
158 }
159
160
161 // MPRIS
162 // ------------------------------------------------------------------------
163 //
164
165 // also give the function a "random" name as we have to have it in global scope to be able
166 // to invoke callbacks from outside, UUID might start with a number, so prepend something
167 const mediaSessionsClassName = "f" + generateGuid().replace(/-/g, "");
168
169 var activePlayer;
170 // When a player has no duration yet, we'll wait for it becoming known
171 // to determine whether to ignore it (short sound) or make it active
172 var pendingActivePlayer;
173 var playerMetadata = {};
174 var playerCallbacks = [];
175
176 // Playback state communicated via media sessions api
177 var playerPlaybackState = "";
178
179 var players = [];
180
181 var pendingSeekingUpdate = 0;
182
183 var titleTagObserver = null;
184 var oldPageTitle = "";
185
186 addCallback("mpris", "play", function () {
187     playerPlay();
188 });
189
190 addCallback("mpris", "pause", function () {
191     playerPause();
192 });
193
194 addCallback("mpris", "playPause", function () {
195     if (activePlayer) {
196         if (activePlayer.paused) { // TODO take into account media sessions playback state
197             playerPlay();
198         } else {
199             playerPause();
200         }
201     }
202 });
203
204 addCallback("mpris", "stop", function () {
205     // When available, use the "stop" media sessions action
206     if (playerCallbacks.indexOf("stop") > -1) {
207         executeScript(`
208             function() {
209                 try {
210                     ${mediaSessionsClassName}.executeCallback("stop");
211                 } catch (e) {
212                     console.warn("Exception executing 'stop' media sessions callback", e);
213                 }
214             }
215         `);
216         return;
217     }
218
219     // otherwise since there's no "stop" on the player, simulate it be rewinding and reloading
220     if (activePlayer) {
221         activePlayer.pause();
222         activePlayer.currentTime = 0;
223         // calling load() now as is suggested in some "how to fake video Stop" code snippets
224         // utterly breaks stremaing sites
225         //activePlayer.load();
226
227         // needs to be delayed slightly otherwise we pause(), then send "stopped", and only after that
228         // the "paused" signal is handled and we end up in Paused instead of Stopped state
229         setTimeout(function() {
230             sendMessage("mpris", "stopped");
231         }, 1);
232         return;
233     }
234 });
235
236 addCallback("mpris", "next", function () {
237     if (playerCallbacks.indexOf("nexttrack") > -1) {
238         executeScript(`
239             function() {
240                 try {
241                     ${mediaSessionsClassName}.executeCallback("nexttrack");
242                 } catch (e) {
243                     console.warn("Exception executing 'nexttrack' media sessions callback", e);
244                 }
245             }
246         `);
247     }
248 });
249
250 addCallback("mpris", "previous", function () {
251     if (playerCallbacks.indexOf("previoustrack") > -1) {
252         executeScript(`
253             function() {
254                 try {
255                     ${mediaSessionsClassName}.executeCallback("previoustrack");
256                 } catch (e) {
257                     console.warn("Exception executing 'previoustrack' media sessions callback", e);
258                 }
259             }
260         `);
261     }
262 });
263
264 addCallback("mpris", "setFullscreen", (message) => {
265     if (activePlayer) {
266         if (message.fullscreen) {
267             activePlayer.requestFullscreen();
268         } else {
269             document.exitFullscreen();
270         }
271     }
272 });
273
274 addCallback("mpris", "setPosition", function (message) {
275     if (activePlayer) {
276         activePlayer.currentTime = message.position;
277     }
278 });
279
280 addCallback("mpris", "setPlaybackRate", function (message) {
281     if (activePlayer) {
282         activePlayer.playbackRate = message.playbackRate;
283     }
284 });
285
286 addCallback("mpris", "setVolume", function (message) {
287     if (activePlayer) {
288         activePlayer.volume = message.volume;
289         activePlayer.muted = (message.volume == 0.0);
290     }
291 });
292
293 addCallback("mpris", "setLoop", function (message) {
294     if (activePlayer) {
295         activePlayer.loop = message.loop;
296     }
297 });
298
299 addCallback("mpris", "identify", function (message) {
300     if (activePlayer) {
301         // We don't have a dedicated "send player info" callback, so we instead send a "playing"
302         // and if we're paused, we'll send a "paused" event right after
303         // TODO figure out a way how to add this to the host without breaking compat
304
305         var paused = activePlayer.paused;
306         playerPlaying(activePlayer);
307         if (paused) {
308             playerPaused(activePlayer);
309         }
310     }
311 });
312
313 function playerPlaying(player) {
314     setPlayerActive(player);
315 }
316
317 function playerPaused(player) {
318     sendPlayerInfo(player, "paused");
319 }
320
321 function setPlayerActive(player) {
322     pendingActivePlayer = player;
323
324     if (isNaN(player.duration)) {
325         // Ignore this player for now until we know a duration
326         // In durationchange event handler we'll check for this and end up here again
327         return;
328     }
329
330     // Ignore short sounds, they are most likely a chat notification sound
331     // A stream has a duration of Infinity
332     // Note that "NaN" is also not finite but we already returned earlier for that
333     if (isFinite(player.duration) && player.duration > 0 && player.duration < 8) {
334         return;
335     }
336
337     pendingActivePlayer = undefined;
338     activePlayer = player;
339
340     // when playback starts, send along metadata
341     // a website might have set Media Sessions metadata prior to playing
342     // and then we would have ignored the metadata signal because there was no player
343     sendMessage("mpris", "playing", {
344         mediaSrc: player.currentSrc || player.src,
345         pageTitle: document.title,
346         poster: player.poster,
347         duration: player.duration,
348         currentTime: player.currentTime,
349         playbackRate: player.playbackRate,
350         volume: player.volume,
351         muted: player.muted,
352         loop: player.loop,
353         metadata: playerMetadata,
354         callbacks: playerCallbacks,
355         fullscreen: document.fullscreenElement !== null,
356         canSetFullscreen: player.tagName.toLowerCase() === "video"
357     });
358
359     if (!titleTagObserver) {
360
361         // Observe changes to the <title> tag in case it is updated after the player has started playing
362         let titleTag = document.querySelector("head > title");
363         if (titleTag) {
364             oldPageTitle = titleTag.innerText;
365
366             titleTagObserver = new MutationObserver((mutations) => {
367                 mutations.forEach((mutation) => {
368                     const pageTitle = mutation.target.textContent;
369                     if (pageTitle && oldPageTitle !== pageTitle) {
370                         sendMessage("mpris", "titlechange", {
371                             pageTitle: pageTitle
372                         });
373                     }
374                     oldPageTitle = pageTitle;
375                 });
376             });
377
378             titleTagObserver.observe(titleTag, {
379                 childList: true, // text content is technically a child node
380                 subtree: true,
381                 characterData: true
382             });
383         }
384     }
385 }
386
387 function sendPlayerGone() {
388     var playerIdx = players.indexOf(activePlayer);
389     if (playerIdx > -1) {
390         players.splice(playerIdx, 1);
391     }
392
393     activePlayer = undefined;
394     pendingActivePlayer = undefined;
395     playerMetadata = {};
396     playerCallbacks = [];
397     sendMessage("mpris", "gone");
398
399     if (titleTagObserver) {
400         titleTagObserver.disconnect();
401         titleTagObserver = null;
402     }
403 }
404
405 function sendPlayerInfo(player, event, payload) {
406     if (player != activePlayer) {
407         return;
408     }
409
410     sendMessage("mpris", event, payload);
411 }
412
413 function registerPlayer(player) {
414     if (players.indexOf(player) > -1) {
415         //console.log("Already know", player);
416         return;
417     }
418
419     // auto-playing player, become active right away
420     if (!player.paused) {
421         playerPlaying(player);
422     }
423     player.addEventListener("play", function () {
424         playerPlaying(player);
425     });
426
427     player.addEventListener("pause", function () {
428         playerPaused(player);
429     });
430
431     // what about "stalled" event?
432     player.addEventListener("waiting", function () {
433         sendPlayerInfo(player, "waiting");
434     });
435
436     // playlist is now empty or being reloaded, stop player
437     // e.g. when using Ajax page navigation and the user nagivated away
438     player.addEventListener("emptied", function () {
439         // When the player is emptied but the website tells us it's just "paused"
440         // keep it around (Bug 402324: Soundcloud does this)
441         if (player === activePlayer && playerPlaybackState === "paused") {
442             return;
443         }
444
445         // could have its own signal but for compat it's easier just to pretend to have stopped
446         sendPlayerInfo(player, "stopped");
447     });
448
449     // opposite of "waiting", we finished buffering enough
450     // only if we are playing, though, should we set playback state back to playing
451     player.addEventListener("canplay", function () {
452         if (!player.paused) {
453             sendPlayerInfo(player, "canplay");
454         }
455     });
456
457     player.addEventListener("timeupdate", function () {
458         sendPlayerInfo(player, "timeupdate", {
459             currentTime: player.currentTime
460         });
461     });
462
463     player.addEventListener("ratechange", function () {
464         sendPlayerInfo(player, "ratechange", {
465             playbackRate: player.playbackRate
466         });
467     });
468
469     // TODO use player.seekable for determining whether we can seek?
470     player.addEventListener("durationchange", function () {
471         // Deferred active due to unknown duration
472         if (pendingActivePlayer == player) {
473             setPlayerActive(pendingActivePlayer);
474             return;
475         }
476
477         sendPlayerInfo(player, "duration", {
478             duration: player.duration
479         });
480     });
481
482     player.addEventListener("seeking", function () {
483         if (pendingSeekingUpdate) {
484             return;
485         }
486
487         // Compress "seeking" signals, this is invoked continuously as the user drags the slider
488         pendingSeekingUpdate = setTimeout(function() {
489             pendingSeekingUpdate = 0;
490         }, 250);
491
492         sendPlayerInfo(player, "seeking", {
493             currentTime: player.currentTime
494         });
495     });
496
497     player.addEventListener("seeked", function () {
498         sendPlayerInfo(player, "seeked", {
499             currentTime: player.currentTime
500         });
501     });
502
503     player.addEventListener("volumechange", function () {
504         sendPlayerInfo(player, "volumechange", {
505             volume: player.volume,
506             muted: player.muted
507         });
508     });
509
510     players.push(player);
511 }
512
513 function findAllPlayersFromNode(node) {
514     if (typeof node.getElementsByTagName !== "function") {
515         return [];
516     }
517
518     return [...node.getElementsByTagName("video"), ...node.getElementsByTagName("audio")];
519 }
520
521
522 function registerAllPlayers() {
523     var players = findAllPlayersFromNode(document);
524     players.forEach(registerPlayer);
525 }
526
527 function playerPlay() {
528     // if a media sessions callback is registered, it takes precedence over us manually messing with the player
529     if (playerCallbacks.indexOf("play") > -1) {
530         executeScript(`
531             function() {
532                 try {
533                     ${mediaSessionsClassName}.executeCallback("play");
534                 } catch (e) {
535                     console.warn("Exception executing 'play' media sessions callback", e);
536                 }
537             }
538         `);
539     } else if (activePlayer) {
540         activePlayer.play();
541     }
542 }
543
544 function playerPause() {
545     if (playerCallbacks.indexOf("pause") > -1) {
546         executeScript(`
547             function() {
548                 try {
549                     ${mediaSessionsClassName}.executeCallback("pause");
550                 } catch (e) {
551                     console.warn("Exception executing 'pause' media sessions callback", e);
552                 }
553             }
554         `);
555     } else if (activePlayer) {
556         activePlayer.pause();
557     }
558 }
559
560 function loadMpris() {
561     // TODO figure out somehow when a <video> tag is added dynamically and autoplays
562     // as can happen on Ajax-heavy pages like YouTube
563     // could also be done if we just look for the "audio playing in this tab" and only then check for player?
564     // cf. "checkPlayer" event above
565
566     var observer = new MutationObserver(function (mutations) {
567         mutations.forEach(function (mutation) {
568             mutation.addedNodes.forEach(function (node) {
569                 if (typeof node.matches !== "function") {
570                     return;
571                 }
572
573                 // Check whether the node itself or any of its children is a player
574                 var players = findAllPlayersFromNode(node);
575                 if (node.matches("video,audio")) {
576                     players.unshift(node);
577                 }
578
579                 players.forEach(function (player) {
580                     registerPlayer(player);
581                 });
582             });
583
584             mutation.removedNodes.forEach(function (node) {
585                 if (typeof node.matches !== "function") {
586                     return;
587                 }
588
589                 // Check whether the node itself or any of its children is the current player
590                 var players = findAllPlayersFromNode(node);
591                 if (node.matches("video,audio")) {
592                     players.unshift(node);
593                 }
594
595                 players.forEach(function (player) {
596                     if (player == activePlayer) {
597                         // If the player is still in the visible DOM, don't consider it gone
598                         if (document.body.contains(player)) {
599                             return; // continue
600                         }
601
602                         // If the player got temporarily added by us, don't consider it gone
603                         if (player.dataset.pbiPausedForDomRemoval === "true") {
604                             return;
605                         }
606
607                         sendPlayerGone();
608                         return;
609                     }
610                 });
611             });
612         });
613     });
614
615     window.addEventListener("pagehide", function () {
616         // about to navigate to a different page, tell our extension that the player will be gone shortly
617         // we listen for tab closed in the extension but we don't for navigating away as URL change doesn't
618         // neccesarily mean a navigation.
619         // NOTE beforeunload is not emitted for iframes!
620         sendPlayerGone();
621     });
622
623     // In some cases DOMContentLoaded won't fire, e.g. when watching a video file directly in the browser
624     // it generates a "video player" page for you but won't fire the event
625     registerAllPlayers();
626
627     document.addEventListener("DOMContentLoaded", function() {
628         registerAllPlayers();
629
630         observer.observe(document, {
631             childList: true,
632             subtree: true
633         });
634     });
635
636     document.addEventListener("fullscreenchange", () => {
637         if (activePlayer) {
638             sendPlayerInfo(activePlayer, "fullscreenchange", {
639                 fullscreen: document.fullscreenElement !== null
640             });
641         }
642     });
643 }
644
645 // This adds a shim for the Chrome media sessions API which is currently only supported on Android
646 // Documentation: https://developers.google.com/web/updates/2017/02/media-session
647 // Try it here: https://googlechrome.github.io/samples/media-session/video.html
648
649 // Bug 379087: Only inject this stuff if we're a proper HTML page
650 // otherwise we might end up messing up XML stuff
651 // only if our documentElement is a "html" tag we'll do it
652 // the rest is only set up in DOMContentLoaded which is only executed for proper pages anyway
653
654 // tagName always returned "HTML" for me but I wouldn't trust it always being uppercase
655 function loadMediaSessionsShim() {
656     if (document.documentElement.tagName.toLowerCase() === "html") {
657
658         window.addEventListener("pbiMprisMessage", (e) => {
659             let data = e.detail || {};
660
661             let action = data.action;
662             let payload = data.payload;
663
664             switch (action) {
665             case "metadata":
666                 playerMetadata = {};
667
668                 if (typeof payload !== "object") {
669                     return;
670                 }
671
672                 playerMetadata = payload;
673                 sendMessage("mpris", "metadata", payload);
674
675                 return;
676
677             case "playbackState":
678                 if (!["none", "paused", "playing"].includes(payload)) {
679                     return;
680                 }
681
682                 playerPlaybackState = payload;
683
684                 if (!activePlayer) {
685                     return;
686                 }
687
688                 if (playerPlaybackState === "playing") {
689                     playerPlaying(activePlayer);
690                 } else if (playerPlaybackState === "paused") {
691                     playerPaused(activePlayer);
692                 }
693
694                 return;
695
696             case "callbacks":
697                 if (Array.isArray(payload)) {
698                     playerCallbacks = payload;
699                 } else {
700                     playerCallbacks = [];
701                 }
702                 sendMessage("mpris", "callbacks", playerCallbacks);
703
704                 return;
705             }
706         });
707
708         executeScript(`
709             function() {
710                 ${mediaSessionsClassName}_constructor = function() {
711                     this.callbacks = {};
712                     this.pendingCallbacksUpdate = 0;
713                     this.metadata = null;
714                     this.playbackState = "none";
715
716                     this.sendMessage = function (action, payload) {
717                         let event = new CustomEvent("pbiMprisMessage", {
718                             detail: {
719                                 action: action,
720                                 payload: payload
721                             }
722                         });
723                         window.dispatchEvent(event);
724                     };
725
726                     this.executeCallback = function (action) {
727                         let details = {
728                             action: action
729                             // for seekforward, seekbackward, seekto there's additional information one would need to add
730                         };
731                         this.callbacks[action](details);
732                     };
733
734                     this.setCallback = function (name, cb) {
735                         const oldCallbacks = Object.keys(this.callbacks).sort();
736
737                         if (cb) {
738                             this.callbacks[name] = cb;
739                         } else {
740                             delete this.callbacks[name];
741                         }
742
743                         const newCallbacks = Object.keys(this.callbacks).sort();
744
745                         if (oldCallbacks.toString() === newCallbacks.toString()) {
746                             return;
747                         }
748
749                         if (this.pendingCallbacksUpdate) {
750                             return;
751                         }
752
753                         this.pendingCallbacksUpdate = setTimeout(() => {
754                             this.pendingCallbacksUpdate = 0;
755
756                             // Make sure to send the current callbacks, not "newCallbacks" at the time of starting the timeout
757                             const callbacks = Object.keys(this.callbacks);
758                             this.sendMessage("callbacks", callbacks);
759                         }, 0);
760                     };
761
762                     this.setMetadata = function (metadata) {
763                         // MediaMetadata is not a regular Object so we cannot just JSON.stringify it
764                         let newMetadata = {};
765
766                         let dirty = (!metadata != !this.metadata);
767                         if (metadata) {
768                             const keys = Object.getOwnPropertyNames(Object.getPrototypeOf(metadata));
769
770                             const oldMetadata = this.metadata || {};
771
772                             keys.forEach((key) => {
773                                 const value = metadata[key];
774                                 if (!value || typeof value === "function") {
775                                     return; // continue
776                                 }
777
778                                 // We only have Strings or the "artwork" Array, so a toString() comparison should suffice...
779                                 dirty |= (value.toString() !== (oldMetadata[key] || "").toString());
780
781                                 newMetadata[key] = value;
782                             });
783                         }
784
785                         this.metadata = metadata;
786
787                         if (dirty) {
788                             this.sendMessage("metadata", newMetadata);
789                         }
790                     };
791
792                     this.setPlaybackState = function (playbackState) {
793                         if (this.playbackState === playbackState) {
794                             return;
795                         }
796
797                         this.playbackState = playbackState;
798                         this.sendMessage("playbackState", playbackState);
799                     };
800                 };
801
802                 ${mediaSessionsClassName} = new ${mediaSessionsClassName}_constructor();
803
804                 if (!navigator.mediaSession) {
805                     navigator.mediaSession = {};
806                 }
807
808                 var noop = function() { };
809
810                 var oldSetActionHandler = navigator.mediaSession.setActionHandler || noop;
811                 navigator.mediaSession.setActionHandler = function (name, cb) {
812                     ${mediaSessionsClassName}.setCallback(name, cb);
813
814                     // Call the original native implementation
815                     // "call()" is needed as the real setActionHandler is a class member
816                     // and calling it directly is illegal as it lacks the context
817                     // This may throw for unsupported actions but we registered the callback
818                     // ourselves before
819                     return oldSetActionHandler.call(navigator.mediaSession, name, cb);
820                 };
821
822                 Object.defineProperty(navigator.mediaSession, "metadata", {
823                     get: () => ${mediaSessionsClassName}.metadata,
824                     set: (newValue) => {
825                         ${mediaSessionsClassName}.setMetadata(newValue);
826                     }
827                 });
828                 Object.defineProperty(navigator.mediaSession, "playbackState", {
829                     get: () => ${mediaSessionsClassName}.playbackState,
830                     set: (newValue) => {
831                         ${mediaSessionsClassName}.setPlaybackState(newValue);
832                     }
833                 });
834
835                 if (!window.MediaMetadata) {
836                     window.MediaMetadata = function (data) {
837                         Object.assign(this, data);
838                     };
839                     window.MediaMetadata.prototype.title = "";
840                     window.MediaMetadata.prototype.artist = "";
841                     window.MediaMetadata.prototype.album = "";
842                     window.MediaMetadata.prototype.artwork = [];
843                 }
844             }
845         `);
846
847         // here we replace the document.createElement function with our own so we can detect
848         // when an <audio> tag is created that is not added to the DOM which most pages do
849         // while a <video> tag typically ends up being displayed to the user, audio is not.
850         // HACK We cannot really pass variables from the page's scope to our content-script's scope
851         // so we just blatantly insert the <audio> tag in the DOM and pick it up through our regular
852         // mechanism. Let's see how this goes :D
853
854         // HACK When removing a media object from DOM it is paused, so what we do here is once the
855         // player loaded some data we add it (doesn't work earlier since it cannot pause when
856         // there's nothing loaded to pause) to the DOM and before we remove it, we note down that
857         // we will now get a paused event because of that. When we get it, we just play() the player
858         // so it continues playing :-)
859         const addPlayerToDomEvadingAutoPlayBlocking = `
860             player.registerInDom = () => {
861                 // Needs to be dataset so it's accessible from mutation observer on webpage
862                 player.dataset.pbiPausedForDomRemoval = "true";
863                 player.removeEventListener("play", player.registerInDom);
864
865                 // If it is already in DOM by the time it starts playing, we don't need to do anything
866                 if (document.body && document.body.contains(player)) {
867                     delete player.dataset.pbiPausedForDomRemoval;
868                     player.removeEventListener("pause", player.replayAfterRemoval);
869                 } else {
870                     (document.head || document.documentElement).appendChild(player);
871                     player.parentNode.removeChild(player);
872                 }
873             };
874
875             player.replayAfterRemoval = () => {
876                 if (player.dataset.pbiPausedForDomRemoval === "true") {
877                     delete player.dataset.pbiPausedForDomRemoval;
878                     player.removeEventListener("pause", player.replyAfterRemoval);
879
880                     player.play();
881                 }
882             };
883
884             player.addEventListener("play", player.registerInDom);
885             player.addEventListener("pause", player.replayAfterRemoval);
886         `;
887
888         const handleCreateElement = `
889             const tagName = arguments[0];
890
891             if (typeof tagName === "string") {
892                 if (tagName.toLowerCase() === "audio") {
893                     const player = createdTag;
894                     ${addPlayerToDomEvadingAutoPlayBlocking}
895                 } else if (tagName.toLowerCase() === "video") {
896                     (document.head || document.documentElement).appendChild(createdTag);
897                     createdTag.parentNode.removeChild(createdTag);
898                 }
899             }
900         `;
901
902         if (IS_FIREFOX) {
903             const oldCreateElement = Document.prototype.createElement;
904             exportFunction(function() {
905                 const createdTag = oldCreateElement.apply(this, arguments);
906                 eval(handleCreateElement);
907                 return createdTag;
908             }, Document.prototype, {defineAs: "createElement"});
909         } else {
910             executeScript(`
911                 function() {
912                     const oldCreateElement = Document.prototype.createElement;
913                     Document.prototype.createElement = function() {
914                         const createdTag = oldCreateElement.apply(this, arguments);
915                         ${handleCreateElement}
916                         return createdTag;
917                     };
918                 }
919             `);
920         }
921
922         // We also briefly add items created as new Audio() to the DOM so we can control it
923         // similar to the document.createElement hack above since we cannot share variables
924         // between the actual website and the background script despite them sharing the same DOM
925
926         if (IS_FIREFOX) {
927             // Firefox enforces Content-Security-Policy also for scripts injected by the content-script
928             // This causes our executeScript calls to fail for pages like Nextcloud
929             // It also doesn't seem to have the aggressive autoplay prevention Chrome has,
930             // so the horrible replyAfterRemoval hack from above isn't copied into this
931             // See Bug 411148: Music playing from the ownCloud Music app does not show up
932
933             // A function exported with exportFunction loses its prototype, leading to Bug 414512
934
935             const oldAudio = window.Audio;
936             // It is important to use the prototype on wrappedJSObject as the prototype
937             // of the content script is restricted and not accessible by the website
938             const oldAudioPrototype = window.wrappedJSObject.Audio.prototype;
939
940             const audioConstructor = function(...args) {
941                 const player = new oldAudio(...args);
942                 eval(addPlayerToDomEvadingAutoPlayBlocking);
943                 return player;
944             };
945             exportFunction(audioConstructor, window.wrappedJSObject, {defineAs: "Audio"});
946
947             window.wrappedJSObject.Audio.prototype = oldAudioPrototype;
948         } else {
949             executeScript(`function() {
950                 var oldAudio = window.Audio;
951                 window.Audio = function (...args) {
952                     const player = new oldAudio(...args);
953                     ${addPlayerToDomEvadingAutoPlayBlocking}
954                     return player;
955                 };
956             }`);
957         }
958     }
959 }
960
961 // PURPOSE / WEB SHARE API
962 // ------------------------------------------------------------------------
963 //
964 const purposeTransferClassName = "p" + generateGuid().replace(/-/g, "");
965
966 var purposeLoaded = false;
967 function loadPurpose() {
968     if (purposeLoaded) {
969         return;
970     }
971
972     purposeLoaded = true;
973
974     // navigator.share must only be defined in secure (https) context
975     if (!window.isSecureContext) {
976         return;
977     }
978
979      window.addEventListener("pbiPurposeMessage", (e) => {
980         const data = e.detail || {};
981
982         const action = data.action;
983         const payload = data.payload;
984
985         if (action !== "share") {
986             return;
987         }
988
989         sendMessage("purpose", "share", payload).then((response) => {
990             executeScript(`
991                 function() {
992                     ${purposeTransferClassName}.pendingResolve();
993                 }
994             `);
995         }, (err) => {
996             // Deliberately not giving any more details about why it got rejected
997             executeScript(`
998                 function() {
999                     ${purposeTransferClassName}.pendingReject(new DOMException("Share request aborted", "AbortError"));
1000                 }
1001             `);
1002         }).finally(() => {
1003             executeScript(`
1004                 function() {
1005                     ${purposeTransferClassName}.reset();
1006                 }
1007             `);
1008         });;
1009     });
1010
1011     executeScript(`
1012         function() {
1013             ${purposeTransferClassName} = function() {};
1014             let transfer = ${purposeTransferClassName};
1015             transfer.reset = () => {
1016                 transfer.pendingResolve = null;
1017                 transfer.pendingReject = null;
1018             };
1019             transfer.reset();
1020
1021             if (!navigator.canShare) {
1022                 navigator.canShare = (data) => {
1023                     if (!data) {
1024                         return false;
1025                     }
1026
1027                     if (data.title === undefined && data.text === undefined && data.url === undefined) {
1028                         return false;
1029                     }
1030
1031                     if (data.url) {
1032                         // check if URL is valid
1033                         try {
1034                             new URL(data.url, document.location.href);
1035                         } catch (e) {
1036                             return false;
1037                         }
1038                     }
1039
1040                     return true;
1041                 }
1042             }
1043
1044             if (!navigator.share) {
1045                 navigator.share = (data) => {
1046                     return new Promise((resolve, reject) => {
1047                         if (!navigator.canShare(data)) {
1048                             return reject(new TypeError());
1049                         }
1050
1051                         if (data.url) {
1052                             // validity already checked in canShare, hence no catch
1053                             data.url = new URL(data.url, document.location.href).toString();
1054                         }
1055
1056                         if (!window.event || !window.event.isTrusted) {
1057                             return reject(new DOMException("navigator.share can only be called in response to user interaction", "NotAllowedError"));
1058                         }
1059
1060                         if (transfer.pendingResolve || transfer.pendingReject) {
1061                             return reject(new DOMException("A share is already in progress", "AbortError"));
1062                         }
1063
1064                         transfer.pendingResolve = resolve;
1065                         transfer.pendingReject = reject;
1066
1067                         const event = new CustomEvent("pbiPurposeMessage", {
1068                             detail: {
1069                                 action: "share",
1070                                 payload: data
1071                             }
1072                         });
1073                         window.dispatchEvent(event);
1074                     });
1075                 };
1076             }
1077         }
1078     `);
1079 }