1
0

index.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { addGlobalStyle } from "@sv443-network/userutils";
  2. import { initOnSelector } from "./utils";
  3. import { clearConfig, getFeatures, initConfig } from "./config";
  4. import { defaultLogLevel, mode, scriptInfo } from "./constants";
  5. import { error, getDomain, info, getSessionId, log, setLogLevel } from "./utils";
  6. import { initSiteEvents, siteEvents } from "./siteEvents";
  7. import { initTranslations, setLocale } from "./translations";
  8. import { emitInterface, initInterface } from "./interface";
  9. import { addCfgMenu } from "./menu/menu_old";
  10. import { addWelcomeMenu, showWelcomeMenu } from "./menu/welcomeMenu";
  11. import { initObservers, observers } from "./observers";
  12. import {
  13. // other:
  14. featInfo,
  15. // features:
  16. // layout
  17. preInitLayout,
  18. addWatermark,
  19. removeUpgradeTab, initVolumeFeatures,
  20. removeShareTrackingParam, fixSpacing,
  21. addScrollToActiveBtn,
  22. // song lists
  23. preInitSongLists,
  24. initQueueButtons,
  25. // behavior
  26. preInitBehavior,
  27. initBeforeUnloadHook, disableBeforeUnload,
  28. initAutoCloseToasts, initRememberSongTime,
  29. disableDarkReader,
  30. // input
  31. preInitInput,
  32. initArrowKeySkip, initSiteSwitch,
  33. addAnchorImprovements, initNumKeysSkip,
  34. // lyrics
  35. addMediaCtrlLyricsBtn, geniUrlBase,
  36. // menu
  37. addConfigMenuOption,
  38. // other
  39. checkVersion,
  40. } from "./features/index";
  41. {
  42. // console watermark with sexy gradient
  43. 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%);";
  44. const styleCommon = "color: #fff; font-size: 1.5em; padding-left: 6px; padding-right: 6px;";
  45. console.log();
  46. console.log(
  47. `%c${scriptInfo.name}%cv${scriptInfo.version}%c\n\nBuild ${scriptInfo.buildNumber} ─ ${scriptInfo.namespace}`,
  48. `font-weight: bold; ${styleCommon} ${styleGradient}`,
  49. `background-color: #333; ${styleCommon}`,
  50. "padding: initial;",
  51. );
  52. console.log([
  53. "Powered by:",
  54. "─ Lots of ambition",
  55. `─ My song metadata API: ${geniUrlBase}`,
  56. "─ My userscript utility library: https://github.com/Sv443-Network/UserUtils",
  57. "─ This tiny event listener library: https://github.com/ai/nanoevents",
  58. "─ The React library: https://github.com/facebook/react",
  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. registerMenuCommands();
  76. }
  77. catch(e) {
  78. void e;
  79. }
  80. try {
  81. document.addEventListener("DOMContentLoaded", () => {
  82. domLoaded = true;
  83. });
  84. const features = await initConfig();
  85. await initTranslations(features.locale ?? "en_US");
  86. setLocale(features.locale ?? "en_US");
  87. setLogLevel(features.logLevel);
  88. preInitLayout(features);
  89. preInitBehavior(features);
  90. preInitInput(features);
  91. preInitSongLists(features);
  92. if(features.disableBeforeUnloadPopup && domain === "ytm")
  93. disableBeforeUnload();
  94. if(!domLoaded)
  95. document.addEventListener("DOMContentLoaded", onDomLoad);
  96. else
  97. onDomLoad();
  98. if(features.rememberSongTime)
  99. initRememberSongTime();
  100. }
  101. catch(err) {
  102. error("General Error:", err);
  103. }
  104. // init menu separately from features
  105. try {
  106. void "TODO(v1.2):";
  107. // initMenu();
  108. }
  109. catch(err) {
  110. error("Couldn't initialize menu:", err);
  111. }
  112. }
  113. /** Called when the DOM has finished loading and can be queried and altered by the userscript */
  114. async function onDomLoad() {
  115. // post-build these double quotes are replaced by backticks (because if backticks are used here, the bundler converts them to double quotes)
  116. addGlobalStyle("#{{GLOBAL_STYLE}}");
  117. initObservers();
  118. initOnSelector();
  119. const features = getFeatures();
  120. const ftInit = [] as Promise<void>[];
  121. await checkVersion();
  122. log(`DOM loaded. Initializing features for domain "${domain}"...`);
  123. try {
  124. if(domain === "ytm") {
  125. disableDarkReader();
  126. ftInit.push(initSiteEvents());
  127. if(typeof await GM.getValue("bytm-installed") !== "string") {
  128. // open welcome menu with language selector
  129. await addWelcomeMenu();
  130. info("Showing welcome menu");
  131. await showWelcomeMenu();
  132. await GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version }));
  133. }
  134. try {
  135. ftInit.push(addCfgMenu()); // TODO(v1.2): remove
  136. }
  137. catch(err) {
  138. error("Couldn't add menu:", err);
  139. }
  140. observers.body.addListener("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
  141. listener: addConfigMenuOption,
  142. });
  143. if(features.arrowKeySupport)
  144. ftInit.push(initArrowKeySkip());
  145. if(features.removeUpgradeTab)
  146. ftInit.push(removeUpgradeTab());
  147. if(features.watermarkEnabled)
  148. ftInit.push(addWatermark());
  149. if(features.geniusLyrics)
  150. ftInit.push(addMediaCtrlLyricsBtn());
  151. if(features.deleteFromQueueButton || features.lyricsQueueButton)
  152. ftInit.push(initQueueButtons());
  153. if(features.anchorImprovements)
  154. ftInit.push(addAnchorImprovements());
  155. if(features.closeToastsTimeout > 0)
  156. ftInit.push(initAutoCloseToasts());
  157. if(features.removeShareTrackingParam)
  158. ftInit.push(removeShareTrackingParam());
  159. if(features.numKeysSkipToTime)
  160. ftInit.push(initNumKeysSkip());
  161. if(features.fixSpacing)
  162. ftInit.push(fixSpacing());
  163. if(features.scrollToActiveSongBtn)
  164. ftInit.push(addScrollToActiveBtn());
  165. ftInit.push(initVolumeFeatures());
  166. }
  167. if(["ytm", "yt"].includes(domain)) {
  168. if(features.switchBetweenSites)
  169. ftInit.push(initSiteSwitch(domain));
  170. }
  171. Promise.allSettled(ftInit).then(() => {
  172. emitInterface("bytm:ready");
  173. });
  174. }
  175. catch(err) {
  176. error("Feature error:", err);
  177. }
  178. }
  179. void ["TODO:", initFeatures];
  180. async function initFeatures() {
  181. const ftInit = [] as Promise<void>[];
  182. log(`DOM loaded. Initializing features for domain "${domain}"...`);
  183. for(const [ftKey, ftInfo] of Object.entries(featInfo)) {
  184. try {
  185. const res = ftInfo.enable() as void | Promise<void>;
  186. if(res instanceof Promise)
  187. ftInit.push(res);
  188. else
  189. ftInit.push(Promise.resolve());
  190. }
  191. catch(err) {
  192. error(`Couldn't initialize feature "${ftKey}" due to error:`, err);
  193. }
  194. }
  195. siteEvents.on("configOptionChanged", (ftKey, oldValue, newValue) => {
  196. try {
  197. // @ts-ignore
  198. if(featInfo[ftKey].change) {
  199. // @ts-ignore
  200. featInfo[ftKey].change(oldValue, newValue);
  201. }
  202. // @ts-ignore
  203. else if(featInfo[ftKey].disable) {
  204. // @ts-ignore
  205. const disableRes = featInfo[ftKey].disable();
  206. if(disableRes instanceof Promise)
  207. disableRes.then(() => featInfo[ftKey].enable());
  208. else
  209. featInfo[ftKey].enable();
  210. }
  211. else {
  212. // TODO: set "page reload required" flag in new menu
  213. if(confirm("[Work in progress]\nYou changed an option that requires a page reload to be applied.\nReload the page now?")) {
  214. disableBeforeUnload();
  215. location.reload();
  216. }
  217. }
  218. }
  219. catch(err) {
  220. error(`Couldn't change feature "${ftKey}" due to error:`, err);
  221. }
  222. });
  223. Promise.all(ftInit).then(() => {
  224. emitInterface("bytm:ready");
  225. });
  226. }
  227. function registerMenuCommands() {
  228. if(mode === "development") {
  229. GM.registerMenuCommand("Reset config", async () => {
  230. if(confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) {
  231. await clearConfig();
  232. disableBeforeUnload();
  233. location.reload();
  234. }
  235. }, "r");
  236. GM.registerMenuCommand("List GM values", async () => {
  237. const keys = await GM.listValues();
  238. console.log("GM values:");
  239. if(keys.length === 0)
  240. console.log(" No values found.");
  241. for(const key of keys)
  242. console.log(` ${key} -> ${await GM.getValue(key)}`);
  243. alert("See console.");
  244. }, "l");
  245. GM.registerMenuCommand("Delete all GM values", async () => {
  246. if(confirm("Clear all GM values?\nSee console for details.")) {
  247. const keys = await GM.listValues();
  248. console.log("Clearing GM values:");
  249. if(keys.length === 0)
  250. console.log(" No values found.");
  251. for(const key of keys) {
  252. await GM.deleteValue(key);
  253. console.log(` Deleted ${key}`);
  254. }
  255. }
  256. }, "d");
  257. GM.registerMenuCommand("Delete GM value by name", async () => {
  258. const key = prompt("Enter the name of the GM value to delete.\nEmpty input cancels the operation.");
  259. if(key && key.length > 0) {
  260. const oldVal = await GM.getValue(key);
  261. await GM.deleteValue(key);
  262. console.log(`Deleted GM value '${key}' with previous value '${oldVal}'`);
  263. }
  264. }, "n");
  265. GM.registerMenuCommand("Reset install timestamp", async () => {
  266. await GM.deleteValue("bytm-installed");
  267. console.log("Reset install time.");
  268. }, "t");
  269. GM.registerMenuCommand("Reset version check timestamp", async () => {
  270. await GM.deleteValue("bytm-version-check");
  271. console.log("Reset version check time.");
  272. }, "v");
  273. GM.registerMenuCommand("List active selector listeners", async () => {
  274. const lines = [] as string[];
  275. let listenersAmt = 0;
  276. for(const [obsName, obs] of Object.entries(observers)) {
  277. const listeners = obs.getAllListeners();
  278. lines.push(`- "${obsName}" (${listeners.size} listeners):`);
  279. [...listeners].forEach(([k, v]) => {
  280. listenersAmt += v.length;
  281. lines.push(` [${v.length}] ${k}`);
  282. v.forEach(({ all, continuous }, i) => {
  283. lines.push(` ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}, ${all ? "select multiple" : "select single"}`);
  284. });
  285. });
  286. }
  287. console.log(`Showing currently active listeners for ${Object.keys(observers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
  288. alert("See console.");
  289. }, "s");
  290. }
  291. }
  292. preInit();