index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import { addGlobalStyle, compress, decompress, type Stringifiable } from "@sv443-network/userutils";
  2. import { initOnSelector, warn } from "./utils";
  3. import { clearConfig, getFeatures, initConfig } from "./config";
  4. import { buildNumber, compressionFormat, defaultLogLevel, mode, scriptInfo } from "./constants";
  5. import { error, getDomain, info, getSessionId, log, setLogLevel, initTranslations, setLocale } from "./utils";
  6. import { initSiteEvents } from "./siteEvents";
  7. import { emitInterface, initInterface, initPlugins } from "./interface";
  8. import { addWelcomeMenu, showWelcomeMenu } from "./menu/welcomeMenu";
  9. import { initObservers, observers } from "./observers";
  10. import {
  11. // layout
  12. addWatermark, removeUpgradeTab,
  13. removeShareTrackingParam, fixSpacing,
  14. addScrollToActiveBtn,
  15. // volume
  16. initVolumeFeatures,
  17. // song lists
  18. initQueueButtons,
  19. // behavior
  20. initBeforeUnloadHook, disableBeforeUnload,
  21. initAutoCloseToasts, initRememberSongTime,
  22. disableDarkReader,
  23. // input
  24. initArrowKeySkip, initSiteSwitch,
  25. addAnchorImprovements, initNumKeysSkip,
  26. // lyrics
  27. addMediaCtrlLyricsBtn,
  28. // menu
  29. addConfigMenuOption,
  30. // other
  31. initVersionCheck, initLyricsCache,
  32. } from "./features/index";
  33. {
  34. // console watermark with sexy gradient
  35. 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%);";
  36. const styleCommon = "color: #fff; font-size: 1.5em; padding-left: 6px; padding-right: 6px;";
  37. console.log();
  38. console.log(
  39. `%c${scriptInfo.name}%cv${scriptInfo.version}%c\n\nBuild #${buildNumber} ─ ${scriptInfo.namespace}`,
  40. `font-weight: bold; ${styleCommon} ${styleGradient}`,
  41. `background-color: #333; ${styleCommon}`,
  42. "padding: initial;",
  43. );
  44. console.log([
  45. "Powered by:",
  46. "─ Lots of ambition and dedication",
  47. "─ My song metadata API: https://api.sv443.net/geniurl",
  48. "─ My userscript utility library: https://github.com/Sv443-Network/UserUtils",
  49. "─ The fuse.js library: https://github.com/krisk/Fuse",
  50. "─ This markdown parser library: https://github.com/markedjs/marked",
  51. "─ This tiny event listener library: https://github.com/ai/nanoevents",
  52. ].join("\n"));
  53. console.log();
  54. }
  55. let domLoaded = false;
  56. const domain = getDomain();
  57. /** Stuff that needs to be called ASAP, before anything async happens */
  58. function preInit() {
  59. log("Session ID:", getSessionId());
  60. initInterface();
  61. setLogLevel(defaultLogLevel);
  62. if(domain === "ytm")
  63. initBeforeUnloadHook();
  64. init();
  65. }
  66. async function init() {
  67. try {
  68. document.addEventListener("DOMContentLoaded", () => {
  69. domLoaded = true;
  70. });
  71. const features = await initConfig();
  72. setLogLevel(features.logLevel);
  73. await initLyricsCache();
  74. await initTranslations(features.locale ?? "en_US");
  75. setLocale(features.locale ?? "en_US");
  76. emitInterface("bytm:initPlugins");
  77. if(features.disableBeforeUnloadPopup && domain === "ytm")
  78. disableBeforeUnload();
  79. if(!domLoaded)
  80. document.addEventListener("DOMContentLoaded", onDomLoad);
  81. else
  82. onDomLoad();
  83. if(features.rememberSongTime)
  84. initRememberSongTime();
  85. }
  86. catch(err) {
  87. error("General Error:", err);
  88. }
  89. // init menu separately from features
  90. try {
  91. void "TODO(v1.2):";
  92. // initMenu();
  93. }
  94. catch(err) {
  95. error("Couldn't initialize menu:", err);
  96. }
  97. }
  98. /** Called when the DOM has finished loading and can be queried and altered by the userscript */
  99. async function onDomLoad() {
  100. insertGlobalStyle();
  101. initObservers();
  102. initOnSelector();
  103. const features = getFeatures();
  104. const ftInit = [] as Promise<void>[];
  105. await initVersionCheck();
  106. log(`DOM loaded. Initializing features for domain "${domain}"...`);
  107. try {
  108. if(domain === "ytm") {
  109. disableDarkReader();
  110. ftInit.push(initSiteEvents());
  111. if(typeof await GM.getValue("bytm-installed") !== "string") {
  112. // open welcome menu with language selector
  113. await addWelcomeMenu();
  114. info("Showing welcome menu");
  115. await showWelcomeMenu();
  116. await GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version }));
  117. }
  118. observers.body.addListener("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
  119. listener: addConfigMenuOption,
  120. });
  121. if(features.arrowKeySupport)
  122. ftInit.push(initArrowKeySkip());
  123. if(features.removeUpgradeTab)
  124. ftInit.push(removeUpgradeTab());
  125. if(features.watermarkEnabled)
  126. ftInit.push(addWatermark());
  127. if(features.geniusLyrics)
  128. ftInit.push(addMediaCtrlLyricsBtn());
  129. if(features.deleteFromQueueButton || features.lyricsQueueButton)
  130. ftInit.push(initQueueButtons());
  131. if(features.anchorImprovements)
  132. ftInit.push(addAnchorImprovements());
  133. if(features.closeToastsTimeout > 0)
  134. ftInit.push(initAutoCloseToasts());
  135. if(features.removeShareTrackingParam)
  136. ftInit.push(removeShareTrackingParam());
  137. if(features.numKeysSkipToTime)
  138. ftInit.push(initNumKeysSkip());
  139. if(features.fixSpacing)
  140. ftInit.push(fixSpacing());
  141. if(features.scrollToActiveSongBtn)
  142. ftInit.push(addScrollToActiveBtn());
  143. ftInit.push(initVolumeFeatures());
  144. }
  145. if(["ytm", "yt"].includes(domain)) {
  146. if(features.switchBetweenSites)
  147. ftInit.push(initSiteSwitch(domain));
  148. // TODO: for hot reloading features
  149. // ftInit.push(new Promise((resolve) => {
  150. // for(const [k, v] of Object.entries(featInfo)) {
  151. // try {
  152. // const featVal = features[k as keyof typeof featInfo];
  153. // // @ts-ignore
  154. // if(v.enable && featVal === true) {
  155. // console.log("###> enable", k);
  156. // // @ts-ignore
  157. // v.enable(features);
  158. // console.log("###>> enable ok");
  159. // }
  160. // // @ts-ignore
  161. // else if(v.disable && featVal === false) {
  162. // console.log("###> disable", k);
  163. // // @ts-ignore
  164. // v.disable(features);
  165. // console.log("###>> disable ok");
  166. // }
  167. // }
  168. // catch(err) {
  169. // error(`Couldn't initialize feature "${k}" due to error:`, err);
  170. // }
  171. // }
  172. // console.log("###>>> done for loop");
  173. // resolve();
  174. // }));
  175. }
  176. await Promise.allSettled(ftInit);
  177. emitInterface("bytm:ready");
  178. try {
  179. initPlugins();
  180. }
  181. catch(err) {
  182. error("Plugin loading error:", err);
  183. }
  184. try {
  185. registerDevMenuCommands();
  186. }
  187. catch(e) {
  188. warn("Couldn't register dev menu commands:", e);
  189. }
  190. }
  191. catch(err) {
  192. error("Feature error:", err);
  193. }
  194. }
  195. // TODO(v1.2):
  196. // async function initFeatures() {
  197. // const ftInit = [] as Promise<void>[];
  198. // log(`DOM loaded. Initializing features for domain "${domain}"...`);
  199. // for(const [ftKey, ftInfo] of Object.entries(featInfo)) {
  200. // try {
  201. // // @ts-ignore
  202. // const res = ftInfo?.enable?.() as undefined | Promise<void>;
  203. // if(res instanceof Promise)
  204. // ftInit.push(res);
  205. // else
  206. // ftInit.push(Promise.resolve());
  207. // }
  208. // catch(err) {
  209. // error(`Couldn't initialize feature "${ftKey}" due to error:`, err);
  210. // }
  211. // }
  212. // siteEvents.on("configOptionChanged", (ftKey, oldValue, newValue) => {
  213. // try {
  214. // // @ts-ignore
  215. // if(featInfo[ftKey].change) {
  216. // // @ts-ignore
  217. // featInfo[ftKey].change(oldValue, newValue);
  218. // }
  219. // // @ts-ignore
  220. // else if(featInfo[ftKey].disable) {
  221. // // @ts-ignore
  222. // const disableRes = featInfo[ftKey].disable();
  223. // if(disableRes instanceof Promise) // @ts-ignore
  224. // disableRes.then(() => featInfo[ftKey]?.enable?.());
  225. // else // @ts-ignore
  226. // featInfo[ftKey]?.enable?.();
  227. // }
  228. // else {
  229. // // TODO: set "page reload required" flag in new menu
  230. // if(confirm("[Work in progress]\nYou changed an option that requires a page reload to be applied.\nReload the page now?")) {
  231. // disableBeforeUnload();
  232. // location.reload();
  233. // }
  234. // }
  235. // }
  236. // catch(err) {
  237. // error(`Couldn't change feature "${ftKey}" due to error:`, err);
  238. // }
  239. // });
  240. // Promise.all(ftInit).then(() => {
  241. // emitInterface("bytm:ready");
  242. // });
  243. // }
  244. /** Inserts the bundled CSS files imported throughout the script into a <style> element in the <head> */
  245. function insertGlobalStyle() {
  246. // post-build these double quotes are replaced by backticks (because if backticks are used here, the bundler converts them to double quotes)
  247. addGlobalStyle("#{{GLOBAL_STYLE}}").id = "bytm-style-global";
  248. }
  249. /** Registers dev commands using `GM.registerMenuCommand` */
  250. function registerDevMenuCommands() {
  251. if(mode !== "development")
  252. return;
  253. GM.registerMenuCommand("Reset config", async () => {
  254. if(confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) {
  255. await clearConfig();
  256. disableBeforeUnload();
  257. location.reload();
  258. }
  259. }, "r");
  260. GM.registerMenuCommand("List GM values in console with decompression", async () => {
  261. const keys = await GM.listValues();
  262. console.log(`GM values (${keys.length}):`);
  263. if(keys.length === 0)
  264. console.log(" No values found.");
  265. const values = {} as Record<string, Stringifiable | undefined>;
  266. let longestKey = 0;
  267. for(const key of keys) {
  268. const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  269. const val = await GM.getValue(key, undefined);
  270. values[key] = typeof val !== "undefined" && isEncoded ? await decompress(val, compressionFormat, "string") : val;
  271. longestKey = Math.max(longestKey, key.length);
  272. }
  273. for(const [key, finalVal] of Object.entries(values)) {
  274. const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  275. const lengthStr = String(finalVal).length > 50 ? `(${String(finalVal).length} chars) ` : "";
  276. console.log(` "${key}"${" ".repeat(longestKey - key.length)} -${isEncoded ? "-[decoded]-" : ""}> ${lengthStr}${finalVal}`);
  277. }
  278. }, "l");
  279. GM.registerMenuCommand("List GM values in console, without decompression", async () => {
  280. const keys = await GM.listValues();
  281. console.log(`GM values (${keys.length}):`);
  282. if(keys.length === 0)
  283. console.log(" No values found.");
  284. const values = {} as Record<string, Stringifiable | undefined>;
  285. let longestKey = 0;
  286. for(const key of keys) {
  287. const val = await GM.getValue(key, undefined);
  288. values[key] = val;
  289. longestKey = Math.max(longestKey, key.length);
  290. }
  291. for(const [key, val] of Object.entries(values)) {
  292. const lengthStr = String(val).length >= 16 ? `(${String(val).length} chars) ` : "";
  293. console.log(` "${key}"${" ".repeat(longestKey - key.length)} -> ${lengthStr}${val}`);
  294. }
  295. });
  296. GM.registerMenuCommand("Delete all GM values", async () => {
  297. const keys = await GM.listValues();
  298. if(confirm(`Clear all ${keys.length} GM values?\nSee console for details.`)) {
  299. console.log(`Clearing ${keys.length} GM values:`);
  300. if(keys.length === 0)
  301. console.log(" No values found.");
  302. for(const key of keys) {
  303. await GM.deleteValue(key);
  304. console.log(` Deleted ${key}`);
  305. }
  306. }
  307. }, "d");
  308. GM.registerMenuCommand("Delete GM values by name (comma separated)", async () => {
  309. const keys = prompt("Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation.");
  310. if(!keys)
  311. return;
  312. for(const key of keys?.split(",") ?? []) {
  313. if(key && key.length > 0) {
  314. const truncLength = 400;
  315. const oldVal = await GM.getValue(key);
  316. await GM.deleteValue(key);
  317. console.log(`Deleted GM value '${key}' with previous value '${oldVal && String(oldVal).length > truncLength ? String(oldVal).substring(0, truncLength) + `… (${String(oldVal).length} / ${truncLength} chars.)` : oldVal}'`);
  318. }
  319. }
  320. }, "n");
  321. GM.registerMenuCommand("Reset install timestamp", async () => {
  322. await GM.deleteValue("bytm-installed");
  323. console.log("Reset install time.");
  324. }, "t");
  325. GM.registerMenuCommand("Reset version check timestamp", async () => {
  326. await GM.deleteValue("bytm-version-check");
  327. console.log("Reset version check time.");
  328. }, "v");
  329. GM.registerMenuCommand("List active selector listeners in console", async () => {
  330. const lines = [] as string[];
  331. let listenersAmt = 0;
  332. for(const [obsName, obs] of Object.entries(observers)) {
  333. const listeners = obs.getAllListeners();
  334. lines.push(`- "${obsName}" (${listeners.size} listeners):`);
  335. [...listeners].forEach(([k, v]) => {
  336. listenersAmt += v.length;
  337. lines.push(` [${v.length}] ${k}`);
  338. v.forEach(({ all, continuous }, i) => {
  339. lines.push(` ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}, ${all ? "select multiple" : "select single"}`);
  340. });
  341. });
  342. }
  343. console.log(`Showing currently active listeners for ${Object.keys(observers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
  344. }, "s");
  345. GM.registerMenuCommand("Compress value", async () => {
  346. const input = prompt("Enter the value to compress.\nSee console for output.");
  347. if(input && input.length > 0) {
  348. const compressed = await compress(input, compressionFormat);
  349. console.log(`Compression result (${input.length} chars -> ${compressed.length} chars)\nValue: ${compressed}`);
  350. }
  351. });
  352. GM.registerMenuCommand("Decompress value", async () => {
  353. const input = prompt("Enter the value to decompress.\nSee console for output.");
  354. if(input && input.length > 0) {
  355. const decompressed = await decompress(input, compressionFormat);
  356. console.log(`Decompresion result (${input.length} chars -> ${decompressed.length} chars)\nValue: ${decompressed}`);
  357. }
  358. });
  359. }
  360. preInit();