BetterYTM.user.js 142 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502
  1. // ==UserScript==
  2. // @name BetterYTM
  3. // @namespace https://github.com/Sv443/BetterYTM
  4. // @version 1.0.0
  5. // @description Configurable layout and UX improvements for YouTube Music
  6. // @description:de Konfigurierbares Layout und UX-Verbesserungen für YouTube Music
  7. // @homepageURL https://github.com/Sv443/BetterYTM#readme
  8. // @supportURL https://github.com/Sv443/BetterYTM/issues
  9. // @license MIT
  10. // @author Sv443
  11. // @copyright Sv443 (https://github.com/Sv443)
  12. // @icon https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/icon/icon_48.png
  13. // @match https://music.youtube.com/*
  14. // @match https://www.youtube.com/*
  15. // @run-at document-start
  16. // @downloadURL https://raw.githubusercontent.com/Sv443/BetterYTM/main/dist/BetterYTM.user.js
  17. // @updateURL https://raw.githubusercontent.com/Sv443/BetterYTM/main/dist/BetterYTM.user.js
  18. // @connect api.sv443.net
  19. // @grant GM.getValue
  20. // @grant GM.setValue
  21. // @grant GM.getResourceUrl
  22. // @grant GM.setClipboard
  23. // @grant unsafeWindow
  24. // @noframes
  25. // @resource icon https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/icon/icon_48.png
  26. // @resource close https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/close.png
  27. // @resource delete https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/delete.svg
  28. // @resource error https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/error.svg
  29. // @resource lyrics https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/lyrics.svg
  30. // @resource spinner https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/spinner.svg
  31. // @resource arrow_down https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/arrow_down.svg
  32. // @resource skip_to https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/skip_to.svg
  33. // @resource github https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/external/github.png
  34. // @resource greasyfork https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/external/greasyfork.png
  35. // ==/UserScript==
  36. /*
  37. ▄▄▄ ▄ ▄▄▄▄▄▄ ▄
  38. █ █ ▄▄▄ █ █ ▄▄▄ ▄ ▄█ █ █ █▀▄▀█
  39. █▀▀▄ █▄█ █▀ █▀ █▄█ █▀ █ █ █ █
  40. █▄▄▀ ▀▄▄ ▀▄▄ ▀▄▄ ▀▄▄ █ █ █ █ █
  41. Made with ❤️ by Sv443
  42. I welcome every contribution on GitHub!
  43. https://github.com/Sv443/BetterYTM
  44. */
  45. /* Disclaimer: I am not affiliated with or endorsed by YouTube, Google, Alphabet, Genius or anyone else */
  46. /* C&D this 🖕 */
  47. var __webpack_exports__ = {};
  48. ;// CONCATENATED MODULE: ./node_modules/@sv443-network/userutils/dist/index.mjs
  49. var __defProp = Object.defineProperty;
  50. var __defProps = Object.defineProperties;
  51. var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
  52. var __getOwnPropSymbols = Object.getOwnPropertySymbols;
  53. var __hasOwnProp = Object.prototype.hasOwnProperty;
  54. var __propIsEnum = Object.prototype.propertyIsEnumerable;
  55. var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
  56. var __spreadValues = (a, b) => {
  57. for (var prop in b || (b = {}))
  58. if (__hasOwnProp.call(b, prop))
  59. __defNormalProp(a, prop, b[prop]);
  60. if (__getOwnPropSymbols)
  61. for (var prop of __getOwnPropSymbols(b)) {
  62. if (__propIsEnum.call(b, prop))
  63. __defNormalProp(a, prop, b[prop]);
  64. }
  65. return a;
  66. };
  67. var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
  68. var __publicField = (obj, key, value) => {
  69. __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
  70. return value;
  71. };
  72. var __async = (__this, __arguments, generator) => {
  73. return new Promise((resolve, reject) => {
  74. var fulfilled = (value) => {
  75. try {
  76. step(generator.next(value));
  77. } catch (e) {
  78. reject(e);
  79. }
  80. };
  81. var rejected = (value) => {
  82. try {
  83. step(generator.throw(value));
  84. } catch (e) {
  85. reject(e);
  86. }
  87. };
  88. var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
  89. step((generator = generator.apply(__this, __arguments)).next());
  90. });
  91. };
  92. // lib/math.ts
  93. function clamp(value, min, max) {
  94. return Math.max(Math.min(value, max), min);
  95. }
  96. function mapRange(value, range_1_min, range_1_max, range_2_min, range_2_max) {
  97. if (Number(range_1_min) === 0 && Number(range_2_min) === 0)
  98. return value * (range_2_max / range_1_max);
  99. return (value - range_1_min) * ((range_2_max - range_2_min) / (range_1_max - range_1_min)) + range_2_min;
  100. }
  101. function randRange(...args) {
  102. let min, max;
  103. if (typeof args[0] === "number" && typeof args[1] === "number") {
  104. [min, max] = args;
  105. } else if (typeof args[0] === "number" && typeof args[1] !== "number") {
  106. min = 0;
  107. max = args[0];
  108. } else
  109. throw new TypeError(`Wrong parameter(s) provided - expected: "number" and "number|undefined", got: "${typeof args[0]}" and "${typeof args[1]}"`);
  110. min = Number(min);
  111. max = Number(max);
  112. if (isNaN(min) || isNaN(max))
  113. throw new TypeError(`Parameters "min" and "max" can't be NaN`);
  114. if (min > max)
  115. throw new TypeError(`Parameter "min" can't be bigger than "max"`);
  116. return Math.floor(Math.random() * (max - min + 1)) + min;
  117. }
  118. // lib/array.ts
  119. function randomItem(array) {
  120. return randomItemIndex(array)[0];
  121. }
  122. function randomItemIndex(array) {
  123. if (array.length === 0)
  124. return [void 0, void 0];
  125. const idx = randRange(array.length - 1);
  126. return [array[idx], idx];
  127. }
  128. function takeRandomItem(arr) {
  129. const [itm, idx] = randomItemIndex(arr);
  130. if (idx === void 0)
  131. return void 0;
  132. arr.splice(idx, 1);
  133. return itm;
  134. }
  135. function randomizeArray(array) {
  136. const retArray = [...array];
  137. if (array.length === 0)
  138. return array;
  139. for (let i = retArray.length - 1; i > 0; i--) {
  140. const j = Math.floor(randRange(0, 1e4) / 1e4 * (i + 1));
  141. [retArray[i], retArray[j]] = [retArray[j], retArray[i]];
  142. }
  143. return retArray;
  144. }
  145. // lib/config.ts
  146. var ConfigManager = class {
  147. /**
  148. * Creates an instance of ConfigManager to manage a user configuration that is cached in memory and persistently saved across sessions.
  149. * Supports migrating data from older versions of the configuration to newer ones and populating the cache with default data if no persistent data is found.
  150. *
  151. * ⚠️ Requires the directives `@grant GM.getValue` and `@grant GM.setValue`
  152. * ⚠️ Make sure to call `loadData()` at least once after creating an instance, or the returned data will be the same as `options.defaultConfig`
  153. *
  154. * @template TData The type of the data that is saved in persistent storage (will be automatically inferred from `config.defaultConfig`) - this should also be the type of the data format associated with the current `options.formatVersion`
  155. * @param options The options for this ConfigManager instance
  156. */
  157. constructor(options) {
  158. __publicField(this, "id");
  159. __publicField(this, "formatVersion");
  160. __publicField(this, "defaultConfig");
  161. __publicField(this, "cachedConfig");
  162. __publicField(this, "migrations");
  163. this.id = options.id;
  164. this.formatVersion = options.formatVersion;
  165. this.defaultConfig = options.defaultConfig;
  166. this.cachedConfig = options.defaultConfig;
  167. this.migrations = options.migrations;
  168. }
  169. /**
  170. * Loads the data saved in persistent storage into the in-memory cache and also returns it.
  171. * Automatically populates persistent storage with default data if it doesn't contain any data yet.
  172. * Also runs all necessary migration functions if the data format has changed since the last time the data was saved.
  173. */
  174. loadData() {
  175. return __async(this, null, function* () {
  176. try {
  177. const gmData = yield GM.getValue(`_uucfg-${this.id}`, this.defaultConfig);
  178. let gmFmtVer = Number(yield GM.getValue(`_uucfgver-${this.id}`));
  179. if (typeof gmData !== "string") {
  180. yield this.saveDefaultData();
  181. return this.defaultConfig;
  182. }
  183. if (isNaN(gmFmtVer))
  184. yield GM.setValue(`_uucfgver-${this.id}`, gmFmtVer = this.formatVersion);
  185. let parsed = JSON.parse(gmData);
  186. if (gmFmtVer < this.formatVersion && this.migrations)
  187. parsed = yield this.runMigrations(parsed, gmFmtVer);
  188. return this.cachedConfig = typeof parsed === "object" ? parsed : void 0;
  189. } catch (err) {
  190. yield this.saveDefaultData();
  191. return this.defaultConfig;
  192. }
  193. });
  194. }
  195. /** Returns a copy of the data from the in-memory cache. Use `loadData()` to get fresh data from persistent storage (usually not necessary since the cache should always exactly reflect persistent storage). */
  196. getData() {
  197. return this.deepCopy(this.cachedConfig);
  198. }
  199. /** Saves the data synchronously to the in-memory cache and asynchronously to the persistent storage */
  200. setData(data) {
  201. this.cachedConfig = data;
  202. return new Promise((resolve) => __async(this, null, function* () {
  203. yield Promise.all([
  204. GM.setValue(`_uucfg-${this.id}`, JSON.stringify(data)),
  205. GM.setValue(`_uucfgver-${this.id}`, this.formatVersion)
  206. ]);
  207. resolve();
  208. }));
  209. }
  210. /** Saves the default configuration data passed in the constructor synchronously to the in-memory cache and asynchronously to persistent storage */
  211. saveDefaultData() {
  212. return __async(this, null, function* () {
  213. this.cachedConfig = this.defaultConfig;
  214. return new Promise((resolve) => __async(this, null, function* () {
  215. yield Promise.all([
  216. GM.setValue(`_uucfg-${this.id}`, JSON.stringify(this.defaultConfig)),
  217. GM.setValue(`_uucfgver-${this.id}`, this.formatVersion)
  218. ]);
  219. resolve();
  220. }));
  221. });
  222. }
  223. /**
  224. * Call this method to clear all persistently stored data associated with this ConfigManager instance.
  225. * The in-memory cache will be left untouched, so you may still access the data with `getData()`.
  226. * Calling `loadData()` or `setData()` after this method was called will recreate persistent storage with the cached or default data.
  227. *
  228. * ⚠️ This requires the additional directive `@grant GM.deleteValue`
  229. */
  230. deleteConfig() {
  231. return __async(this, null, function* () {
  232. yield Promise.all([
  233. GM.deleteValue(`_uucfg-${this.id}`),
  234. GM.deleteValue(`_uucfgver-${this.id}`)
  235. ]);
  236. });
  237. }
  238. /** Runs all necessary migration functions consecutively - may be overwritten in a subclass */
  239. runMigrations(oldData, oldFmtVer) {
  240. return __async(this, null, function* () {
  241. if (!this.migrations)
  242. return oldData;
  243. let newData = oldData;
  244. const sortedMigrations = Object.entries(this.migrations).sort(([a], [b]) => Number(a) - Number(b));
  245. let lastFmtVer = oldFmtVer;
  246. for (const [fmtVer, migrationFunc] of sortedMigrations) {
  247. const ver = Number(fmtVer);
  248. if (oldFmtVer < this.formatVersion && oldFmtVer < ver) {
  249. try {
  250. const migRes = migrationFunc(newData);
  251. newData = migRes instanceof Promise ? yield migRes : migRes;
  252. lastFmtVer = oldFmtVer = ver;
  253. } catch (err) {
  254. console.error(`Error while running migration function for format version ${fmtVer}:`, err);
  255. }
  256. }
  257. }
  258. yield Promise.all([
  259. GM.setValue(`_uucfg-${this.id}`, JSON.stringify(newData)),
  260. GM.setValue(`_uucfgver-${this.id}`, lastFmtVer)
  261. ]);
  262. return newData;
  263. });
  264. }
  265. /** Copies a JSON-compatible object and loses its internal references */
  266. deepCopy(obj) {
  267. return JSON.parse(JSON.stringify(obj));
  268. }
  269. };
  270. // lib/dom.ts
  271. function getUnsafeWindow() {
  272. try {
  273. return unsafeWindow;
  274. } catch (e) {
  275. return window;
  276. }
  277. }
  278. function insertAfter(beforeElement, afterElement) {
  279. var _a;
  280. (_a = beforeElement.parentNode) == null ? void 0 : _a.insertBefore(afterElement, beforeElement.nextSibling);
  281. return afterElement;
  282. }
  283. function addParent(element, newParent) {
  284. const oldParent = element.parentNode;
  285. if (!oldParent)
  286. throw new Error("Element doesn't have a parent node");
  287. oldParent.replaceChild(newParent, element);
  288. newParent.appendChild(element);
  289. return newParent;
  290. }
  291. function addGlobalStyle(style) {
  292. const styleElem = document.createElement("style");
  293. styleElem.innerHTML = style;
  294. document.head.appendChild(styleElem);
  295. }
  296. function preloadImages(srcUrls, rejects = false) {
  297. const promises = srcUrls.map((src) => new Promise((res, rej) => {
  298. const image = new Image();
  299. image.src = src;
  300. image.addEventListener("load", () => res(image));
  301. image.addEventListener("error", (evt) => rejects && rej(evt));
  302. }));
  303. return Promise.allSettled(promises);
  304. }
  305. function openInNewTab(href) {
  306. const openElem = document.createElement("a");
  307. Object.assign(openElem, {
  308. className: "userutils-open-in-new-tab",
  309. target: "_blank",
  310. rel: "noopener noreferrer",
  311. href
  312. });
  313. openElem.style.display = "none";
  314. document.body.appendChild(openElem);
  315. openElem.click();
  316. setTimeout(openElem.remove, 50);
  317. }
  318. function interceptEvent(eventObject, eventName, predicate) {
  319. if (typeof Error.stackTraceLimit === "number" && Error.stackTraceLimit < 1e3) {
  320. Error.stackTraceLimit = 1e3;
  321. }
  322. (function(original) {
  323. eventObject.__proto__.addEventListener = function(...args) {
  324. var _a, _b;
  325. const origListener = typeof args[1] === "function" ? args[1] : (_b = (_a = args[1]) == null ? void 0 : _a.handleEvent) != null ? _b : () => void 0;
  326. args[1] = function(...a) {
  327. if (args[0] === eventName && predicate(Array.isArray(a) ? a[0] : a))
  328. return;
  329. else
  330. return origListener.apply(this, a);
  331. };
  332. original.apply(this, args);
  333. };
  334. })(eventObject.__proto__.addEventListener);
  335. }
  336. function interceptWindowEvent(eventName, predicate) {
  337. return interceptEvent(getUnsafeWindow(), eventName, predicate);
  338. }
  339. function amplifyMedia(mediaElement, multiplier = 1) {
  340. const context = new (window.AudioContext || window.webkitAudioContext)();
  341. const result = {
  342. mediaElement,
  343. amplify: (multiplier2) => {
  344. result.gain.gain.value = multiplier2;
  345. },
  346. getAmpLevel: () => result.gain.gain.value,
  347. context,
  348. source: context.createMediaElementSource(mediaElement),
  349. gain: context.createGain()
  350. };
  351. result.source.connect(result.gain);
  352. result.gain.connect(context.destination);
  353. result.amplify(multiplier);
  354. return result;
  355. }
  356. function isScrollable(element) {
  357. const { overflowX, overflowY } = getComputedStyle(element);
  358. return {
  359. vertical: (overflowY === "scroll" || overflowY === "auto") && element.scrollHeight > element.clientHeight,
  360. horizontal: (overflowX === "scroll" || overflowX === "auto") && element.scrollWidth > element.clientWidth
  361. };
  362. }
  363. // lib/misc.ts
  364. function autoPlural(word, num) {
  365. if (Array.isArray(num) || num instanceof NodeList)
  366. num = num.length;
  367. return `${word}${num === 1 ? "" : "s"}`;
  368. }
  369. function pauseFor(time) {
  370. return new Promise((res) => {
  371. setTimeout(() => res(), time);
  372. });
  373. }
  374. function debounce(func, timeout = 300) {
  375. let timer;
  376. return function(...args) {
  377. clearTimeout(timer);
  378. timer = setTimeout(() => func.apply(this, args), timeout);
  379. };
  380. }
  381. function fetchAdvanced(_0) {
  382. return __async(this, arguments, function* (url, options = {}) {
  383. const { timeout = 1e4 } = options;
  384. const controller = new AbortController();
  385. const id = setTimeout(() => controller.abort(), timeout);
  386. const res = yield fetch(url, __spreadProps(__spreadValues({}, options), {
  387. signal: controller.signal
  388. }));
  389. clearTimeout(id);
  390. return res;
  391. });
  392. }
  393. // lib/onSelector.ts
  394. var selectorMap = /* @__PURE__ */ new Map();
  395. function onSelector(selector, options) {
  396. let selectorMapItems = [];
  397. if (selectorMap.has(selector))
  398. selectorMapItems = selectorMap.get(selector);
  399. selectorMapItems.push(options);
  400. selectorMap.set(selector, selectorMapItems);
  401. checkSelectorExists(selector, selectorMapItems);
  402. }
  403. function removeOnSelector(selector) {
  404. return selectorMap.delete(selector);
  405. }
  406. function checkSelectorExists(selector, options) {
  407. const deleteIndices = [];
  408. options.forEach((option, i) => {
  409. try {
  410. const elements = option.all ? document.querySelectorAll(selector) : document.querySelector(selector);
  411. if (elements !== null && elements instanceof NodeList && elements.length > 0 || elements !== null) {
  412. option.listener(elements);
  413. if (!option.continuous)
  414. deleteIndices.push(i);
  415. }
  416. } catch (err) {
  417. console.error(`Couldn't call listener for selector '${selector}'`, err);
  418. }
  419. });
  420. if (deleteIndices.length > 0) {
  421. const newOptsArray = options.filter((_, i) => !deleteIndices.includes(i));
  422. if (newOptsArray.length === 0)
  423. selectorMap.delete(selector);
  424. else {
  425. selectorMap.set(selector, newOptsArray);
  426. }
  427. }
  428. }
  429. function initOnSelector(options = {}) {
  430. const observer = new MutationObserver(() => {
  431. for (const [selector, options2] of selectorMap.entries())
  432. checkSelectorExists(selector, options2);
  433. });
  434. observer.observe(document.body, __spreadValues({
  435. subtree: true,
  436. childList: true
  437. }, options));
  438. }
  439. function getSelectorMap() {
  440. return selectorMap;
  441. }
  442. ;// CONCATENATED MODULE: ./src/constants.ts
  443. const modeRaw = "production";
  444. const branchRaw = "main";
  445. /** The mode in which the script was built (production or development) */
  446. const mode = (modeRaw.match(/^{{.+}}$/) ? "production" : modeRaw);
  447. /** The branch to use in various URLs that point to the GitHub repo */
  448. const branch = (branchRaw.match(/^{{.+}}$/) ? "main" : branchRaw);
  449. /**
  450. * How much info should be logged to the devtools console
  451. * 0 = Debug (show everything) or 1 = Info (show only important stuff)
  452. */
  453. const defaultLogLevel = mode === "production" ? 1 : 0;
  454. /** Info about the userscript, parsed from the userscript header (tools/post-build.js) */
  455. const constants_scriptInfo = {
  456. name: GM.info.script.name,
  457. version: GM.info.script.version,
  458. namespace: GM.info.script.namespace,
  459. buildNumber: "8d02f2a", // assert as generic string instead of literal
  460. };
  461. ;// CONCATENATED MODULE: ./src/utils.ts
  462. let curLogLevel = 1;
  463. /** Common prefix to be able to tell logged messages apart and filter them in devtools */
  464. const consPrefix = `[${constants_scriptInfo.name}]`;
  465. const consPrefixDbg = (/* unused pure expression or super */ null && (`[${scriptInfo.name}/#DEBUG]`));
  466. /** Sets the current log level. 0 = Debug, 1 = Info */
  467. function setLogLevel(level) {
  468. if (curLogLevel !== level)
  469. console.log(consPrefix, "Setting log level to", level === 0 ? "Debug" : "Info");
  470. curLogLevel = level;
  471. }
  472. /** Extracts the log level from the last item from spread arguments - returns 0 if the last item is not a number or too low or high */
  473. function getLogLevel(args) {
  474. const minLogLvl = 0, maxLogLvl = 1;
  475. if (typeof args.at(-1) === "number")
  476. return clamp(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl);
  477. return 0;
  478. }
  479. /**
  480. * Logs all passed values to the console, as long as the log level is sufficient.
  481. * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if they shouldn't be.
  482. */
  483. function log(...args) {
  484. if (curLogLevel <= getLogLevel(args))
  485. console.log(consPrefix, ...args);
  486. }
  487. /**
  488. * Logs all passed values to the console as info, as long as the log level is sufficient.
  489. * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if they shouldn't be.
  490. */
  491. function utils_info(...args) {
  492. if (curLogLevel <= getLogLevel(args))
  493. console.info(consPrefix, ...args);
  494. }
  495. /** Logs all passed values to the console as a warning, no matter the log level. */
  496. function warn(...args) {
  497. console.warn(consPrefix, ...args);
  498. }
  499. /** Logs all passed values to the console as an error, no matter the log level. */
  500. function error(...args) {
  501. console.error(consPrefix, ...args);
  502. }
  503. /** Logs all passed values to the console with a debug-specific prefix */
  504. function dbg(...args) {
  505. console.log(consPrefixDbg, ...args);
  506. }
  507. /**
  508. * Returns the current video time in seconds
  509. * Dispatches mouse movement events in case the video time can't be estimated
  510. * @returns Returns null if the video time is unavailable
  511. */
  512. function getVideoTime() {
  513. return new Promise((res) => {
  514. const domain = getDomain();
  515. try {
  516. if (domain === "ytm") {
  517. onSelector("#progress-bar", {
  518. listener: (pbEl) => res(!isNaN(Number(pbEl.value)) ? Number(pbEl.value) : null)
  519. });
  520. }
  521. else if (domain === "yt") {
  522. // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it)
  523. ytForceShowVideoTime();
  524. const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]";
  525. let videoTime = -1;
  526. const mut = new MutationObserver(() => {
  527. // .observe() is only called when the element exists - no need to check for null
  528. videoTime = Number(document.querySelector(pbSelector).getAttribute("aria-valuenow"));
  529. });
  530. const observe = (progElem) => {
  531. mut.observe(progElem, {
  532. attributes: true,
  533. attributeFilter: ["aria-valuenow"],
  534. });
  535. if (videoTime >= 0 && !isNaN(videoTime)) {
  536. res(videoTime);
  537. mut.disconnect();
  538. }
  539. else
  540. setTimeout(() => {
  541. res(videoTime >= 0 && !isNaN(videoTime) ? videoTime : null);
  542. mut.disconnect();
  543. }, 500);
  544. };
  545. onSelector(pbSelector, { listener: observe });
  546. }
  547. }
  548. catch (err) {
  549. error("Couldn't get video time due to error:", err);
  550. res(null);
  551. }
  552. });
  553. }
  554. /**
  555. * Sends events that force the video controls to become visible for about 3 seconds.
  556. * This only works once, then the page needs to be reloaded!
  557. */
  558. function ytForceShowVideoTime() {
  559. const player = document.querySelector("#movie_player");
  560. if (!player)
  561. return false;
  562. const defaultProps = {
  563. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  564. view: getUnsafeWindow(),
  565. bubbles: true,
  566. cancelable: false,
  567. };
  568. player.dispatchEvent(new MouseEvent("mouseenter", defaultProps));
  569. const { x, y, width, height } = player.getBoundingClientRect();
  570. const screenY = Math.round(y + height / 2);
  571. const screenX = x + Math.min(50, Math.round(width / 3));
  572. player.dispatchEvent(new MouseEvent("mousemove", Object.assign(Object.assign({}, defaultProps), { screenY,
  573. screenX, movementX: 5, movementY: 0 })));
  574. return true;
  575. }
  576. /**
  577. * Returns the current domain as a constant string representation
  578. * @throws Throws if script runs on an unexpected website
  579. */
  580. function getDomain() {
  581. if (location.hostname.match(/^music\.youtube/))
  582. return "ytm";
  583. else if (location.hostname.match(/youtube\./))
  584. return "yt";
  585. else
  586. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  587. }
  588. /** Returns the URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl) */
  589. function getResourceUrl(name) {
  590. return GM.getResourceUrl(name);
  591. }
  592. ;// CONCATENATED MODULE: ./node_modules/nanoevents/index.js
  593. let createNanoEvents = () => ({
  594. emit(event, ...args) {
  595. let callbacks = this.events[event] || []
  596. for (let i = 0, length = callbacks.length; i < length; i++) {
  597. callbacks[i](...args)
  598. }
  599. },
  600. events: {},
  601. on(event, cb) {
  602. this.events[event]?.push(cb) || (this.events[event] = [cb])
  603. return () => {
  604. this.events[event] = this.events[event]?.filter(i => cb !== i)
  605. }
  606. }
  607. })
  608. ;// CONCATENATED MODULE: ./src/events.ts
  609. var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
  610. function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
  611. return new (P || (P = Promise))(function (resolve, reject) {
  612. function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
  613. function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
  614. function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
  615. step((generator = generator.apply(thisArg, _arguments || [])).next());
  616. });
  617. };
  618. /** EventEmitter instance that is used to detect changes to the site */
  619. const siteEvents = createNanoEvents();
  620. let observers = [];
  621. /** Disconnects and deletes all observers. Run `initSiteEvents()` again to create new ones. */
  622. function removeAllObservers() {
  623. observers.forEach((observer, i) => {
  624. observer.disconnect();
  625. delete observers[i];
  626. });
  627. observers = [];
  628. }
  629. /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */
  630. function initSiteEvents() {
  631. return __awaiter(this, void 0, void 0, function* () {
  632. try {
  633. // the queue container always exists so it doesn't need an extra init function
  634. const queueObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
  635. if (addedNodes.length > 0 || removedNodes.length > 0) {
  636. utils_info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  637. siteEvents.emit("queueChanged", target);
  638. }
  639. });
  640. // only observe added or removed elements
  641. queueObs.observe(document.querySelector(".side-panel.modular #contents.ytmusic-player-queue"), {
  642. childList: true,
  643. });
  644. const autoplayObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => {
  645. if (addedNodes.length > 0 || removedNodes.length > 0) {
  646. utils_info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
  647. siteEvents.emit("autoplayQueueChanged", target);
  648. }
  649. });
  650. autoplayObs.observe(document.querySelector(".side-panel.modular ytmusic-player-queue #automix-contents"), {
  651. childList: true,
  652. });
  653. utils_info("Successfully initialized SiteEvents observers");
  654. observers = observers.concat([
  655. queueObs,
  656. autoplayObs,
  657. ]);
  658. }
  659. catch (err) {
  660. error("Couldn't initialize SiteEvents observers due to an error:\n", err);
  661. }
  662. });
  663. }
  664. ;// CONCATENATED MODULE: ./changelog.md
  665. // Module
  666. var code = "<h2 id=\"100\">1.0.0</h2> <ul> <li>Added Features:<ul> <li>Added configuration menu to toggle and configure all features</li> <li>Added lyrics button to each song in the queue</li> <li>Added &quot;remove from queue&quot; button to each song in the queue</li> <li>Use number keys to skip to a specific point in the song</li> <li>Added feature to make volume slider bigger and volume control finer</li> <li>Added percentage label next to the volume slider &amp; title on hover</li> <li>Improvements to link hitboxes &amp; more links in general</li> <li>Permanent toast notifications can be automatically closed now</li> <li>Remove tracking parameter <code>&amp;si=...</code> from links in the share menu</li> <li>Fix spacing issues throughout the site</li> <li>Added a button to scroll to the currently active song in the queue</li> <li>Added an easter egg to the watermark and config menu option :)</li> </ul> </li> <li>Changes &amp; Fixes:<ul> <li>Now the lyrics button will directly link to the lyrics (using my API <a href=\"https://github.com/Sv443/geniURL\">geniURL</a>)</li> <li>Video time is now kept when switching site on regular YT too</li> <li>Fixed compatibility with the new site design</li> <li>A loading indicator is shown while the lyrics are loading</li> <li>Images are now smaller and cached by the userscript extension</li> <li>Song names with hyphens are now resolved better for lyrics lookup</li> <li>Site switch with <kbd>F9</kbd> will now keep the video time</li> <li>Moved lots of utility code to my new library <a href=\"https://github.com/Sv443-Network/UserUtils\">UserUtils</a></li> </ul> </li> </ul> <br> <h2 id=\"020\">0.2.0</h2> <ul> <li>Added Features:<ul> <li>Switch between YouTube and YT Music (with <kbd>F9</kbd> by default)</li> <li>Search for song lyrics with new button in media controls</li> <li>Remove &quot;Upgrade to YTM Premium&quot; tab</li> </ul> </li> </ul> <br> <h2 id=\"010\">0.1.0</h2> <ul> <li>Added support for arrow keys to skip forward or backward (currently only by fixed 10 second interval)</li> </ul> ";
  667. // Exports
  668. /* harmony default export */ var changelog = (code);
  669. ;// CONCATENATED MODULE: ./src/menu/menu_old.ts
  670. var menu_old_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
  671. function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
  672. return new (P || (P = Promise))(function (resolve, reject) {
  673. function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
  674. function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
  675. function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
  676. step((generator = generator.apply(thisArg, _arguments || [])).next());
  677. });
  678. };
  679. let isMenuOpen = false;
  680. /** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */
  681. const scrollIndicatorOffsetThreshold = 30;
  682. let scrollIndicatorEnabled = true;
  683. /**
  684. * Adds an element to open the BetterYTM menu
  685. * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23
  686. */
  687. function addMenu() {
  688. var _a, _b;
  689. return menu_old_awaiter(this, void 0, void 0, function* () {
  690. const backgroundElem = document.createElement("div");
  691. backgroundElem.id = "bytm-cfg-menu-bg";
  692. backgroundElem.classList.add("bytm-menu-bg");
  693. backgroundElem.title = "Click here to close the menu";
  694. backgroundElem.style.visibility = "hidden";
  695. backgroundElem.style.display = "none";
  696. backgroundElem.addEventListener("click", (e) => {
  697. var _a;
  698. if (isMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg")
  699. closeMenu(e);
  700. });
  701. document.body.addEventListener("keydown", (e) => {
  702. if (isMenuOpen && e.key === "Escape")
  703. closeMenu(e);
  704. });
  705. const menuContainer = document.createElement("div");
  706. menuContainer.title = ""; // prevent bg title from propagating downwards
  707. menuContainer.classList.add("bytm-menu");
  708. menuContainer.id = "bytm-cfg-menu";
  709. const headerElem = document.createElement("div");
  710. headerElem.classList.add("bytm-menu-header");
  711. const titleCont = document.createElement("div");
  712. titleCont.id = "bytm-menu-titlecont";
  713. titleCont.role = "heading";
  714. titleCont.ariaLevel = "1";
  715. const titleElem = document.createElement("h2");
  716. titleElem.id = "bytm-menu-title";
  717. titleElem.innerText = `${constants_scriptInfo.name} - Configuration`;
  718. const linksCont = document.createElement("div");
  719. linksCont.id = "bytm-menu-linkscont";
  720. const addLink = (imgSrc, href, title) => {
  721. const anchorElem = document.createElement("a");
  722. anchorElem.className = "bytm-menu-link bytm-no-select";
  723. anchorElem.rel = "noopener noreferrer";
  724. anchorElem.target = "_blank";
  725. anchorElem.href = href;
  726. anchorElem.title = title;
  727. const imgElem = document.createElement("img");
  728. imgElem.className = "bytm-menu-img";
  729. imgElem.src = imgSrc;
  730. imgElem.style.width = "32px";
  731. imgElem.style.height = "32px";
  732. anchorElem.appendChild(imgElem);
  733. linksCont.appendChild(anchorElem);
  734. };
  735. addLink(yield getResourceUrl("github"), constants_scriptInfo.namespace, `Open ${constants_scriptInfo.name} on GitHub`);
  736. // TODO:
  737. // addLink(await getResourceUrl("greasyfork"), "https://greasyfork.org/en/users/184165-sv443", `Open ${scriptInfo.name} on GreasyFork`);
  738. const closeElem = document.createElement("img");
  739. closeElem.classList.add("bytm-menu-close");
  740. closeElem.src = yield getResourceUrl("close");
  741. closeElem.title = "Click to close the menu";
  742. closeElem.addEventListener("click", closeMenu);
  743. titleCont.appendChild(titleElem);
  744. titleCont.appendChild(linksCont);
  745. headerElem.appendChild(titleCont);
  746. headerElem.appendChild(closeElem);
  747. const featuresCont = document.createElement("div");
  748. featuresCont.id = "bytm-menu-opts";
  749. /** Gets called whenever the feature config is changed */
  750. const confChanged = debounce((key, initialVal, newVal) => menu_old_awaiter(this, void 0, void 0, function* () {
  751. const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val);
  752. utils_info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
  753. const featConf = Object.assign({}, getFeatures());
  754. featConf[key] = newVal;
  755. yield saveFeatures(featConf);
  756. }));
  757. const featureCfg = getFeatures();
  758. const featureCfgWithCategories = Object.entries(featInfo)
  759. .reduce((acc, [key, { category }]) => {
  760. if (!acc[category])
  761. acc[category] = {};
  762. acc[category][key] = featureCfg[key];
  763. return acc;
  764. }, {});
  765. const fmtVal = (v) => String(v).trim();
  766. const toggleLabelText = (toggled) => toggled ? "On" : "Off";
  767. for (const category in featureCfgWithCategories) {
  768. const featObj = featureCfgWithCategories[category];
  769. const catHeaderElem = document.createElement("h3");
  770. catHeaderElem.classList.add("bytm-ftconf-category-header");
  771. catHeaderElem.role = "heading";
  772. catHeaderElem.ariaLevel = "2";
  773. catHeaderElem.innerText = `${categoryNames[category]}:`;
  774. featuresCont.appendChild(catHeaderElem);
  775. for (const featKey in featObj) {
  776. const ftInfo = featInfo[featKey];
  777. // @ts-ignore
  778. if (!ftInfo || ftInfo.hidden === true)
  779. continue;
  780. const { desc, type, default: ftDefault } = ftInfo;
  781. // @ts-ignore
  782. const step = (_a = ftInfo === null || ftInfo === void 0 ? void 0 : ftInfo.step) !== null && _a !== void 0 ? _a : undefined;
  783. const val = featureCfg[featKey];
  784. const initialVal = (_b = val !== null && val !== void 0 ? val : ftDefault) !== null && _b !== void 0 ? _b : undefined;
  785. const ftConfElem = document.createElement("div");
  786. ftConfElem.classList.add("bytm-ftitem");
  787. {
  788. const textElem = document.createElement("div");
  789. textElem.innerText = desc;
  790. ftConfElem.appendChild(textElem);
  791. }
  792. {
  793. let inputType = "text";
  794. let inputTag = "input";
  795. switch (type) {
  796. case "toggle":
  797. inputType = "checkbox";
  798. break;
  799. case "slider":
  800. inputType = "range";
  801. break;
  802. case "number":
  803. inputType = "number";
  804. break;
  805. case "select":
  806. inputTag = "select";
  807. inputType = undefined;
  808. break;
  809. }
  810. const inputElemId = `bytm-ftconf-${featKey}-input`;
  811. const ctrlElem = document.createElement("span");
  812. ctrlElem.classList.add("bytm-ftconf-ctrl");
  813. const inputElem = document.createElement(inputTag);
  814. inputElem.classList.add("bytm-ftconf-input");
  815. inputElem.id = inputElemId;
  816. if (inputType)
  817. inputElem.type = inputType;
  818. if (typeof initialVal !== "undefined")
  819. inputElem.value = String(initialVal);
  820. if (type === "number" && step)
  821. inputElem.step = step;
  822. // @ts-ignore
  823. if (typeof ftInfo.min !== "undefined" && ftInfo.max !== "undefined") {
  824. // @ts-ignore
  825. inputElem.min = ftInfo.min;
  826. // @ts-ignore
  827. inputElem.max = ftInfo.max;
  828. }
  829. if (type === "toggle" && typeof initialVal !== "undefined")
  830. inputElem.checked = Boolean(initialVal);
  831. // @ts-ignore
  832. const unitTxt = typeof ftInfo.unit === "string" ? " " + ftInfo.unit : "";
  833. let labelElem;
  834. if (type === "slider") {
  835. labelElem = document.createElement("label");
  836. labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label");
  837. labelElem.htmlFor = inputElemId;
  838. labelElem.innerText = fmtVal(initialVal) + unitTxt;
  839. inputElem.addEventListener("input", () => {
  840. if (labelElem)
  841. labelElem.innerText = fmtVal(parseInt(inputElem.value)) + unitTxt;
  842. });
  843. }
  844. else if (type === "toggle") {
  845. labelElem = document.createElement("label");
  846. labelElem.classList.add("bytm-ftconf-label", "bytm-toggle-label");
  847. labelElem.htmlFor = inputElemId;
  848. labelElem.innerText = toggleLabelText(Boolean(initialVal)) + unitTxt;
  849. inputElem.addEventListener("input", () => {
  850. if (labelElem)
  851. labelElem.innerText = toggleLabelText(inputElem.checked) + unitTxt;
  852. });
  853. }
  854. else if (type === "select") {
  855. for (const { value, label } of ftInfo.options) {
  856. const optionElem = document.createElement("option");
  857. optionElem.value = String(value);
  858. optionElem.innerText = label;
  859. if (value === initialVal)
  860. optionElem.selected = true;
  861. inputElem.appendChild(optionElem);
  862. }
  863. }
  864. inputElem.addEventListener("input", () => {
  865. let v = Number(String(inputElem.value).trim());
  866. if (isNaN(v))
  867. v = Number(inputElem.value);
  868. if (typeof initialVal !== "undefined")
  869. confChanged(featKey, initialVal, (type !== "toggle" ? v : inputElem.checked));
  870. });
  871. if (labelElem) {
  872. labelElem.id = `bytm-ftconf-${featKey}-label`;
  873. ctrlElem.appendChild(labelElem);
  874. }
  875. ctrlElem.appendChild(inputElem);
  876. ftConfElem.appendChild(ctrlElem);
  877. }
  878. featuresCont.appendChild(ftConfElem);
  879. }
  880. }
  881. siteEvents.on("rebuildCfgMenu", (newConfig) => {
  882. for (const ftKey in featInfo) {
  883. const ftElem = document.querySelector(`#bytm-ftconf-${ftKey}-input`);
  884. const labelElem = document.querySelector(`#bytm-ftconf-${ftKey}-label`);
  885. if (!ftElem)
  886. continue;
  887. const ftInfo = featInfo[ftKey];
  888. const value = newConfig[ftKey];
  889. if (ftInfo.type === "toggle")
  890. ftElem.checked = Boolean(value);
  891. else
  892. ftElem.value = String(value);
  893. if (!labelElem)
  894. continue;
  895. // @ts-ignore
  896. const unitTxt = typeof ftInfo.unit === "string" ? " " + ftInfo.unit : "";
  897. if (ftInfo.type === "slider")
  898. labelElem.innerText = fmtVal(Number(value)) + unitTxt;
  899. else if (ftInfo.type === "toggle")
  900. labelElem.innerText = toggleLabelText(Boolean(value)) + unitTxt;
  901. }
  902. });
  903. const scrollIndicator = document.createElement("img");
  904. scrollIndicator.id = "bytm-menu-scroll-indicator";
  905. scrollIndicator.src = yield getResourceUrl("arrow_down");
  906. scrollIndicator.role = "button";
  907. scrollIndicator.title = "Click to scroll to the bottom";
  908. featuresCont.appendChild(scrollIndicator);
  909. scrollIndicator.addEventListener("click", () => {
  910. const bottomAnchor = document.querySelector("#bytm-menu-bottom-anchor");
  911. bottomAnchor === null || bottomAnchor === void 0 ? void 0 : bottomAnchor.scrollIntoView({
  912. behavior: "smooth",
  913. });
  914. });
  915. featuresCont.addEventListener("scroll", (evt) => {
  916. var _a, _b;
  917. const scrollPos = (_b = (_a = evt.target) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0;
  918. const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
  919. if (!scrollIndicator)
  920. return;
  921. if (scrollIndicatorEnabled && scrollPos > scrollIndicatorOffsetThreshold && !scrollIndicator.classList.contains("bytm-hidden")) {
  922. scrollIndicator.classList.add("bytm-hidden");
  923. }
  924. else if (scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) {
  925. scrollIndicator.classList.remove("bytm-hidden");
  926. }
  927. });
  928. const bottomAnchor = document.createElement("div");
  929. bottomAnchor.id = "bytm-menu-bottom-anchor";
  930. featuresCont.appendChild(bottomAnchor);
  931. const footerCont = document.createElement("div");
  932. footerCont.id = "bytm-menu-footer-cont";
  933. const footerElem = document.createElement("div");
  934. footerElem.classList.add("bytm-menu-footer");
  935. footerElem.innerText = "You need to reload the page to apply changes";
  936. const reloadElem = document.createElement("button");
  937. reloadElem.classList.add("bytm-btn");
  938. reloadElem.style.marginLeft = "10px";
  939. reloadElem.innerText = "Reload now";
  940. reloadElem.title = "Click to reload the page";
  941. reloadElem.addEventListener("click", () => {
  942. closeMenu();
  943. location.reload();
  944. });
  945. footerElem.appendChild(reloadElem);
  946. const resetElem = document.createElement("button");
  947. resetElem.classList.add("bytm-btn");
  948. resetElem.title = "Click to reset all settings to their default values";
  949. resetElem.innerText = "Reset";
  950. resetElem.addEventListener("click", () => menu_old_awaiter(this, void 0, void 0, function* () {
  951. if (confirm("Do you really want to reset all settings to their default values?\nThe page will be automatically reloaded.")) {
  952. yield setDefaultFeatures();
  953. closeMenu();
  954. location.reload();
  955. }
  956. }));
  957. const exportElem = document.createElement("button");
  958. exportElem.classList.add("bytm-btn");
  959. exportElem.title = "Click to export your current configuration";
  960. exportElem.innerText = "Export";
  961. exportElem.addEventListener("click", () => menu_old_awaiter(this, void 0, void 0, function* () {
  962. closeMenu();
  963. openExportMenu();
  964. }));
  965. const importElem = document.createElement("button");
  966. importElem.classList.add("bytm-btn");
  967. importElem.title = "Click to import a configuration you have previously exported";
  968. importElem.innerText = "Import";
  969. importElem.addEventListener("click", () => menu_old_awaiter(this, void 0, void 0, function* () {
  970. closeMenu();
  971. openImportMenu();
  972. }));
  973. const buttonsCont = document.createElement("div");
  974. buttonsCont.id = "bytm-menu-footer-buttons-cont";
  975. buttonsCont.appendChild(exportElem);
  976. buttonsCont.appendChild(importElem);
  977. buttonsCont.appendChild(resetElem);
  978. footerCont.appendChild(footerElem);
  979. footerCont.appendChild(buttonsCont);
  980. menuContainer.appendChild(headerElem);
  981. menuContainer.appendChild(featuresCont);
  982. const versionCont = document.createElement("div");
  983. versionCont.id = "bytm-menu-version-cont";
  984. const versionElem = document.createElement("a");
  985. versionElem.id = "bytm-menu-version";
  986. versionElem.role = "button";
  987. versionElem.title = `Version ${constants_scriptInfo.version} (build ${constants_scriptInfo.buildNumber}) - click to open the changelog`;
  988. versionElem.innerText = `v${constants_scriptInfo.version} (${constants_scriptInfo.buildNumber})`;
  989. versionElem.addEventListener("click", (e) => {
  990. e.preventDefault();
  991. e.stopPropagation();
  992. closeMenu();
  993. openChangelogMenu();
  994. });
  995. versionCont.appendChild(versionElem);
  996. menuContainer.appendChild(footerCont);
  997. menuContainer.appendChild(versionCont);
  998. backgroundElem.appendChild(menuContainer);
  999. document.body.appendChild(backgroundElem);
  1000. window.addEventListener("resize", debounce(checkToggleScrollIndicator, 150));
  1001. yield addChangelogMenu();
  1002. yield addExportMenu();
  1003. yield addImportMenu();
  1004. log("Added menu element");
  1005. });
  1006. }
  1007. /** Closes the menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  1008. function closeMenu(evt) {
  1009. if (!isMenuOpen)
  1010. return;
  1011. isMenuOpen = false;
  1012. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  1013. document.body.classList.remove("bytm-disable-scroll");
  1014. const menuBg = document.querySelector("#bytm-cfg-menu-bg");
  1015. if (!menuBg)
  1016. return;
  1017. menuBg.style.visibility = "hidden";
  1018. menuBg.style.display = "none";
  1019. }
  1020. /** Opens the menu if it is closed */
  1021. function openMenu() {
  1022. if (isMenuOpen)
  1023. return;
  1024. isMenuOpen = true;
  1025. document.body.classList.add("bytm-disable-scroll");
  1026. const menuBg = document.querySelector("#bytm-cfg-menu-bg");
  1027. if (!menuBg)
  1028. return;
  1029. menuBg.style.visibility = "visible";
  1030. menuBg.style.display = "block";
  1031. checkToggleScrollIndicator();
  1032. }
  1033. /** Checks if the features container is scrollable and toggles the scroll indicator accordingly */
  1034. function checkToggleScrollIndicator() {
  1035. const featuresCont = document.querySelector("#bytm-menu-opts");
  1036. const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator");
  1037. // disable scroll indicator if container doesn't scroll
  1038. if (featuresCont && scrollIndicator) {
  1039. const verticalScroll = isScrollable(featuresCont).vertical;
  1040. /** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */
  1041. const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold;
  1042. if (!underThreshold && verticalScroll && !scrollIndicatorEnabled) {
  1043. scrollIndicatorEnabled = true;
  1044. scrollIndicator.classList.remove("bytm-hidden");
  1045. }
  1046. if ((!verticalScroll && scrollIndicatorEnabled) || underThreshold) {
  1047. scrollIndicatorEnabled = false;
  1048. scrollIndicator.classList.add("bytm-hidden");
  1049. }
  1050. }
  1051. }
  1052. let isExportMenuOpen = false;
  1053. /** Adds a menu to copy the current configuration as JSON (hidden by default) */
  1054. function addExportMenu() {
  1055. return menu_old_awaiter(this, void 0, void 0, function* () {
  1056. const menuBgElem = document.createElement("div");
  1057. menuBgElem.id = "bytm-export-menu-bg";
  1058. menuBgElem.classList.add("bytm-menu-bg");
  1059. menuBgElem.title = "Click here to close the menu";
  1060. menuBgElem.style.visibility = "hidden";
  1061. menuBgElem.style.display = "none";
  1062. menuBgElem.addEventListener("click", (e) => {
  1063. var _a;
  1064. if (isExportMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-export-menu-bg") {
  1065. closeExportMenu(e);
  1066. openMenu();
  1067. }
  1068. });
  1069. document.body.addEventListener("keydown", (e) => {
  1070. if (isExportMenuOpen && e.key === "Escape") {
  1071. closeExportMenu(e);
  1072. openMenu();
  1073. }
  1074. });
  1075. const menuContainer = document.createElement("div");
  1076. menuContainer.title = ""; // prevent bg title from propagating downwards
  1077. menuContainer.classList.add("bytm-menu");
  1078. menuContainer.id = "bytm-export-menu";
  1079. const headerElem = document.createElement("div");
  1080. headerElem.classList.add("bytm-menu-header");
  1081. const titleCont = document.createElement("div");
  1082. titleCont.id = "bytm-menu-titlecont";
  1083. titleCont.role = "heading";
  1084. titleCont.ariaLevel = "1";
  1085. const titleElem = document.createElement("h2");
  1086. titleElem.id = "bytm-menu-title";
  1087. titleElem.innerText = `${constants_scriptInfo.name} - Export Configuration`;
  1088. const closeElem = document.createElement("img");
  1089. closeElem.classList.add("bytm-menu-close");
  1090. closeElem.src = yield getResourceUrl("close");
  1091. closeElem.title = "Click to close the menu";
  1092. closeElem.addEventListener("click", (e) => {
  1093. closeExportMenu(e);
  1094. openMenu();
  1095. });
  1096. titleCont.appendChild(titleElem);
  1097. headerElem.appendChild(titleCont);
  1098. headerElem.appendChild(closeElem);
  1099. const menuBodyElem = document.createElement("div");
  1100. menuBodyElem.classList.add("bytm-menu-body");
  1101. const textElem = document.createElement("div");
  1102. textElem.id = "bytm-export-menu-text";
  1103. textElem.innerText = "Copy the following text to export your configuration:";
  1104. const textAreaElem = document.createElement("textarea");
  1105. textAreaElem.id = "bytm-export-menu-textarea";
  1106. textAreaElem.readOnly = true;
  1107. textAreaElem.value = JSON.stringify({ formatVersion: formatVersion, data: getFeatures() });
  1108. siteEvents.on("configChanged", (data) => {
  1109. const textAreaElem = document.querySelector("#bytm-export-menu-textarea");
  1110. if (textAreaElem)
  1111. textAreaElem.value = JSON.stringify({ formatVersion: formatVersion, data });
  1112. });
  1113. const footerElem = document.createElement("div");
  1114. footerElem.classList.add("bytm-menu-footer-right");
  1115. const copyBtnElem = document.createElement("button");
  1116. copyBtnElem.classList.add("bytm-btn");
  1117. copyBtnElem.innerText = "Copy to clipboard";
  1118. copyBtnElem.title = "Click to copy the configuration to your clipboard";
  1119. const copiedTextElem = document.createElement("span");
  1120. copiedTextElem.classList.add("bytm-menu-footer-copied");
  1121. copiedTextElem.innerText = "Copied!";
  1122. copiedTextElem.style.display = "none";
  1123. copyBtnElem.addEventListener("click", (evt) => menu_old_awaiter(this, void 0, void 0, function* () {
  1124. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  1125. const textAreaElem = document.querySelector("#bytm-export-menu-textarea");
  1126. if (textAreaElem) {
  1127. GM.setClipboard(textAreaElem.value);
  1128. copiedTextElem.style.display = "inline-block";
  1129. setTimeout(() => {
  1130. copiedTextElem.style.display = "none";
  1131. }, 3000);
  1132. }
  1133. }));
  1134. // flex-direction is row-reverse
  1135. footerElem.appendChild(copyBtnElem);
  1136. footerElem.appendChild(copiedTextElem);
  1137. menuBodyElem.appendChild(textElem);
  1138. menuBodyElem.appendChild(textAreaElem);
  1139. menuBodyElem.appendChild(footerElem);
  1140. menuContainer.appendChild(headerElem);
  1141. menuContainer.appendChild(menuBodyElem);
  1142. menuBgElem.appendChild(menuContainer);
  1143. document.body.appendChild(menuBgElem);
  1144. });
  1145. }
  1146. /** Closes the export menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  1147. function closeExportMenu(evt) {
  1148. if (!isExportMenuOpen)
  1149. return;
  1150. isExportMenuOpen = false;
  1151. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  1152. document.body.classList.remove("bytm-disable-scroll");
  1153. const menuBg = document.querySelector("#bytm-export-menu-bg");
  1154. if (!menuBg)
  1155. return warn("Couldn't find export menu background element");
  1156. menuBg.style.visibility = "hidden";
  1157. menuBg.style.display = "none";
  1158. }
  1159. /** Opens the export menu if it is closed */
  1160. function openExportMenu() {
  1161. if (isExportMenuOpen)
  1162. return;
  1163. isExportMenuOpen = true;
  1164. document.body.classList.add("bytm-disable-scroll");
  1165. const menuBg = document.querySelector("#bytm-export-menu-bg");
  1166. if (!menuBg)
  1167. return warn("Couldn't find export menu background element");
  1168. menuBg.style.visibility = "visible";
  1169. menuBg.style.display = "block";
  1170. }
  1171. let isImportMenuOpen = false;
  1172. /** Adds a menu to import a configuration from JSON (hidden by default) */
  1173. function addImportMenu() {
  1174. return menu_old_awaiter(this, void 0, void 0, function* () {
  1175. const menuBgElem = document.createElement("div");
  1176. menuBgElem.id = "bytm-import-menu-bg";
  1177. menuBgElem.classList.add("bytm-menu-bg");
  1178. menuBgElem.title = "Click here to close the menu";
  1179. menuBgElem.style.visibility = "hidden";
  1180. menuBgElem.style.display = "none";
  1181. menuBgElem.addEventListener("click", (e) => {
  1182. var _a;
  1183. if (isImportMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-import-menu-bg") {
  1184. closeImportMenu(e);
  1185. openMenu();
  1186. }
  1187. });
  1188. document.body.addEventListener("keydown", (e) => {
  1189. if (isImportMenuOpen && e.key === "Escape") {
  1190. closeImportMenu(e);
  1191. openMenu();
  1192. }
  1193. });
  1194. const menuContainer = document.createElement("div");
  1195. menuContainer.title = ""; // prevent bg title from propagating downwards
  1196. menuContainer.classList.add("bytm-menu");
  1197. menuContainer.id = "bytm-import-menu";
  1198. const headerElem = document.createElement("div");
  1199. headerElem.classList.add("bytm-menu-header");
  1200. const titleCont = document.createElement("div");
  1201. titleCont.id = "bytm-menu-titlecont";
  1202. titleCont.role = "heading";
  1203. titleCont.ariaLevel = "1";
  1204. const titleElem = document.createElement("h2");
  1205. titleElem.id = "bytm-menu-title";
  1206. titleElem.innerText = `${constants_scriptInfo.name} - Import Configuration`;
  1207. const closeElem = document.createElement("img");
  1208. closeElem.classList.add("bytm-menu-close");
  1209. closeElem.src = yield getResourceUrl("close");
  1210. closeElem.title = "Click to close the menu";
  1211. closeElem.addEventListener("click", (e) => {
  1212. closeImportMenu(e);
  1213. openMenu();
  1214. });
  1215. titleCont.appendChild(titleElem);
  1216. headerElem.appendChild(titleCont);
  1217. headerElem.appendChild(closeElem);
  1218. const menuBodyElem = document.createElement("div");
  1219. menuBodyElem.classList.add("bytm-menu-body");
  1220. const textElem = document.createElement("div");
  1221. textElem.id = "bytm-import-menu-text";
  1222. textElem.innerText = "Paste the configuration you want to import into the field below, then click the import button";
  1223. const textAreaElem = document.createElement("textarea");
  1224. textAreaElem.id = "bytm-import-menu-textarea";
  1225. const footerElem = document.createElement("div");
  1226. footerElem.classList.add("bytm-menu-footer-right");
  1227. const importBtnElem = document.createElement("button");
  1228. importBtnElem.classList.add("bytm-btn");
  1229. importBtnElem.innerText = "Import";
  1230. importBtnElem.title = "Click to import the configuration";
  1231. importBtnElem.addEventListener("click", (evt) => menu_old_awaiter(this, void 0, void 0, function* () {
  1232. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  1233. const textAreaElem = document.querySelector("#bytm-import-menu-textarea");
  1234. if (!textAreaElem)
  1235. return warn("Couldn't find import menu textarea element");
  1236. try {
  1237. const parsed = JSON.parse(textAreaElem.value.trim());
  1238. if (typeof parsed !== "object")
  1239. return alert("The imported data is not an object");
  1240. if (typeof parsed.formatVersion !== "number")
  1241. return alert("The imported data does not contain a format version");
  1242. if (typeof parsed.data !== "object")
  1243. return alert("The imported object does not contain any data");
  1244. if (parsed.formatVersion < formatVersion) {
  1245. let newData = JSON.parse(JSON.stringify(parsed.data));
  1246. const sortedMigrations = Object.entries(migrations)
  1247. .sort(([a], [b]) => Number(a) - Number(b));
  1248. let curFmtVer = Number(parsed.formatVersion);
  1249. for (const [fmtVer, migrationFunc] of sortedMigrations) {
  1250. const ver = Number(fmtVer);
  1251. if (curFmtVer < formatVersion && curFmtVer < ver) {
  1252. try {
  1253. const migRes = JSON.parse(JSON.stringify(migrationFunc(newData)));
  1254. newData = migRes instanceof Promise ? yield migRes : migRes;
  1255. curFmtVer = ver;
  1256. }
  1257. catch (err) {
  1258. console.error(`Error while running migration function for format version ${fmtVer}:`, err);
  1259. }
  1260. }
  1261. }
  1262. parsed.formatVersion = curFmtVer;
  1263. parsed.data = newData;
  1264. }
  1265. else if (parsed.formatVersion !== formatVersion)
  1266. return alert(`The imported data is in an unsupported format version (expected ${formatVersion} or lower, got ${parsed.formatVersion})`);
  1267. yield saveFeatures(parsed.data);
  1268. if (confirm("Successfully imported the configuration.\nDo you want to reload the page now to apply changes?"))
  1269. return location.reload();
  1270. siteEvents.emit("rebuildCfgMenu", parsed.data);
  1271. closeImportMenu();
  1272. openMenu();
  1273. }
  1274. catch (err) {
  1275. warn("Couldn't import configuration:", err);
  1276. alert("The imported data is not a valid configuration");
  1277. }
  1278. }));
  1279. footerElem.appendChild(importBtnElem);
  1280. menuBodyElem.appendChild(textElem);
  1281. menuBodyElem.appendChild(textAreaElem);
  1282. menuBodyElem.appendChild(footerElem);
  1283. menuContainer.appendChild(headerElem);
  1284. menuContainer.appendChild(menuBodyElem);
  1285. menuBgElem.appendChild(menuContainer);
  1286. document.body.appendChild(menuBgElem);
  1287. });
  1288. }
  1289. /** Closes the import menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  1290. function closeImportMenu(evt) {
  1291. if (!isImportMenuOpen)
  1292. return;
  1293. isImportMenuOpen = false;
  1294. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  1295. document.body.classList.remove("bytm-disable-scroll");
  1296. const menuBg = document.querySelector("#bytm-import-menu-bg");
  1297. const textAreaElem = document.querySelector("#bytm-import-menu-textarea");
  1298. if (textAreaElem)
  1299. textAreaElem.value = "";
  1300. if (!menuBg)
  1301. return warn("Couldn't find import menu background element");
  1302. menuBg.style.visibility = "hidden";
  1303. menuBg.style.display = "none";
  1304. }
  1305. /** Opens the import menu if it is closed */
  1306. function openImportMenu() {
  1307. if (isImportMenuOpen)
  1308. return;
  1309. isImportMenuOpen = true;
  1310. document.body.classList.add("bytm-disable-scroll");
  1311. const menuBg = document.querySelector("#bytm-import-menu-bg");
  1312. if (!menuBg)
  1313. return warn("Couldn't find import menu background element");
  1314. menuBg.style.visibility = "visible";
  1315. menuBg.style.display = "block";
  1316. }
  1317. let isChangelogMenuOpen = false;
  1318. /** Adds a changelog menu (hidden by default) */
  1319. function addChangelogMenu() {
  1320. return menu_old_awaiter(this, void 0, void 0, function* () {
  1321. const menuBgElem = document.createElement("div");
  1322. menuBgElem.id = "bytm-changelog-menu-bg";
  1323. menuBgElem.classList.add("bytm-menu-bg");
  1324. menuBgElem.title = "Click here to close the menu";
  1325. menuBgElem.style.visibility = "hidden";
  1326. menuBgElem.style.display = "none";
  1327. menuBgElem.addEventListener("click", (e) => {
  1328. var _a;
  1329. if (isChangelogMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-changelog-menu-bg") {
  1330. closeChangelogMenu(e);
  1331. openMenu();
  1332. }
  1333. });
  1334. document.body.addEventListener("keydown", (e) => {
  1335. if (isChangelogMenuOpen && e.key === "Escape") {
  1336. closeChangelogMenu(e);
  1337. openMenu();
  1338. }
  1339. });
  1340. const menuContainer = document.createElement("div");
  1341. menuContainer.title = ""; // prevent bg title from propagating downwards
  1342. menuContainer.classList.add("bytm-menu");
  1343. menuContainer.id = "bytm-changelog-menu";
  1344. const headerElem = document.createElement("div");
  1345. headerElem.classList.add("bytm-menu-header");
  1346. const titleCont = document.createElement("div");
  1347. titleCont.id = "bytm-menu-titlecont";
  1348. titleCont.role = "heading";
  1349. titleCont.ariaLevel = "1";
  1350. const titleElem = document.createElement("h2");
  1351. titleElem.id = "bytm-menu-title";
  1352. titleElem.innerText = `${constants_scriptInfo.name} - Changelog`;
  1353. const closeElem = document.createElement("img");
  1354. closeElem.classList.add("bytm-menu-close");
  1355. closeElem.src = yield getResourceUrl("close");
  1356. closeElem.title = "Click to close the menu";
  1357. closeElem.addEventListener("click", (e) => {
  1358. closeChangelogMenu(e);
  1359. openMenu();
  1360. });
  1361. titleCont.appendChild(titleElem);
  1362. headerElem.appendChild(titleCont);
  1363. headerElem.appendChild(closeElem);
  1364. const menuBodyElem = document.createElement("div");
  1365. menuBodyElem.id = "bytm-changelog-menu-body";
  1366. menuBodyElem.classList.add("bytm-menu-body");
  1367. const textElem = document.createElement("div");
  1368. textElem.id = "bytm-changelog-menu-text";
  1369. textElem.classList.add("bytm-markdown-container");
  1370. textElem.innerHTML = changelog;
  1371. menuBodyElem.appendChild(textElem);
  1372. menuContainer.appendChild(headerElem);
  1373. menuContainer.appendChild(menuBodyElem);
  1374. menuBgElem.appendChild(menuContainer);
  1375. document.body.appendChild(menuBgElem);
  1376. const anchors = document.querySelectorAll("#bytm-changelog-menu-text a");
  1377. for (const anchor of anchors)
  1378. anchor.target = "_blank";
  1379. });
  1380. }
  1381. /** Closes the changelog menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  1382. function closeChangelogMenu(evt) {
  1383. if (!isChangelogMenuOpen)
  1384. return;
  1385. isChangelogMenuOpen = false;
  1386. (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation();
  1387. document.body.classList.remove("bytm-disable-scroll");
  1388. const menuBg = document.querySelector("#bytm-changelog-menu-bg");
  1389. if (!menuBg)
  1390. return warn("Couldn't find changelog menu background element");
  1391. menuBg.style.visibility = "hidden";
  1392. menuBg.style.display = "none";
  1393. }
  1394. /** Opens the changelog menu if it is closed */
  1395. function openChangelogMenu() {
  1396. if (isChangelogMenuOpen)
  1397. return;
  1398. isChangelogMenuOpen = true;
  1399. document.body.classList.add("bytm-disable-scroll");
  1400. const menuBg = document.querySelector("#bytm-changelog-menu-bg");
  1401. if (!menuBg)
  1402. return warn("Couldn't find changelog menu background element");
  1403. menuBg.style.visibility = "visible";
  1404. menuBg.style.display = "block";
  1405. }
  1406. ;// CONCATENATED MODULE: ./src/features/input.ts
  1407. var input_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
  1408. function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
  1409. return new (P || (P = Promise))(function (resolve, reject) {
  1410. function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
  1411. function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
  1412. function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
  1413. step((generator = generator.apply(thisArg, _arguments || [])).next());
  1414. });
  1415. };
  1416. function initArrowKeySkip() {
  1417. document.addEventListener("keydown", (evt) => {
  1418. var _a, _b, _c;
  1419. if (!["ArrowLeft", "ArrowRight"].includes(evt.code))
  1420. return;
  1421. // discard the event when a (text) input is currently active, like when editing a playlist
  1422. if (["INPUT", "TEXTAREA", "SELECT"].includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "_"))
  1423. return utils_info(`Captured valid key to skip forward or backward but the current active element is <${(_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.tagName.toLowerCase()}>, so the keypress is ignored`);
  1424. onArrowKeyPress(evt);
  1425. });
  1426. log("Added arrow key press listener");
  1427. }
  1428. /** Called when the user presses any key, anywhere */
  1429. function onArrowKeyPress(evt) {
  1430. log(`Captured key '${evt.code}' in proxy listener`);
  1431. // ripped this stuff from the console, most of these are probably unnecessary but this was finnicky af and I am sick and tired of trial and error
  1432. const defaultProps = {
  1433. altKey: false,
  1434. ctrlKey: false,
  1435. metaKey: false,
  1436. shiftKey: false,
  1437. target: document.body,
  1438. currentTarget: document.body,
  1439. originalTarget: document.body,
  1440. explicitOriginalTarget: document.body,
  1441. srcElement: document.body,
  1442. type: "keydown",
  1443. bubbles: true,
  1444. cancelBubble: false,
  1445. cancelable: true,
  1446. isTrusted: true,
  1447. repeat: false,
  1448. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  1449. view: getUnsafeWindow(),
  1450. };
  1451. let invalidKey = false;
  1452. let keyProps = {};
  1453. switch (evt.code) {
  1454. case "ArrowLeft":
  1455. keyProps = {
  1456. code: "KeyH",
  1457. key: "h",
  1458. keyCode: 72,
  1459. which: 72,
  1460. };
  1461. break;
  1462. case "ArrowRight":
  1463. keyProps = {
  1464. code: "KeyL",
  1465. key: "l",
  1466. keyCode: 76,
  1467. which: 76,
  1468. };
  1469. break;
  1470. default:
  1471. invalidKey = true;
  1472. break;
  1473. }
  1474. if (!invalidKey) {
  1475. const proxyProps = Object.assign(Object.assign({ code: "" }, defaultProps), keyProps);
  1476. document.body.dispatchEvent(new KeyboardEvent("keydown", proxyProps));
  1477. log(`Dispatched proxy keydown event: [${evt.code}] -> [${proxyProps.code}]`);
  1478. }
  1479. else
  1480. warn(`Captured key '${evt.code}' has no defined behavior`);
  1481. }
  1482. /** switch sites only if current video time is greater than this value */
  1483. const videoTimeThreshold = 3;
  1484. /** Initializes the site switch feature */
  1485. function initSiteSwitch(domain) {
  1486. document.addEventListener("keydown", (e) => {
  1487. if (e.key === "F9")
  1488. switchSite(domain === "yt" ? "ytm" : "yt");
  1489. });
  1490. log("Initialized site switch listener");
  1491. }
  1492. /** Switches to the other site (between YT and YTM) */
  1493. function switchSite(newDomain) {
  1494. return input_awaiter(this, void 0, void 0, function* () {
  1495. try {
  1496. if (newDomain === "ytm" && !location.href.includes("/watch"))
  1497. return warn("Not on a video page, so the site switch is ignored");
  1498. let subdomain;
  1499. if (newDomain === "ytm")
  1500. subdomain = "music";
  1501. else if (newDomain === "yt")
  1502. subdomain = "www";
  1503. if (!subdomain)
  1504. throw new Error(`Unrecognized domain '${newDomain}'`);
  1505. disableBeforeUnload();
  1506. const { pathname, search, hash } = new URL(location.href);
  1507. const vt = yield getVideoTime();
  1508. log(`Found video time of ${vt} seconds`);
  1509. const cleanSearch = search.split("&")
  1510. .filter((param) => !param.match(/^\??t=/))
  1511. .join("&");
  1512. const newSearch = typeof vt === "number" && vt > videoTimeThreshold ?
  1513. cleanSearch.includes("?")
  1514. ? `${cleanSearch.startsWith("?")
  1515. ? cleanSearch
  1516. : "?" + cleanSearch}&t=${vt - 1}`
  1517. : `?t=${vt - 1}`
  1518. : cleanSearch;
  1519. const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
  1520. utils_info(`Switching to domain '${newDomain}' at ${newUrl}`);
  1521. location.assign(newUrl);
  1522. }
  1523. catch (err) {
  1524. error("Error while switching site:", err);
  1525. }
  1526. });
  1527. }
  1528. let beforeUnloadEnabled = true;
  1529. /** Disables the popup before leaving the site */
  1530. function disableBeforeUnload() {
  1531. beforeUnloadEnabled = false;
  1532. utils_info("Disabled popup before leaving the site");
  1533. }
  1534. /** (Re-)enables the popup before leaving the site */
  1535. function enableBeforeUnload() {
  1536. beforeUnloadEnabled = true;
  1537. info("Enabled popup before leaving the site");
  1538. }
  1539. /**
  1540. * Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload`
  1541. * event listeners before they can be called by the site.
  1542. */
  1543. function initBeforeUnloadHook() {
  1544. Error.stackTraceLimit = 1000; // default is 25 on FF so this should hopefully be more than enough
  1545. (function (original) {
  1546. // @ts-ignore
  1547. window.__proto__.addEventListener = function (...args) {
  1548. const origListener = typeof args[1] === "function" ? args[1] : args[1].handleEvent;
  1549. args[1] = function (...a) {
  1550. if (!beforeUnloadEnabled && args[0] === "beforeunload")
  1551. return utils_info("Prevented beforeunload event listener from being called");
  1552. else
  1553. return origListener.apply(this, a);
  1554. };
  1555. original.apply(this, args);
  1556. };
  1557. // @ts-ignore
  1558. })(window.__proto__.addEventListener);
  1559. }
  1560. /** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */
  1561. function initNumKeysSkip() {
  1562. document.addEventListener("keydown", (e) => {
  1563. var _a, _b, _c, _d;
  1564. if (!e.key.trim().match(/^[0-9]$/))
  1565. return;
  1566. if (isMenuOpen)
  1567. return;
  1568. // discard the event when a (text) input is currently active, like when editing a playlist or when the search bar is focused
  1569. if (document.activeElement !== document.body
  1570. && !["progress-bar"].includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : "_")
  1571. && !["BUTTON", "A"].includes((_d = (_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.tagName) !== null && _d !== void 0 ? _d : "_"))
  1572. return utils_info("Captured valid key to skip video to but an unexpected element is focused, so the keypress is ignored");
  1573. skipToTimeKey(Number(e.key));
  1574. });
  1575. log("Added number key press listener");
  1576. }
  1577. /** Returns the x position as a fraction of timeKey in maxWidth */
  1578. function getX(timeKey, maxWidth) {
  1579. if (timeKey >= 10)
  1580. return maxWidth;
  1581. return Math.floor((maxWidth / 10) * timeKey);
  1582. }
  1583. /** Calculates DOM-relative offsets of the bounding client rect of the passed element - see https://stackoverflow.com/a/442474/11187044 */
  1584. function getOffsetRect(elem) {
  1585. let left = 0;
  1586. let top = 0;
  1587. const rect = elem.getBoundingClientRect();
  1588. while (elem && !isNaN(elem.offsetLeft) && !isNaN(elem.offsetTop)) {
  1589. left += elem.offsetLeft - elem.scrollLeft;
  1590. top += elem.offsetTop - elem.scrollTop;
  1591. elem = elem.offsetParent;
  1592. }
  1593. return {
  1594. top,
  1595. left,
  1596. width: rect.width,
  1597. height: rect.height,
  1598. };
  1599. }
  1600. /** Emulates a click on the video progress bar at the position calculated from the passed time key (0-9) */
  1601. function skipToTimeKey(key) {
  1602. // not technically a progress element but behaves pretty much the same
  1603. const progressElem = document.querySelector("tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar");
  1604. if (!progressElem)
  1605. return;
  1606. const rect = getOffsetRect(progressElem);
  1607. const x = getX(key, rect.width);
  1608. const y = rect.top - rect.height / 2;
  1609. log(`Skipping to time key ${key} (x offset: ${x}px of ${rect.width}px)`);
  1610. const evt = new MouseEvent("mousedown", {
  1611. clientX: x,
  1612. clientY: Math.round(y),
  1613. // @ts-ignore
  1614. layerX: x,
  1615. layerY: Math.round(rect.height / 2),
  1616. target: progressElem,
  1617. bubbles: true,
  1618. shiftKey: false,
  1619. ctrlKey: false,
  1620. altKey: false,
  1621. metaKey: false,
  1622. button: 0,
  1623. buttons: 1,
  1624. which: 1,
  1625. isTrusted: true,
  1626. offsetX: 0,
  1627. offsetY: 0,
  1628. // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
  1629. view: getUnsafeWindow(),
  1630. });
  1631. progressElem.dispatchEvent(evt);
  1632. }
  1633. ;// CONCATENATED MODULE: ./src/features/lyrics.ts
  1634. var lyrics_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
  1635. function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
  1636. return new (P || (P = Promise))(function (resolve, reject) {
  1637. function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
  1638. function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
  1639. function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
  1640. step((generator = generator.apply(thisArg, _arguments || [])).next());
  1641. });
  1642. };
  1643. var __asyncValues = (undefined && undefined.__asyncValues) || function (o) {
  1644. if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
  1645. var m = o[Symbol.asyncIterator], i;
  1646. return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
  1647. function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
  1648. function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
  1649. };
  1650. /** Base URL of geniURL */
  1651. const geniUrlBase = "https://api.sv443.net/geniurl";
  1652. /** GeniURL endpoint that gives song metadata when provided with a `?q` or `?artist` and `?song` parameter - [more info](https://api.sv443.net/geniurl) */
  1653. const geniURLSearchTopUrl = `${geniUrlBase}/search/top`;
  1654. /**
  1655. * The threshold to pass to geniURL's fuzzy filtering.
  1656. * From fuse.js docs: At what point does the match algorithm give up. A threshold of 0.0 requires a perfect match (of both letters and location), a threshold of 1.0 would match anything.
  1657. * Set to undefined to use the default.
  1658. */
  1659. const threshold = 0.55;
  1660. /** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
  1661. const geniUrlRatelimitTimeframe = 30;
  1662. const thresholdParam = threshold ? `&threshold=${clamp(threshold, 0, 1)}` : "";
  1663. void thresholdParam; // TODO: remove once geniURL 1.4 is released
  1664. /** Cache with key format `ARTIST - SONG` (sanitized) and lyrics URLs as values. Used to prevent extraneous requests to geniURL. */
  1665. const lyricsUrlCache = new Map();
  1666. /** How many cache entries can exist at a time - this is used to cap memory usage */
  1667. const maxLyricsCacheSize = 100;
  1668. /**
  1669. * Returns the lyrics URL from the passed un-/sanitized artist and song name, or undefined if the entry doesn't exist yet.
  1670. * **The passed parameters need to be sanitized first!**
  1671. */
  1672. function getLyricsCacheEntry(artists, song) {
  1673. return lyricsUrlCache.get(`${artists} - ${song}`);
  1674. }
  1675. /** Adds the provided entry into the lyrics URL cache */
  1676. function addLyricsCacheEntry(artists, song, lyricsUrl) {
  1677. lyricsUrlCache.set(`${sanitizeArtists(artists)} - ${sanitizeSong(song)}`, lyricsUrl);
  1678. // delete oldest entry if cache gets too big
  1679. if (lyricsUrlCache.size > maxLyricsCacheSize)
  1680. lyricsUrlCache.delete([...lyricsUrlCache.keys()].at(-1));
  1681. }
  1682. let currentSongTitle = "";
  1683. /** Adds a lyrics button to the media controls bar */
  1684. function addMediaCtrlLyricsBtn() {
  1685. onSelector(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", { listener: addActualMediaCtrlLyricsBtn });
  1686. }
  1687. /** Actually adds the lyrics button after the like button renderer has been verified to exist */
  1688. function addActualMediaCtrlLyricsBtn(likeContainer) {
  1689. return lyrics_awaiter(this, void 0, void 0, function* () {
  1690. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
  1691. if (!songTitleElem)
  1692. return warn("Couldn't find song title element");
  1693. // run parallel without awaiting so the MutationObserver below can observe the title element in time
  1694. (() => lyrics_awaiter(this, void 0, void 0, function* () {
  1695. const gUrl = yield getCurrentLyricsUrl();
  1696. const linkElem = yield createLyricsBtn(gUrl !== null && gUrl !== void 0 ? gUrl : undefined);
  1697. linkElem.id = "betterytm-lyrics-button";
  1698. log("Inserted lyrics button into media controls bar");
  1699. insertAfter(likeContainer, linkElem);
  1700. }))();
  1701. currentSongTitle = songTitleElem.title;
  1702. const spinnerIconUrl = yield getResourceUrl("spinner");
  1703. const lyricsIconUrl = yield getResourceUrl("lyrics");
  1704. const errorIconUrl = yield getResourceUrl("error");
  1705. const onMutation = (mutations) => { var _a, mutations_1, mutations_1_1; return lyrics_awaiter(this, void 0, void 0, function* () {
  1706. var _b, e_1, _c, _d;
  1707. try {
  1708. for (_a = true, mutations_1 = __asyncValues(mutations); mutations_1_1 = yield mutations_1.next(), _b = mutations_1_1.done, !_b; _a = true) {
  1709. _d = mutations_1_1.value;
  1710. _a = false;
  1711. const mut = _d;
  1712. const newTitle = mut.target.title;
  1713. if (newTitle !== currentSongTitle && newTitle.length > 0) {
  1714. const lyricsBtn = document.querySelector("#betterytm-lyrics-button");
  1715. if (!lyricsBtn)
  1716. continue;
  1717. utils_info(`Song title changed from '${currentSongTitle}' to '${newTitle}'`);
  1718. lyricsBtn.style.cursor = "wait";
  1719. lyricsBtn.style.pointerEvents = "none";
  1720. const imgElem = lyricsBtn.querySelector("img");
  1721. imgElem.src = spinnerIconUrl;
  1722. imgElem.classList.add("bytm-spinner");
  1723. currentSongTitle = newTitle;
  1724. const url = yield getCurrentLyricsUrl(); // can take a second or two
  1725. imgElem.src = lyricsIconUrl;
  1726. imgElem.classList.remove("bytm-spinner");
  1727. if (!url) {
  1728. let artist, song;
  1729. if ("mediaSession" in navigator && navigator.mediaSession.metadata) {
  1730. artist = navigator.mediaSession.metadata.artist;
  1731. song = navigator.mediaSession.metadata.title;
  1732. }
  1733. const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : "";
  1734. imgElem.src = errorIconUrl;
  1735. imgElem.title = "Couldn't find lyrics URL - click to open the manual lyrics search";
  1736. lyricsBtn.style.cursor = "pointer";
  1737. lyricsBtn.style.pointerEvents = "all";
  1738. lyricsBtn.style.display = "inline-flex";
  1739. lyricsBtn.style.visibility = "visible";
  1740. lyricsBtn.href = `https://genius.com/search${query}`;
  1741. continue;
  1742. }
  1743. lyricsBtn.href = url;
  1744. lyricsBtn.title = "Open the current song's lyrics in a new tab";
  1745. lyricsBtn.style.cursor = "pointer";
  1746. lyricsBtn.style.visibility = "visible";
  1747. lyricsBtn.style.display = "inline-flex";
  1748. lyricsBtn.style.pointerEvents = "initial";
  1749. }
  1750. }
  1751. }
  1752. catch (e_1_1) { e_1 = { error: e_1_1 }; }
  1753. finally {
  1754. try {
  1755. if (!_a && !_b && (_c = mutations_1.return)) yield _c.call(mutations_1);
  1756. }
  1757. finally { if (e_1) throw e_1.error; }
  1758. }
  1759. }); };
  1760. // since YT and YTM don't reload the page on video change, MutationObserver needs to be used to watch for changes in the video title
  1761. const obs = new MutationObserver(onMutation);
  1762. obs.observe(songTitleElem, { attributes: true, attributeFilter: ["title"] });
  1763. });
  1764. }
  1765. /** Removes everything in parentheses from the passed song name */
  1766. function sanitizeSong(songName) {
  1767. const parensRegex = /\(.+\)/gmi;
  1768. const squareParensRegex = /\[.+\]/gmi;
  1769. // trim right after the song name:
  1770. const sanitized = songName
  1771. .replace(parensRegex, "")
  1772. .replace(squareParensRegex, "");
  1773. return sanitized.trim();
  1774. }
  1775. /** Removes the secondary artist (if it exists) from the passed artists string */
  1776. function sanitizeArtists(artists) {
  1777. artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at &bull; [•] character
  1778. if (artists.match(/&/))
  1779. artists = artists.split(/\s*&\s*/gm)[0];
  1780. if (artists.match(/,/))
  1781. artists = artists.split(/,\s*/gm)[0];
  1782. return artists.trim();
  1783. }
  1784. /** Returns the lyrics URL from genius for the currently selected song */
  1785. function getCurrentLyricsUrl() {
  1786. var _a;
  1787. return lyrics_awaiter(this, void 0, void 0, function* () {
  1788. try {
  1789. // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
  1790. const isVideo = typeof ((_a = document.querySelector("ytmusic-player")) === null || _a === void 0 ? void 0 : _a.hasAttribute("video-mode"));
  1791. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string");
  1792. const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string:first-child");
  1793. if (!songTitleElem || !songMetaElem || !songTitleElem.title)
  1794. return undefined;
  1795. const songNameRaw = songTitleElem.title;
  1796. const songName = sanitizeSong(songNameRaw);
  1797. const artistName = sanitizeArtists(songMetaElem.title);
  1798. /** Use when the current song is not a "real YTM song" with a static background, but rather a music video */
  1799. const getGeniusUrlVideo = () => lyrics_awaiter(this, void 0, void 0, function* () {
  1800. if (!songName.includes("-")) // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
  1801. return yield getGeniusUrl(artistName, songName);
  1802. const { artist, song } = splitVideoTitle(songName);
  1803. return yield getGeniusUrl(artist, song);
  1804. });
  1805. const url = isVideo ? yield getGeniusUrlVideo() : yield getGeniusUrl(artistName, songName);
  1806. return url;
  1807. }
  1808. catch (err) {
  1809. error("Couldn't resolve lyrics URL:", err);
  1810. return undefined;
  1811. }
  1812. });
  1813. }
  1814. /** Fetches the actual lyrics URL from geniURL - **the passed parameters need to be sanitized first!** */
  1815. function getGeniusUrl(artist, song) {
  1816. var _a, _b, _c;
  1817. return lyrics_awaiter(this, void 0, void 0, function* () {
  1818. try {
  1819. const cacheEntry = getLyricsCacheEntry(artist, song);
  1820. if (cacheEntry) {
  1821. utils_info(`Found lyrics URL in cache: ${cacheEntry}`);
  1822. return cacheEntry;
  1823. }
  1824. const startTs = Date.now();
  1825. const fetchUrl = `${geniURLSearchTopUrl}?disableFuzzy&artist=${encodeURIComponent(artist)}&song=${encodeURIComponent(song)}`;
  1826. log(`Requesting URL from geniURL at '${fetchUrl}'`);
  1827. const fetchRes = yield fetchAdvanced(fetchUrl);
  1828. if (fetchRes.status === 429) {
  1829. alert(`You are being rate limited.\nPlease wait ${(_a = fetchRes.headers.get("retry-after")) !== null && _a !== void 0 ? _a : geniUrlRatelimitTimeframe} seconds before requesting more lyrics.`);
  1830. return undefined;
  1831. }
  1832. else if (fetchRes.status < 200 || fetchRes.status >= 300) {
  1833. error(`Couldn't fetch lyrics URL from geniURL - status: ${fetchRes.status} - response: ${(_c = (_b = (yield fetchRes.json()).message) !== null && _b !== void 0 ? _b : yield fetchRes.text()) !== null && _c !== void 0 ? _c : "(none)"}`);
  1834. return undefined;
  1835. }
  1836. const result = yield fetchRes.json();
  1837. if (typeof result === "object" && result.error) {
  1838. error("Couldn't fetch lyrics URL:", result.message);
  1839. return undefined;
  1840. }
  1841. const url = result.url;
  1842. utils_info(`Found lyrics URL (after ${Date.now() - startTs}ms): ${url}`);
  1843. addLyricsCacheEntry(artist, song, url);
  1844. return url;
  1845. }
  1846. catch (err) {
  1847. error("Couldn't get lyrics URL due to error:", err);
  1848. return undefined;
  1849. }
  1850. });
  1851. }
  1852. /** Creates the base lyrics button element */
  1853. function createLyricsBtn(geniusUrl, hideIfLoading = true) {
  1854. return lyrics_awaiter(this, void 0, void 0, function* () {
  1855. const linkElem = document.createElement("a");
  1856. linkElem.className = "ytmusic-player-bar bytm-generic-btn";
  1857. linkElem.title = geniusUrl ? "Click to open this song's lyrics in a new tab" : "Loading lyrics URL...";
  1858. if (geniusUrl)
  1859. linkElem.href = geniusUrl;
  1860. linkElem.role = "button";
  1861. linkElem.target = "_blank";
  1862. linkElem.rel = "noopener noreferrer";
  1863. linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
  1864. linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
  1865. const imgElem = document.createElement("img");
  1866. imgElem.className = "bytm-generic-btn-img";
  1867. imgElem.src = yield getResourceUrl("lyrics");
  1868. linkElem.appendChild(imgElem);
  1869. return linkElem;
  1870. });
  1871. }
  1872. /** Splits a video title that contains a hyphen into an artist and song */
  1873. function splitVideoTitle(title) {
  1874. const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v);
  1875. return { artist, song: rest.join("-") };
  1876. }
  1877. ;// CONCATENATED MODULE: ./src/features/layout.ts
  1878. var layout_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
  1879. function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
  1880. return new (P || (P = Promise))(function (resolve, reject) {
  1881. function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
  1882. function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
  1883. function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
  1884. step((generator = generator.apply(thisArg, _arguments || [])).next());
  1885. });
  1886. };
  1887. let features;
  1888. function preInitLayout(feats) {
  1889. features = feats;
  1890. }
  1891. let menuOpenAmt = 0, logoExchanged = false;
  1892. /** Adds a watermark beneath the logo */
  1893. function addWatermark() {
  1894. const watermark = document.createElement("a");
  1895. watermark.role = "button";
  1896. watermark.id = "bytm-watermark";
  1897. watermark.className = "style-scope ytmusic-nav-bar bytm-no-select";
  1898. watermark.innerText = constants_scriptInfo.name;
  1899. watermark.title = "Open menu";
  1900. watermark.tabIndex = 1000;
  1901. improveLogo();
  1902. watermark.addEventListener("click", (e) => {
  1903. e.stopPropagation();
  1904. menuOpenAmt++;
  1905. if ((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
  1906. openMenu();
  1907. if ((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
  1908. exchangeLogo();
  1909. });
  1910. // when using the tab key to navigate
  1911. watermark.addEventListener("keydown", (e) => {
  1912. if (e.key === "Enter") {
  1913. e.stopPropagation();
  1914. menuOpenAmt++;
  1915. if ((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
  1916. openMenu();
  1917. if ((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
  1918. exchangeLogo();
  1919. }
  1920. });
  1921. onSelector("ytmusic-nav-bar #left-content", {
  1922. listener: (logoElem) => insertAfter(logoElem, watermark),
  1923. });
  1924. log("Added watermark element");
  1925. }
  1926. /** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
  1927. function improveLogo() {
  1928. return layout_awaiter(this, void 0, void 0, function* () {
  1929. try {
  1930. const res = yield fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
  1931. const svg = yield res.text();
  1932. onSelector("ytmusic-logo a", {
  1933. listener: (logoElem) => {
  1934. var _a;
  1935. logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
  1936. logoElem.innerHTML = svg;
  1937. logoElem.querySelectorAll("ellipse").forEach((e) => {
  1938. e.classList.add("bytm-mod-logo-ellipse");
  1939. });
  1940. (_a = logoElem.querySelector("path")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-mod-logo-path");
  1941. log("Swapped logo to inline SVG");
  1942. },
  1943. });
  1944. }
  1945. catch (err) {
  1946. error("Couldn't improve logo due to an error:", err);
  1947. }
  1948. });
  1949. }
  1950. /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
  1951. function exchangeLogo() {
  1952. onSelector(".bytm-mod-logo", {
  1953. listener: (logoElem) => layout_awaiter(this, void 0, void 0, function* () {
  1954. if (logoElem.classList.contains("bytm-logo-exchanged"))
  1955. return;
  1956. logoExchanged = true;
  1957. logoElem.classList.add("bytm-logo-exchanged");
  1958. const iconUrl = yield getResourceUrl("icon");
  1959. const newLogo = document.createElement("img");
  1960. newLogo.className = "bytm-mod-logo-img";
  1961. newLogo.src = iconUrl;
  1962. logoElem.insertBefore(newLogo, logoElem.querySelector("svg"));
  1963. document.head.querySelectorAll("link[rel=\"icon\"]").forEach((e) => {
  1964. e.href = iconUrl;
  1965. });
  1966. setTimeout(() => {
  1967. logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove());
  1968. }, 1000);
  1969. }),
  1970. });
  1971. }
  1972. /** Called whenever the avatar popover menu exists to add a BYTM-Configuration button to the user menu popover */
  1973. function addConfigMenuOption(container) {
  1974. return layout_awaiter(this, void 0, void 0, function* () {
  1975. const cfgOptElem = document.createElement("div");
  1976. cfgOptElem.role = "button";
  1977. cfgOptElem.className = "bytm-cfg-menu-option";
  1978. const cfgOptItemElem = document.createElement("div");
  1979. cfgOptItemElem.className = "bytm-cfg-menu-option-item";
  1980. cfgOptItemElem.ariaLabel = cfgOptItemElem.title = "Click to open BetterYTM's configuration menu";
  1981. cfgOptItemElem.addEventListener("click", (e) => layout_awaiter(this, void 0, void 0, function* () {
  1982. const settingsBtnElem = document.querySelector("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
  1983. settingsBtnElem === null || settingsBtnElem === void 0 ? void 0 : settingsBtnElem.click();
  1984. menuOpenAmt++;
  1985. yield pauseFor(100);
  1986. if ((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
  1987. openMenu();
  1988. if ((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
  1989. exchangeLogo();
  1990. }));
  1991. const cfgOptIconElem = document.createElement("img");
  1992. cfgOptIconElem.className = "bytm-cfg-menu-option-icon";
  1993. cfgOptIconElem.src = yield getResourceUrl("icon");
  1994. const cfgOptTextElem = document.createElement("div");
  1995. cfgOptTextElem.className = "bytm-cfg-menu-option-text";
  1996. cfgOptTextElem.innerText = "BetterYTM Configuration";
  1997. cfgOptItemElem.appendChild(cfgOptIconElem);
  1998. cfgOptItemElem.appendChild(cfgOptTextElem);
  1999. cfgOptElem.appendChild(cfgOptItemElem);
  2000. container.appendChild(cfgOptElem);
  2001. log("Added BYTM-Configuration button to menu popover");
  2002. });
  2003. }
  2004. /** Removes the "Upgrade" / YT Music Premium tab from the sidebar */
  2005. function removeUpgradeTab() {
  2006. onSelector("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
  2007. listener: (tabElemLarge) => {
  2008. tabElemLarge.remove();
  2009. log("Removed large upgrade tab");
  2010. },
  2011. });
  2012. onSelector("ytmusic-app-layout #mini-guide ytmusic-guide-renderer #sections ytmusic-guide-section-renderer[is-primary] #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
  2013. listener: (tabElemSmall) => {
  2014. tabElemSmall.remove();
  2015. log("Removed small upgrade tab");
  2016. },
  2017. });
  2018. }
  2019. function initVolumeFeatures() {
  2020. // not technically an input element but behaves pretty much the same
  2021. onSelector("tp-yt-paper-slider#volume-slider", {
  2022. listener: (sliderElem) => {
  2023. const volSliderCont = document.createElement("div");
  2024. volSliderCont.id = "bytm-vol-slider-cont";
  2025. addParent(sliderElem, volSliderCont);
  2026. if (typeof features.volumeSliderSize === "number")
  2027. setVolSliderSize();
  2028. if (features.volumeSliderLabel)
  2029. addVolumeSliderLabel(sliderElem, volSliderCont);
  2030. setVolSliderStep(sliderElem);
  2031. },
  2032. });
  2033. }
  2034. /** Adds a percentage label to the volume slider and tooltip */
  2035. function addVolumeSliderLabel(sliderElem, sliderCont) {
  2036. const labelElem = document.createElement("div");
  2037. labelElem.className = "bytm-vol-slider-label";
  2038. labelElem.innerText = `${sliderElem.value}%`;
  2039. // prevent video from minimizing
  2040. labelElem.addEventListener("click", (e) => e.stopPropagation());
  2041. const getLabelTexts = (slider) => {
  2042. const labelShort = `${slider.value}%`;
  2043. const sensText = features.volumeSliderStep !== featInfo.volumeSliderStep.default ? ` (Sensitivity: ${slider.step}%)` : "";
  2044. const labelFull = `Volume: ${labelShort}${sensText}`;
  2045. return { labelShort, labelFull };
  2046. };
  2047. const { labelFull } = getLabelTexts(sliderElem);
  2048. sliderCont.setAttribute("title", labelFull);
  2049. sliderElem.setAttribute("title", labelFull);
  2050. sliderElem.setAttribute("aria-valuetext", labelFull);
  2051. const updateLabel = () => {
  2052. const { labelShort, labelFull } = getLabelTexts(sliderElem);
  2053. sliderCont.setAttribute("title", labelFull);
  2054. sliderElem.setAttribute("title", labelFull);
  2055. sliderElem.setAttribute("aria-valuetext", labelFull);
  2056. const labelElem2 = document.querySelector(".bytm-vol-slider-label");
  2057. if (labelElem2)
  2058. labelElem2.innerText = labelShort;
  2059. };
  2060. sliderElem.addEventListener("change", () => updateLabel());
  2061. onSelector("#bytm-vol-slider-cont", {
  2062. listener: (volumeCont) => {
  2063. volumeCont.appendChild(labelElem);
  2064. },
  2065. });
  2066. let lastSliderVal = Number(sliderElem.value);
  2067. // show label if hovering over slider or slider is focused
  2068. const sliderHoverObserver = new MutationObserver(() => {
  2069. if (sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem)
  2070. labelElem.classList.add("bytm-visible");
  2071. else if (labelElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem)
  2072. labelElem.classList.remove("bytm-visible");
  2073. if (Number(sliderElem.value) !== lastSliderVal) {
  2074. lastSliderVal = Number(sliderElem.value);
  2075. updateLabel();
  2076. }
  2077. });
  2078. sliderHoverObserver.observe(sliderElem, {
  2079. attributes: true,
  2080. });
  2081. }
  2082. /** Sets the volume slider to a set size */
  2083. function setVolSliderSize() {
  2084. const { volumeSliderSize: size } = features;
  2085. if (typeof size !== "number" || isNaN(Number(size)))
  2086. return;
  2087. addGlobalStyle(`\
  2088. #bytm-vol-slider-cont tp-yt-paper-slider#volume-slider {
  2089. width: ${size}px !important;
  2090. }`);
  2091. }
  2092. /** Sets the `step` attribute of the volume slider */
  2093. function setVolSliderStep(sliderElem) {
  2094. sliderElem.setAttribute("step", String(features.volumeSliderStep));
  2095. }
  2096. function initQueueButtons() {
  2097. const addQueueBtns = (evt) => {
  2098. let amt = 0;
  2099. for (const queueItm of evt.childNodes) {
  2100. if (!queueItm.classList.contains("bytm-has-queue-btns")) {
  2101. addQueueButtons(queueItm);
  2102. amt++;
  2103. }
  2104. }
  2105. if (amt > 0)
  2106. log(`Added buttons to ${amt} new queue ${autoPlural("item", amt)}`);
  2107. };
  2108. siteEvents.on("queueChanged", addQueueBtns);
  2109. siteEvents.on("autoplayQueueChanged", addQueueBtns);
  2110. const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
  2111. if (queueItems.length === 0)
  2112. return;
  2113. queueItems.forEach(itm => addQueueButtons(itm));
  2114. log(`Added buttons to ${queueItems.length} existing queue ${autoPlural("item", queueItems)}`);
  2115. }
  2116. /**
  2117. * Adds the buttons to each item in the current song queue.
  2118. * Also observes for changes to add new buttons to new items in the queue.
  2119. * @param queueItem The element with tagname `ytmusic-player-queue-item` to add queue buttons to
  2120. */
  2121. function addQueueButtons(queueItem) {
  2122. var _a;
  2123. return layout_awaiter(this, void 0, void 0, function* () {
  2124. const queueBtnsCont = document.createElement("div");
  2125. queueBtnsCont.className = "bytm-queue-btn-container";
  2126. const lyricsIconUrl = yield getResourceUrl("lyrics");
  2127. const deleteIconUrl = yield getResourceUrl("delete");
  2128. let lyricsBtnElem;
  2129. if (features.lyricsQueueButton) {
  2130. lyricsBtnElem = yield createLyricsBtn(undefined, false);
  2131. lyricsBtnElem.title = "Open this song's lyrics in a new tab";
  2132. lyricsBtnElem.style.display = "inline-flex";
  2133. lyricsBtnElem.style.visibility = "initial";
  2134. lyricsBtnElem.style.pointerEvents = "initial";
  2135. lyricsBtnElem.addEventListener("click", (e) => layout_awaiter(this, void 0, void 0, function* () {
  2136. e.stopPropagation();
  2137. const songInfo = queueItem.querySelector(".song-info");
  2138. if (!songInfo)
  2139. return;
  2140. const [songEl, artistEl] = songInfo.querySelectorAll("yt-formatted-string");
  2141. const song = songEl === null || songEl === void 0 ? void 0 : songEl.innerText;
  2142. const artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.innerText;
  2143. if (!song || !artist)
  2144. return;
  2145. let lyricsUrl;
  2146. const artistsSan = sanitizeArtists(artist);
  2147. const songSan = sanitizeSong(song);
  2148. const splitTitle = splitVideoTitle(songSan);
  2149. const cachedLyricsUrl = songSan.includes("-")
  2150. ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
  2151. : getLyricsCacheEntry(artistsSan, songSan);
  2152. if (cachedLyricsUrl)
  2153. lyricsUrl = cachedLyricsUrl;
  2154. else if (!songInfo.hasAttribute("data-bytm-loading")) {
  2155. const imgEl = lyricsBtnElem === null || lyricsBtnElem === void 0 ? void 0 : lyricsBtnElem.querySelector("img");
  2156. if (!imgEl)
  2157. return;
  2158. if (!cachedLyricsUrl) {
  2159. songInfo.setAttribute("data-bytm-loading", "");
  2160. imgEl.src = yield getResourceUrl("spinner");
  2161. imgEl.classList.add("bytm-spinner");
  2162. }
  2163. lyricsUrl = cachedLyricsUrl !== null && cachedLyricsUrl !== void 0 ? cachedLyricsUrl : yield getGeniusUrl(artistsSan, songSan);
  2164. const resetImgElem = () => {
  2165. imgEl.src = lyricsIconUrl;
  2166. imgEl.classList.remove("bytm-spinner");
  2167. };
  2168. if (!cachedLyricsUrl) {
  2169. songInfo.removeAttribute("data-bytm-loading");
  2170. // so the new image doesn't "blink"
  2171. setTimeout(resetImgElem, 100);
  2172. }
  2173. if (!lyricsUrl) {
  2174. resetImgElem();
  2175. if (confirm("Couldn't find a lyrics page for this song.\nDo you want to open genius.com to manually search for it?"))
  2176. openInNewTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} ${songSan}`)}`);
  2177. return;
  2178. }
  2179. }
  2180. lyricsUrl && openInNewTab(lyricsUrl);
  2181. }));
  2182. }
  2183. let deleteBtnElem;
  2184. if (features.deleteFromQueueButton) {
  2185. deleteBtnElem = document.createElement("a");
  2186. Object.assign(deleteBtnElem, {
  2187. title: "Remove this song from the queue",
  2188. className: "ytmusic-player-bar bytm-delete-from-queue bytm-generic-btn",
  2189. role: "button",
  2190. });
  2191. deleteBtnElem.style.visibility = "initial";
  2192. deleteBtnElem.addEventListener("click", (e) => layout_awaiter(this, void 0, void 0, function* () {
  2193. e.stopPropagation();
  2194. // container of the queue item popup menu - element gets reused for every queue item
  2195. let queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
  2196. try {
  2197. // three dots button to open the popup menu of a queue item
  2198. const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape button");
  2199. if (queuePopupCont)
  2200. queuePopupCont.setAttribute("data-bytm-hidden", "true");
  2201. dotsBtnElem === null || dotsBtnElem === void 0 ? void 0 : dotsBtnElem.click();
  2202. yield pauseFor(20);
  2203. queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
  2204. queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.setAttribute("data-bytm-hidden", "true");
  2205. // a little bit janky and unreliable but the only way afaik
  2206. const removeFromQueueBtn = queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.querySelector("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)");
  2207. yield pauseFor(10);
  2208. removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click();
  2209. }
  2210. catch (err) {
  2211. error("Couldn't remove song from queue due to error:", err);
  2212. }
  2213. finally {
  2214. queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.removeAttribute("data-bytm-hidden");
  2215. }
  2216. }));
  2217. const imgElem = document.createElement("img");
  2218. imgElem.className = "bytm-generic-btn-img";
  2219. imgElem.src = deleteIconUrl;
  2220. deleteBtnElem.appendChild(imgElem);
  2221. }
  2222. lyricsBtnElem && queueBtnsCont.appendChild(lyricsBtnElem);
  2223. deleteBtnElem && queueBtnsCont.appendChild(deleteBtnElem);
  2224. (_a = queueItem.querySelector(".song-info")) === null || _a === void 0 ? void 0 : _a.appendChild(queueBtnsCont);
  2225. queueItem.classList.add("bytm-has-queue-btns");
  2226. });
  2227. }
  2228. /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
  2229. function addAnchorImprovements() {
  2230. try {
  2231. const preventDefault = (e) => e.preventDefault();
  2232. /** Adds anchor improvements to &lt;ytmusic-responsive-list-item-renderer&gt; */
  2233. const addListItemAnchors = (items) => {
  2234. var _a;
  2235. for (const item of items) {
  2236. if (item.classList.contains("bytm-anchor-improved"))
  2237. continue;
  2238. item.classList.add("bytm-anchor-improved");
  2239. const thumbnailElem = item.querySelector(".left-items");
  2240. const titleElem = item.querySelector(".title-column .title a");
  2241. if (!thumbnailElem || !titleElem)
  2242. continue;
  2243. const anchorElem = document.createElement("a");
  2244. anchorElem.classList.add("bytm-anchor", "bytm-carousel-shelf-anchor");
  2245. anchorElem.href = (_a = titleElem === null || titleElem === void 0 ? void 0 : titleElem.href) !== null && _a !== void 0 ? _a : "#";
  2246. anchorElem.target = "_self";
  2247. anchorElem.role = "button";
  2248. anchorElem.addEventListener("click", preventDefault);
  2249. addParent(thumbnailElem, anchorElem);
  2250. }
  2251. };
  2252. // home page
  2253. onSelector("#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
  2254. continuous: true,
  2255. all: true,
  2256. listener: addListItemAnchors,
  2257. });
  2258. // related tab in /watch
  2259. onSelector("ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
  2260. continuous: true,
  2261. all: true,
  2262. listener: addListItemAnchors,
  2263. });
  2264. // playlists
  2265. onSelector("#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
  2266. continuous: true,
  2267. all: true,
  2268. listener: addListItemAnchors,
  2269. });
  2270. // generic shelves
  2271. onSelector("#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
  2272. continuous: true,
  2273. all: true,
  2274. listener: addListItemAnchors,
  2275. });
  2276. }
  2277. catch (err) {
  2278. error("Couldn't improve carousel shelf anchors due to an error:", err);
  2279. }
  2280. try {
  2281. const addSidebarAnchors = (sidebarCont) => {
  2282. const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item");
  2283. improveSidebarAnchors(items);
  2284. return items.length;
  2285. };
  2286. onSelector("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
  2287. listener: (sidebarCont) => {
  2288. const itemsAmt = addSidebarAnchors(sidebarCont);
  2289. log(`Added anchors around ${itemsAmt} sidebar ${autoPlural("item", itemsAmt)}`);
  2290. },
  2291. });
  2292. onSelector("ytmusic-app-layout #mini-guide ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
  2293. listener: (miniSidebarCont) => {
  2294. const itemsAmt = addSidebarAnchors(miniSidebarCont);
  2295. log(`Added anchors around ${itemsAmt} mini sidebar ${autoPlural("item", itemsAmt)}`);
  2296. },
  2297. });
  2298. }
  2299. catch (err) {
  2300. error("Couldn't add anchors to sidebar items due to an error:", err);
  2301. }
  2302. }
  2303. const sidebarPaths = [
  2304. "/",
  2305. "/explore",
  2306. "/library",
  2307. ];
  2308. /**
  2309. * Adds anchors to the sidebar items so they can be opened in a new tab
  2310. * @param sidebarItem
  2311. */
  2312. function improveSidebarAnchors(sidebarItems) {
  2313. sidebarItems.forEach((item, i) => {
  2314. var _a;
  2315. const anchorElem = document.createElement("a");
  2316. anchorElem.classList.add("bytm-anchor", "bytm-no-select");
  2317. anchorElem.role = "button";
  2318. anchorElem.target = "_self";
  2319. anchorElem.href = (_a = sidebarPaths[i]) !== null && _a !== void 0 ? _a : "#";
  2320. anchorElem.title = "Middle click to open in a new tab";
  2321. anchorElem.addEventListener("click", (e) => {
  2322. e.preventDefault();
  2323. });
  2324. addParent(item, anchorElem);
  2325. });
  2326. }
  2327. /** Closes toasts after a set amount of time */
  2328. function initAutoCloseToasts() {
  2329. try {
  2330. const animTimeout = 300;
  2331. const closeTimeout = Math.max(features.closeToastsTimeout * 1000 + animTimeout, animTimeout);
  2332. onSelector("tp-yt-paper-toast#toast", {
  2333. all: true,
  2334. continuous: true,
  2335. listener: (toastElems) => layout_awaiter(this, void 0, void 0, function* () {
  2336. var _a;
  2337. for (const toastElem of toastElems) {
  2338. if (!toastElem.hasAttribute("allow-click-through"))
  2339. continue;
  2340. if (toastElem.classList.contains("bytm-closing"))
  2341. continue;
  2342. toastElem.classList.add("bytm-closing");
  2343. yield pauseFor(closeTimeout);
  2344. toastElem.classList.remove("paper-toast-open");
  2345. log(`Automatically closed toast '${(_a = toastElem.querySelector("#text-container yt-formatted-string")) === null || _a === void 0 ? void 0 : _a.innerText}' after ${features.closeToastsTimeout * 1000}ms`);
  2346. // wait for the transition to finish
  2347. yield pauseFor(animTimeout);
  2348. toastElem.style.display = "none";
  2349. }
  2350. }),
  2351. });
  2352. log("Initialized automatic toast closing");
  2353. }
  2354. catch (err) {
  2355. error("Error in automatic toast closing:", err);
  2356. }
  2357. }
  2358. /** Continuously removes the ?si tracking parameter from share URLs */
  2359. function removeShareTrackingParam() {
  2360. onSelector("yt-copy-link-renderer input#share-url", {
  2361. continuous: true,
  2362. listener: (inputElem) => {
  2363. try {
  2364. const url = new URL(inputElem.value);
  2365. if (!url.searchParams.has("si"))
  2366. return;
  2367. url.searchParams.delete("si");
  2368. inputElem.value = String(url);
  2369. log(`Removed tracking parameter from share link: ${url}`);
  2370. }
  2371. catch (err) {
  2372. warn("Couldn't remove tracking parameter from share link due to error:", err);
  2373. }
  2374. },
  2375. });
  2376. }
  2377. /** Applies global CSS to fix various spacings */
  2378. function fixSpacing() {
  2379. addGlobalStyle(`\
  2380. ytmusic-carousel-shelf-renderer ytmusic-carousel ytmusic-responsive-list-item-renderer {
  2381. margin-bottom: var(--ytmusic-carousel-item-margin-bottom, 16px) !important;
  2382. }
  2383. ytmusic-carousel-shelf-renderer ytmusic-carousel {
  2384. --ytmusic-carousel-item-height: 60px !important;
  2385. }`);
  2386. }
  2387. /** Adds a button to the queue to scroll to the active song */
  2388. function addScrollToActiveBtn() {
  2389. onSelector(".side-panel.modular #tabsContent tp-yt-paper-tab:nth-of-type(1)", {
  2390. listener: (tabElem) => layout_awaiter(this, void 0, void 0, function* () {
  2391. const containerElem = document.createElement("div");
  2392. containerElem.id = "bytm-scroll-to-active-btn-cont";
  2393. const linkElem = document.createElement("div");
  2394. linkElem.id = "bytm-scroll-to-active-btn";
  2395. linkElem.className = "ytmusic-player-bar bytm-generic-btn";
  2396. linkElem.title = "Click to scroll to the currently playing song";
  2397. linkElem.role = "button";
  2398. const imgElem = document.createElement("img");
  2399. imgElem.className = "bytm-generic-btn-img";
  2400. imgElem.src = yield getResourceUrl("skip_to");
  2401. linkElem.addEventListener("click", (e) => {
  2402. const activeItem = document.querySelector(".side-panel.modular .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], .side-panel.modular .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], .side-panel.modular .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
  2403. if (!activeItem)
  2404. return;
  2405. e.preventDefault();
  2406. e.stopImmediatePropagation();
  2407. activeItem.scrollIntoView({
  2408. behavior: "smooth",
  2409. block: "center",
  2410. inline: "center",
  2411. });
  2412. });
  2413. linkElem.appendChild(imgElem);
  2414. containerElem.appendChild(linkElem);
  2415. tabElem.appendChild(containerElem);
  2416. }),
  2417. });
  2418. }
  2419. ;// CONCATENATED MODULE: ./src/menu/menu.ts
  2420. // REQUIREMENTS:
  2421. // - modal using the <dialog> element
  2422. // - sections with headers
  2423. // - support for "custom widgets"
  2424. // - debounce or save on button press to store new configuration
  2425. // - much better scaling including no vw and vh units
  2426. // - cleanup function per feature so a page reload is not always needed
  2427. /**
  2428. * The base selector values for the menu tabs
  2429. * Header selector format: `#${baseValue}-header`
  2430. * Content selector format: `#${baseValue}-content`
  2431. */
  2432. const tabsSelectors = {
  2433. options: "bytm-menu-tab-options",
  2434. info: "bytm-menu-tab-info",
  2435. changelog: "bytm-menu-tab-changelog",
  2436. };
  2437. /** Called from init(), before DOMContentLoaded is fired */
  2438. function initMenu() {
  2439. document.addEventListener("DOMContentLoaded", () => {
  2440. // create menu container
  2441. const menuContainer = document.createElement("div");
  2442. menuContainer.id = "bytm-menu-container";
  2443. // add menu html
  2444. menuContainer.innerHTML = menuContent;
  2445. document.body.appendChild(menuContainer);
  2446. initMenuContents();
  2447. });
  2448. }
  2449. function initMenuContents() {
  2450. var _a;
  2451. // hook events
  2452. for (const tab in tabsSelectors) {
  2453. const selector = tabsSelectors[tab];
  2454. (_a = document.querySelector(`#${selector}-header`)) === null || _a === void 0 ? void 0 : _a.addEventListener("click", () => {
  2455. setActiveTab(tab);
  2456. });
  2457. }
  2458. // init tab contents
  2459. initOptionsContent();
  2460. initInfoContent();
  2461. initChangelogContent();
  2462. }
  2463. /** Opens the specified tab */
  2464. function setActiveTab(tab) {
  2465. const tabs = Object.assign({}, tabsSelectors);
  2466. delete tabs[tab];
  2467. // disable all but new active tab
  2468. for (const [, val] of Object.entries(tabs)) {
  2469. document.querySelector(`#${val}-header`).dataset.active = "false";
  2470. document.querySelector(`#${val}-content`).dataset.active = "false";
  2471. }
  2472. // enable new active tab
  2473. document.querySelector(`#${tabsSelectors[tab]}-header`).dataset.active = "true";
  2474. document.querySelector(`#${tabsSelectors[tab]}-content`).dataset.active = "true";
  2475. }
  2476. /** Opens the modal menu dialog */
  2477. function menu_openMenu() {
  2478. var _a;
  2479. (_a = document.querySelector("#bytm-menu-dialog")) === null || _a === void 0 ? void 0 : _a.showModal();
  2480. }
  2481. /** Closes the modal menu dialog */
  2482. function menu_closeMenu() {
  2483. var _a;
  2484. (_a = document.querySelector("#bytm-menu-dialog")) === null || _a === void 0 ? void 0 : _a.close();
  2485. }
  2486. function initOptionsContent() {
  2487. const tab = document.querySelector("#bytm-menu-tab-options-content");
  2488. void tab;
  2489. }
  2490. function initInfoContent() {
  2491. const tab = document.querySelector("#bytm-menu-tab-info-content");
  2492. void tab;
  2493. }
  2494. function initChangelogContent() {
  2495. const tab = document.querySelector("#bytm-menu-tab-changelog-content");
  2496. tab.innerHTML = changelogContent;
  2497. }
  2498. ;// CONCATENATED MODULE: ./src/features/index.ts
  2499. /** Mapping of feature category identifiers to readable strings */
  2500. const categoryNames = {
  2501. input: "Input",
  2502. layout: "Layout",
  2503. lyrics: "Lyrics",
  2504. misc: "Other",
  2505. };
  2506. /** Contains all possible features with their default values and other configuration */
  2507. const featInfo = {
  2508. removeUpgradeTab: {
  2509. desc: "Remove the Upgrade / Premium tab",
  2510. type: "toggle",
  2511. category: "layout",
  2512. default: true,
  2513. },
  2514. volumeSliderLabel: {
  2515. desc: "Add a percentage label next to the volume slider",
  2516. type: "toggle",
  2517. category: "layout",
  2518. default: true,
  2519. },
  2520. volumeSliderSize: {
  2521. desc: "The width of the volume slider in pixels",
  2522. type: "number",
  2523. category: "layout",
  2524. min: 50,
  2525. max: 500,
  2526. step: 5,
  2527. default: 150,
  2528. unit: "px",
  2529. },
  2530. volumeSliderStep: {
  2531. desc: "Volume slider sensitivity (by how little percent the volume can be changed at a time)",
  2532. type: "slider",
  2533. category: "layout",
  2534. min: 1,
  2535. max: 25,
  2536. default: 2,
  2537. unit: "%",
  2538. },
  2539. watermarkEnabled: {
  2540. desc: `Show a ${constants_scriptInfo.name} watermark under the site logo that opens this config menu`,
  2541. type: "toggle",
  2542. category: "layout",
  2543. default: true,
  2544. },
  2545. deleteFromQueueButton: {
  2546. desc: "Add a button to each song in the queue to quickly remove it",
  2547. type: "toggle",
  2548. category: "layout",
  2549. default: true,
  2550. },
  2551. closeToastsTimeout: {
  2552. desc: "After how many seconds to close permanent notifications - 0 to only close them manually (default behavior)",
  2553. type: "number",
  2554. category: "layout",
  2555. min: 0,
  2556. max: 30,
  2557. step: 0.5,
  2558. default: 0,
  2559. unit: "s",
  2560. },
  2561. removeShareTrackingParam: {
  2562. desc: "Remove the tracking parameter (&si=...) from links in the share popup",
  2563. type: "toggle",
  2564. category: "layout",
  2565. default: true,
  2566. },
  2567. fixSpacing: {
  2568. desc: "Fix spacing issues in the layout",
  2569. type: "toggle",
  2570. category: "layout",
  2571. default: true,
  2572. },
  2573. scrollToActiveSongBtn: {
  2574. desc: "Add a button to the queue to scroll to the currently playing song",
  2575. type: "toggle",
  2576. category: "layout",
  2577. default: true,
  2578. },
  2579. arrowKeySupport: {
  2580. desc: "Use arrow keys to skip forwards and backwards by 10 seconds",
  2581. type: "toggle",
  2582. category: "input",
  2583. default: true,
  2584. },
  2585. switchBetweenSites: {
  2586. desc: "Add F9 as a hotkey to switch between the YT and YTM sites on a video / song",
  2587. type: "toggle",
  2588. category: "input",
  2589. default: true,
  2590. },
  2591. switchSitesHotkey: {
  2592. hidden: true,
  2593. desc: "TODO(v1.1): Which hotkey needs to be pressed to switch sites?",
  2594. type: "hotkey",
  2595. category: "input",
  2596. default: {
  2597. key: "F9",
  2598. shift: false,
  2599. ctrl: false,
  2600. meta: false,
  2601. },
  2602. },
  2603. disableBeforeUnloadPopup: {
  2604. desc: "Prevent the confirmation popup that appears when trying to leave the site while a song is playing",
  2605. type: "toggle",
  2606. category: "input",
  2607. default: false,
  2608. },
  2609. anchorImprovements: {
  2610. desc: "Add and improve links all over the page so things can be opened in a new tab easier",
  2611. type: "toggle",
  2612. category: "input",
  2613. default: true,
  2614. },
  2615. numKeysSkipToTime: {
  2616. desc: "Enable skipping to a specific time in the video by pressing a number key (0-9)",
  2617. type: "toggle",
  2618. category: "input",
  2619. default: true,
  2620. },
  2621. geniusLyrics: {
  2622. desc: "Add a button to the media controls of the currently playing song to open its lyrics on genius.com",
  2623. type: "toggle",
  2624. category: "lyrics",
  2625. default: true,
  2626. },
  2627. lyricsQueueButton: {
  2628. desc: "Add a button to each song in the queue to quickly open its lyrics page",
  2629. type: "toggle",
  2630. category: "lyrics",
  2631. default: true,
  2632. },
  2633. logLevel: {
  2634. desc: "How much information to log to the console",
  2635. type: "select",
  2636. category: "misc",
  2637. options: [
  2638. { value: 0, label: "Debug (most)" },
  2639. { value: 1, label: "Info (only important)" },
  2640. ],
  2641. default: 1,
  2642. },
  2643. };
  2644. ;// CONCATENATED MODULE: ./src/config.ts
  2645. var config_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
  2646. function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
  2647. return new (P || (P = Promise))(function (resolve, reject) {
  2648. function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
  2649. function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
  2650. function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
  2651. step((generator = generator.apply(thisArg, _arguments || [])).next());
  2652. });
  2653. };
  2654. /** If this number is incremented, the features object data will be migrated to the new format */
  2655. const formatVersion = 3;
  2656. /** Config data format migration dictionary */
  2657. const migrations = {
  2658. // 1 -> 2
  2659. 2: (oldData) => {
  2660. const queueBtnsEnabled = Boolean(oldData.queueButtons);
  2661. delete oldData.queueButtons;
  2662. return Object.assign(Object.assign({}, oldData), { deleteFromQueueButton: queueBtnsEnabled, lyricsQueueButton: queueBtnsEnabled });
  2663. },
  2664. // 2 -> 3
  2665. 3: (oldData) => (Object.assign(Object.assign({}, oldData), { removeShareTrackingParam: true, numKeysSkipToTime: true, fixSpacing: true, scrollToActiveSongBtn: true, logLevel: 1 })),
  2666. };
  2667. const defaultConfig = Object.keys(featInfo)
  2668. .reduce((acc, key) => {
  2669. acc[key] = featInfo[key].default;
  2670. return acc;
  2671. }, {});
  2672. const cfgMgr = new ConfigManager({
  2673. id: "bytm-config",
  2674. formatVersion,
  2675. defaultConfig,
  2676. migrations,
  2677. });
  2678. /** Initializes the ConfigManager instance and loads persistent data into memory */
  2679. function initConfig() {
  2680. return config_awaiter(this, void 0, void 0, function* () {
  2681. const oldFmtVer = Number(yield GM.getValue(`_uucfgver-${cfgMgr.id}`, NaN));
  2682. const data = yield cfgMgr.loadData();
  2683. log(`Initialized ConfigManager (format version = ${cfgMgr.formatVersion})`);
  2684. if (isNaN(oldFmtVer))
  2685. utils_info("Config data initialized with default values");
  2686. else if (oldFmtVer !== cfgMgr.formatVersion)
  2687. utils_info(`Config data migrated from version ${oldFmtVer} to ${cfgMgr.formatVersion}`);
  2688. return data;
  2689. });
  2690. }
  2691. /** Returns the current feature config from the in-memory cache */
  2692. function getFeatures() {
  2693. return cfgMgr.getData();
  2694. }
  2695. /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
  2696. function saveFeatures(featureConf) {
  2697. return config_awaiter(this, void 0, void 0, function* () {
  2698. yield cfgMgr.setData(featureConf);
  2699. siteEvents.emit("configChanged", cfgMgr.getData());
  2700. utils_info("Saved new feature config:", featureConf);
  2701. });
  2702. }
  2703. /** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */
  2704. function setDefaultFeatures() {
  2705. return config_awaiter(this, void 0, void 0, function* () {
  2706. yield cfgMgr.saveDefaultData();
  2707. siteEvents.emit("configChanged", cfgMgr.getData());
  2708. utils_info("Reset feature config to its default values");
  2709. });
  2710. }
  2711. /** Clears the feature config from the persistent storage - since the cache will be out of whack, this should only be run before a site re-/unload */
  2712. function clearConfig() {
  2713. return config_awaiter(this, void 0, void 0, function* () {
  2714. yield cfgMgr.deleteConfig();
  2715. utils_info("Deleted config from persistent storage");
  2716. });
  2717. }
  2718. ;// CONCATENATED MODULE: ./src/index.ts
  2719. var src_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
  2720. function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
  2721. return new (P || (P = Promise))(function (resolve, reject) {
  2722. function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
  2723. function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
  2724. function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
  2725. step((generator = generator.apply(thisArg, _arguments || [])).next());
  2726. });
  2727. };
  2728. {
  2729. // console watermark with sexy gradient
  2730. const styleGradient = "background: rgba(165, 38, 38, 1); background: linear-gradient(90deg, rgb(154, 31, 103) 0%, rgb(135, 31, 31) 40%, rgb(184, 64, 41) 100%);";
  2731. const styleCommon = "color: #fff; font-size: 1.5em; padding-left: 6px; padding-right: 6px;";
  2732. console.log();
  2733. console.log(`%c${constants_scriptInfo.name}%cv${constants_scriptInfo.version}%c\n\nBuild #${constants_scriptInfo.buildNumber} ─ ${constants_scriptInfo.namespace}`, `font-weight: bold; ${styleCommon} ${styleGradient}`, `background-color: #333; ${styleCommon}`, "padding: initial;");
  2734. console.log([
  2735. "Powered by:",
  2736. "─ lots of ambition",
  2737. `─ my song metadata API: ${geniUrlBase}`,
  2738. "─ my userscript utility library: https://github.com/Sv443-Network/UserUtils",
  2739. "─ this tiny event listener library: https://github.com/ai/nanoevents",
  2740. ].join("\n"));
  2741. console.log();
  2742. }
  2743. const domain = getDomain();
  2744. /** Stuff that needs to be called ASAP, before anything async happens */
  2745. function preInit() {
  2746. setLogLevel(defaultLogLevel);
  2747. if (domain === "ytm")
  2748. initBeforeUnloadHook();
  2749. init();
  2750. }
  2751. function init() {
  2752. return src_awaiter(this, void 0, void 0, function* () {
  2753. try {
  2754. registerMenuCommands();
  2755. }
  2756. catch (e) {
  2757. void e;
  2758. }
  2759. // init DOM-dependant stuff like features
  2760. try {
  2761. document.addEventListener("DOMContentLoaded", onDomLoad);
  2762. }
  2763. catch (err) {
  2764. error("General Error:", err);
  2765. }
  2766. // init config
  2767. try {
  2768. const ftConfig = yield initConfig();
  2769. setLogLevel(getFeatures().logLevel);
  2770. preInitLayout(ftConfig);
  2771. if (getFeatures().disableBeforeUnloadPopup)
  2772. disableBeforeUnload();
  2773. }
  2774. catch (err) {
  2775. error("Error while initializing ConfigManager:", err);
  2776. }
  2777. // init menu separately from features
  2778. try {
  2779. void "TODO(v1.1):";
  2780. // initMenu();
  2781. }
  2782. catch (err) {
  2783. error("Couldn't initialize menu:", err);
  2784. }
  2785. });
  2786. }
  2787. /** Called when the DOM has finished loading and can be queried and altered by the userscript */
  2788. function onDomLoad() {
  2789. return src_awaiter(this, void 0, void 0, function* () {
  2790. // post-build these double quotes are replaced by backticks (because if backticks are used here, webpack converts them to double quotes)
  2791. addGlobalStyle(`.bytm-menu-bg {
  2792. --bytm-menu-bg: #333333;
  2793. --bytm-menu-bg-highlight: #1e1e1e;
  2794. --bytm-menu-separator-color: #797979;
  2795. --bytm-menu-border-radius: 10px;
  2796. }
  2797. #bytm-cfg-menu-bg {
  2798. --bytm-menu-height-max: 750px;
  2799. --bytm-menu-width-max: 1000px;
  2800. }
  2801. #bytm-changelog-menu-bg {
  2802. --bytm-menu-height-max: 800px;
  2803. --bytm-menu-width-max: 800px;
  2804. }
  2805. #bytm-export-menu-bg, #bytm-import-menu-bg {
  2806. --bytm-menu-height-max: 500px;
  2807. --bytm-menu-width-max: 600px;
  2808. }
  2809. .bytm-menu-bg {
  2810. display: block;
  2811. position: fixed;
  2812. width: 100%;
  2813. height: 100%;
  2814. top: 0;
  2815. left: 0;
  2816. z-index: 15;
  2817. background-color: rgba(0, 0, 0, 0.6);
  2818. }
  2819. .bytm-menu {
  2820. position: fixed;
  2821. display: flex;
  2822. flex-direction: column;
  2823. width: calc(min(100% - 60px, var(--bytm-menu-width-max)));
  2824. border-radius: var(--bytm-menu-border-radius);
  2825. height: auto;
  2826. max-height: calc(min(100% - 40px, var(--bytm-menu-height-max)));
  2827. left: 50%;
  2828. top: 50%;
  2829. transform: translate(-50%, -50%);
  2830. z-index: 16;
  2831. color: #fff;
  2832. background-color: var(--bytm-menu-bg);
  2833. }
  2834. .bytm-menu-body {
  2835. padding: 20px;
  2836. }
  2837. #bytm-menu-opts {
  2838. display: flex;
  2839. flex-direction: column;
  2840. position: relative;
  2841. padding: 30px 0px;
  2842. overflow-y: auto;
  2843. }
  2844. .bytm-menu-header {
  2845. display: flex;
  2846. justify-content: space-between;
  2847. margin-bottom: 6px;
  2848. padding: 15px 20px 15px 20px;
  2849. background-color: var(--bytm-menu-bg);
  2850. border: 2px solid var(--bytm-menu-separator-color);
  2851. border-style: none none solid none;
  2852. border-radius: var(--bytm-menu-border-radius) var(--bytm-menu-border-radius) 0px 0px;
  2853. }
  2854. #bytm-menu-titlecont {
  2855. display: flex;
  2856. align-items: center;
  2857. }
  2858. #bytm-menu-title {
  2859. display: inline-block;
  2860. font-size: 22px;
  2861. }
  2862. #bytm-menu-linkscont {
  2863. display: flex;
  2864. align-items: center;
  2865. margin-left: 32px;
  2866. }
  2867. .bytm-menu-link {
  2868. display: inline-flex;
  2869. align-items: center;
  2870. cursor: pointer;
  2871. }
  2872. .bytm-menu-link:not(:last-of-type) {
  2873. margin-right: 10px;
  2874. }
  2875. .bytm-menu-close {
  2876. width: 32px;
  2877. height: 32px;
  2878. cursor: pointer;
  2879. }
  2880. .bytm-menu-footer {
  2881. font-size: 17px;
  2882. text-decoration: underline;
  2883. }
  2884. #bytm-menu-footer-cont {
  2885. display: flex;
  2886. flex-direction: row;
  2887. justify-content: space-between;
  2888. margin-top: 6px;
  2889. padding: 20px 20px 8px 20px;
  2890. background: var(--bytm-menu-bg);
  2891. background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, var(--bytm-menu-bg) 30%, var(--bytm-menu-bg) 100%);
  2892. border: 2px solid var(--bytm-menu-separator-color);
  2893. border-style: solid none none none;
  2894. }
  2895. #bytm-menu-footer-buttons-cont button:not(:last-of-type) {
  2896. margin-right: 15px;
  2897. }
  2898. .bytm-menu-footer-right {
  2899. display: flex;
  2900. flex-direction: row-reverse;
  2901. align-items: center;
  2902. margin-top: 15px;
  2903. }
  2904. #bytm-menu-version-cont {
  2905. display: flex;
  2906. justify-content: space-around;
  2907. font-size: 1.2em;
  2908. padding-bottom: 8px;
  2909. border-radius: var(--bytm-menu-border-radius) var(--bytm-menu-border-radius) 0px 0px;
  2910. }
  2911. #bytm-menu-scroll-indicator {
  2912. --bytm-scroll-indicator-padding: 5px;
  2913. position: sticky;
  2914. bottom: -15px;
  2915. left: 50%;
  2916. margin-top: calc(-32px - var(--bytm-scroll-indicator-padding) * 2);
  2917. padding: var(--bytm-scroll-indicator-padding);
  2918. transform: translateX(-50%);
  2919. width: 32px;
  2920. height: 32px;
  2921. z-index: 101;
  2922. background-color: var(--bytm-menu-bg-highlight);
  2923. border-radius: 50%;
  2924. cursor: pointer;
  2925. }
  2926. .bytm-hidden {
  2927. visibility: hidden !important;
  2928. }
  2929. .bytm-ftconf-category-header {
  2930. font-size: 18px;
  2931. margin-top: 32px;
  2932. margin-bottom: 8px;
  2933. padding: 0px 20px;
  2934. }
  2935. .bytm-ftconf-category-header:first-of-type {
  2936. margin-top: 0;
  2937. }
  2938. .bytm-ftitem {
  2939. display: flex;
  2940. flex-direction: row;
  2941. justify-content: space-between;
  2942. align-items: center;
  2943. font-size: 1.4em;
  2944. padding: 8px 20px;
  2945. }
  2946. .bytm-ftconf-ctrl {
  2947. display: inline-flex;
  2948. align-items: center;
  2949. white-space: nowrap;
  2950. }
  2951. .bytm-ftconf-label {
  2952. user-select: none;
  2953. }
  2954. .bytm-slider-label {
  2955. margin-right: 10px;
  2956. }
  2957. .bytm-toggle-label {
  2958. padding-left: 10px;
  2959. padding-right: 5px;
  2960. }
  2961. .bytm-ftconf-input[type=number] {
  2962. width: 75px;
  2963. }
  2964. .bytm-ftconf-input[type=checkbox] {
  2965. margin-left: 5px;
  2966. }
  2967. #bytm-export-menu-text, #bytm-import-menu-text {
  2968. font-size: 1.6em;
  2969. margin-bottom: 15px;
  2970. }
  2971. .bytm-menu-footer-copied {
  2972. font-size: 1.6em;
  2973. margin-right: 15px;
  2974. }
  2975. #bytm-changelog-menu-body {
  2976. overflow-y: auto;
  2977. }
  2978. #bytm-export-menu-textarea, #bytm-import-menu-textarea {
  2979. width: 100%;
  2980. height: 150px;
  2981. resize: none;
  2982. }
  2983. .bytm-markdown-container {
  2984. display: flex;
  2985. flex-direction: column;
  2986. overflow-y: auto;
  2987. font-size: 1.5em;
  2988. line-height: 20px;
  2989. }
  2990. /* Markdown stuff */
  2991. .bytm-markdown-container a, #bytm-menu-version {
  2992. color: #369bff;
  2993. text-decoration: none;
  2994. cursor: pointer;
  2995. }
  2996. .bytm-markdown-container a:hover, #bytm-menu-version:hover {
  2997. text-decoration: underline;
  2998. }
  2999. .bytm-markdown-container kbd {
  3000. --easing: cubic-bezier(0.31, 0.58, 0.24, 1.15);
  3001. display: inline-block;
  3002. vertical-align: bottom;
  3003. padding: 4px;
  3004. padding-top: 2px;
  3005. font-size: 0.95em;
  3006. line-height: 11px;
  3007. background-color: #222;
  3008. border: 1px solid #777;
  3009. border-radius: 5px;
  3010. box-shadow: inset 0 -2px 0 #515559;
  3011. transition: padding 0.1s var(--easing), box-shadow 0.1s var(--easing);
  3012. }
  3013. .bytm-markdown-container kbd:active {
  3014. padding-bottom: 2px;
  3015. box-shadow: inset 0 0 0 #61666c;
  3016. }
  3017. .bytm-markdown-container kbd::selection {
  3018. background: rgba(0, 0, 0, 0);
  3019. }
  3020. .bytm-markdown-container code {
  3021. background-color: #222;
  3022. border-radius: 3px;
  3023. padding: 1px 5px;
  3024. }
  3025. .bytm-markdown-container h2 {
  3026. margin-bottom: 5px;
  3027. }
  3028. .bytm-markdown-container h2:not(:first-of-type) {
  3029. margin-top: 20px;
  3030. }
  3031. .bytm-markdown-container ul li::before {
  3032. content: "• ";
  3033. font-weight: bolder;
  3034. }
  3035. .bytm-markdown-container ul li > ul li::before {
  3036. white-space: pre;
  3037. content: " • ";
  3038. font-weight: bolder;
  3039. }
  3040. .bytm-disable-scroll {
  3041. overflow: hidden !important;
  3042. }
  3043. .bytm-generic-btn {
  3044. display: inline-flex;
  3045. align-items: center;
  3046. justify-content: center;
  3047. position: relative;
  3048. vertical-align: middle;
  3049. cursor: pointer;
  3050. margin-left: 8px;
  3051. width: 40px;
  3052. height: 40px;
  3053. border-radius: 100%;
  3054. background-color: transparent;
  3055. }
  3056. .bytm-generic-btn:hover {
  3057. background-color: var(--yt-spec-10-percent-layer, #1d1d1d);
  3058. }
  3059. .bytm-generic-btn-img {
  3060. display: inline-block;
  3061. z-index: 10;
  3062. width: 24px;
  3063. height: 24px;
  3064. padding: 5px;
  3065. }
  3066. .bytm-spinner {
  3067. animation: rotate 1.2s linear infinite;
  3068. }
  3069. @keyframes rotate {
  3070. from {
  3071. transform: rotate(0deg);
  3072. }
  3073. to {
  3074. transform: rotate(360deg);
  3075. }
  3076. }
  3077. .bytm-anchor {
  3078. all: unset;
  3079. cursor: pointer;
  3080. }
  3081. /* ytmusic-logo a[bytm-animated="true"] .bytm-mod-logo-ellipse {
  3082. transform-origin: 12px 12px;
  3083. animation: rotate 1s ease-in-out infinite;
  3084. } */
  3085. ytmusic-logo a.bytm-logo-exchanged .bytm-mod-logo-path {
  3086. transform-origin: 12px 12px;
  3087. animation: rotate 1s ease-in-out;
  3088. }
  3089. ytmusic-logo a.bytm-logo-exchanged .bytm-mod-logo-img {
  3090. width: 24px;
  3091. height: 24px;
  3092. z-index: 1000;
  3093. position: absolute;
  3094. animation: rotate-fade-in 1s ease-in-out;
  3095. }
  3096. @keyframes rotate-fade-in {
  3097. 0% {
  3098. opacity: 0;
  3099. transform: rotate(0deg);
  3100. }
  3101. 30% {
  3102. opacity: 0;
  3103. }
  3104. 90% {
  3105. opacity: 1;
  3106. }
  3107. 100% {
  3108. transform: rotate(360deg);
  3109. }
  3110. }
  3111. .bytm-no-select {
  3112. user-select: none;
  3113. -ms-user-select: none;
  3114. -moz-user-select: none;
  3115. -webkit-user-select: none;
  3116. }
  3117. /* YTM does some weird styling that breaks everything, so this reverts all of BYTM's buttons to the browser default style */
  3118. button.bytm-btn {
  3119. padding: revert;
  3120. border: revert;
  3121. outline: revert;
  3122. font: revert;
  3123. text-transform: revert;
  3124. color: revert;
  3125. background: revert;
  3126. }
  3127. .bytm-cfg-menu-option {
  3128. display: block;
  3129. padding: 8px 0;
  3130. }
  3131. .bytm-cfg-menu-option-item {
  3132. display: flex;
  3133. flex-direction: row;
  3134. align-items: center;
  3135. font-size: 16px;
  3136. font-weight: 400;
  3137. line-height: 24px;
  3138. padding: var(--yt-compact-link-paper-item-padding, 0px 36px 0 16px);
  3139. min-height: var(--paper-item-min-height, 40px);
  3140. white-space: nowrap;
  3141. cursor: pointer;
  3142. }
  3143. .bytm-cfg-menu-option-item:hover {
  3144. background-color: var(--yt-spec-badge-chip-background, #3e3e3e);
  3145. }
  3146. .bytm-cfg-menu-option-icon {
  3147. width: 24px;
  3148. height: 24px;
  3149. margin-right: 16px;
  3150. display: flex;
  3151. align-items: center;
  3152. flex-direction: row;
  3153. flex: none;
  3154. }
  3155. .bytm-cfg-menu-option-text {
  3156. font-size: 1.4rem;
  3157. line-height: 2rem;
  3158. }
  3159. yt-multi-page-menu-section-renderer.ytd-multi-page-menu-renderer {
  3160. border-bottom: 1px solid var(--yt-spec-10-percent-layer, #3e3e3e);
  3161. }
  3162. #bytm-watermark {
  3163. font-size: 10px;
  3164. display: inline-block;
  3165. position: absolute;
  3166. left: 97px;
  3167. top: 46px;
  3168. z-index: 10;
  3169. color: white;
  3170. text-decoration: none;
  3171. cursor: pointer;
  3172. }
  3173. #bytm-watermark:hover {
  3174. text-decoration: underline;
  3175. }
  3176. .side-panel.modular ytmusic-player-queue-item .song-info.ytmusic-player-queue-item {
  3177. position: relative;
  3178. }
  3179. .side-panel.modular ytmusic-player-queue-item .bytm-queue-btn-container {
  3180. background: rgb(0, 0, 0);
  3181. background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 15%);
  3182. display: none;
  3183. position: absolute;
  3184. right: 0;
  3185. padding-left: 25px;
  3186. height: 100%;
  3187. }
  3188. .side-panel.modular ytmusic-player-queue-item:hover .bytm-queue-btn-container {
  3189. display: inline-block;
  3190. }
  3191. .side-panel.modular ytmusic-player-queue-item[play-button-state="loading"] .bytm-queue-btn-container,
  3192. .side-panel.modular ytmusic-player-queue-item[play-button-state="playing"] .bytm-queue-btn-container,
  3193. .side-panel.modular ytmusic-player-queue-item[play-button-state="paused"] .bytm-queue-btn-container {
  3194. /* using a var() with predefined value from YTM is not viable since the nesting changes the actual value of the variable */
  3195. background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(29, 29, 29, 1) 15%);
  3196. }
  3197. ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown[data-bytm-hidden=true] {
  3198. display: none !important;
  3199. }
  3200. ytmusic-responsive-list-item-renderer:not([unplayable_]) .left-items {
  3201. margin-right: 0 !important;
  3202. }
  3203. .bytm-carousel-shelf-anchor {
  3204. margin-right: var(--ytmusic-responsive-list-item-thumbnail-margin-right, 24px);
  3205. }
  3206. #bytm-vol-slider-cont {
  3207. position: relative;
  3208. }
  3209. .bytm-vol-slider-label {
  3210. opacity: 0.000001;
  3211. position: absolute;
  3212. font-size: 15px;
  3213. top: 50%;
  3214. left: 0;
  3215. transform: translate(calc(-50% - 10px), -50%);
  3216. text-align: right;
  3217. transition: opacity 0.2s ease;
  3218. }
  3219. .bytm-vol-slider-label.bytm-visible {
  3220. opacity: 1;
  3221. }
  3222. #bytm-scroll-to-active-btn-cont {
  3223. display: flex;
  3224. flex-direction: column;
  3225. justify-content: center;
  3226. align-items: center;
  3227. position: absolute;
  3228. right: 5px;
  3229. top: 0;
  3230. height: 100%;
  3231. }
  3232. #bytm-scroll-to-active-btn {
  3233. display: inline-flex;
  3234. align-items: center;
  3235. justify-content: center;
  3236. border-radius: 50%;
  3237. cursor: pointer;
  3238. }
  3239. #bytm-scroll-to-active-btn {
  3240. width: revert;
  3241. height: revert;
  3242. }
  3243. #bytm-scroll-to-active-btn .bytm-generic-btn-img {
  3244. padding: 4px;
  3245. }
  3246. display: none;
  3247. flex-direction: column;
  3248. justify-content: center;
  3249. align-items: center;
  3250. }
  3251. #bytm-menu-backdrop[data-menu-open="true"] {
  3252. display: flex;
  3253. } */
  3254. #bytm-menu-header-container {
  3255. display: flex;
  3256. justify-content: flex-start;
  3257. align-items: center;
  3258. border-color: #ffffff;
  3259. border-style: none solid none none;
  3260. }
  3261. .bytm-menu-header-option {
  3262. display: "flex";
  3263. justify-content: center;
  3264. align-items: center;
  3265. border-color: #ffffff;
  3266. border-style: solid none solid none;
  3267. }
  3268. #bytm-menu-header-option h3 {
  3269. margin: 0;
  3270. }
  3271. .bytm-menu-tab[data-active="true"] {
  3272. display: none;
  3273. }
  3274. .bytm-menu-tab[data-active="false"] {
  3275. display: none;
  3276. }
  3277. `);
  3278. initOnSelector();
  3279. const features = getFeatures();
  3280. log(`Initializing features for domain "${domain}"...`);
  3281. try {
  3282. if (domain === "ytm") {
  3283. try {
  3284. addMenu(); // TODO(v1.1): remove
  3285. }
  3286. catch (err) {
  3287. error("Couldn't add menu:", err);
  3288. }
  3289. initSiteEvents();
  3290. onSelector("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", { listener: addConfigMenuOption });
  3291. if (features.arrowKeySupport)
  3292. initArrowKeySkip();
  3293. if (features.removeUpgradeTab)
  3294. removeUpgradeTab();
  3295. if (features.watermarkEnabled)
  3296. addWatermark();
  3297. if (features.geniusLyrics)
  3298. addMediaCtrlLyricsBtn();
  3299. if (features.deleteFromQueueButton || features.lyricsQueueButton)
  3300. initQueueButtons();
  3301. if (features.anchorImprovements)
  3302. addAnchorImprovements();
  3303. if (features.closeToastsTimeout > 0)
  3304. initAutoCloseToasts();
  3305. if (features.removeShareTrackingParam)
  3306. removeShareTrackingParam();
  3307. if (features.numKeysSkipToTime)
  3308. initNumKeysSkip();
  3309. if (features.fixSpacing)
  3310. fixSpacing();
  3311. if (features.scrollToActiveSongBtn)
  3312. addScrollToActiveBtn();
  3313. initVolumeFeatures();
  3314. }
  3315. if (["ytm", "yt"].includes(domain)) {
  3316. if (features.switchBetweenSites)
  3317. initSiteSwitch(domain);
  3318. }
  3319. }
  3320. catch (err) {
  3321. error("Feature error:", err);
  3322. }
  3323. });
  3324. }
  3325. function registerMenuCommands() {
  3326. if (mode === "development") {
  3327. GM.registerMenuCommand("Reset config", () => src_awaiter(this, void 0, void 0, function* () {
  3328. if (confirm("Are you sure you want to reset the configuration to its default values?\nThis will automatically reload the page.")) {
  3329. yield clearConfig();
  3330. location.reload();
  3331. }
  3332. }), "r");
  3333. GM.registerMenuCommand("List GM values", () => src_awaiter(this, void 0, void 0, function* () {
  3334. alert("See console.");
  3335. const keys = yield GM.listValues();
  3336. console.log("GM values:");
  3337. if (keys.length === 0)
  3338. console.log(" No values found.");
  3339. for (const key of keys)
  3340. console.log(` ${key} -> ${yield GM.getValue(key)}`);
  3341. }), "l");
  3342. GM.registerMenuCommand("Clear all GM values", () => src_awaiter(this, void 0, void 0, function* () {
  3343. if (confirm("Are you sure you want to clear all GM values?")) {
  3344. const keys = yield GM.listValues();
  3345. console.log("Clearing GM values:");
  3346. if (keys.length === 0)
  3347. console.log(" No values found.");
  3348. for (const key of keys) {
  3349. yield GM.deleteValue(key);
  3350. console.log(` Deleted ${key}`);
  3351. }
  3352. }
  3353. }), "c");
  3354. }
  3355. }
  3356. preInit();