index.ts 13 KB

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