index.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import { compress, decompress, pauseFor, type Stringifiable } from "@sv443-network/userutils";
  2. import { addStyleFromResource, domLoaded, warn } from "./utils/index.js";
  3. import { clearConfig, fixCfgKeys, getFeatures, initConfig, setFeatures } from "./config.js";
  4. import { buildNumber, compressionFormat, defaultLogLevel, mode, scriptInfo } from "./constants.js";
  5. import { dbg, error, getDomain, info, getSessionId, log, setLogLevel, initTranslations, setLocale } from "./utils/index.js";
  6. import { initSiteEvents } from "./siteEvents.js";
  7. import { emitInterface, initInterface, initPlugins } from "./interface.js";
  8. import { initObservers, addSelectorListener, globservers } from "./observers.js";
  9. import { getWelcomeDialog } from "./dialogs/index.js";
  10. import type { FeatureConfig } from "./types.js";
  11. import {
  12. // layout
  13. addWatermark, initRemShareTrackParam, fixSpacing,
  14. initThumbnailOverlay, initHideCursorOnIdle, fixHdrIssues,
  15. initShowVotes,
  16. // volume
  17. initVolumeFeatures,
  18. // song lists
  19. initQueueButtons, initAboveQueueBtns,
  20. // behavior
  21. initBeforeUnloadHook, disableBeforeUnload, initAutoCloseToasts,
  22. initRememberSongTime, disableDarkReader,
  23. // input
  24. initArrowKeySkip, initSiteSwitch, addAnchorImprovements,
  25. initNumKeysSkip, initAutoLike,
  26. // lyrics
  27. addPlayerBarLyricsBtn, initLyricsCache,
  28. // menu
  29. addConfigMenuOptionYT, addConfigMenuOptionYTM,
  30. // general
  31. initVersionCheck,
  32. } from "./features/index.js";
  33. //#region console watermark
  34. {
  35. // console watermark with sexy gradient
  36. 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%);";
  37. const styleCommon = "color: #fff; font-size: 1.3rem;";
  38. console.log(
  39. `%c${scriptInfo.name}%c${scriptInfo.version}%c • ${scriptInfo.namespace}%c\n\nBuild #${buildNumber}`,
  40. `${styleCommon} ${styleGradient} font-weight: bold; padding-left: 6px; padding-right: 6px;`,
  41. `${styleCommon} background-color: #333; padding-left: 8px; padding-right: 8px;`,
  42. "color: #fff; font-size: 1.2rem;",
  43. "padding: initial;",
  44. );
  45. console.log([
  46. "Powered by:",
  47. "─ Lots of ambition and dedication",
  48. "─ My song metadata API: https://api.sv443.net/geniurl",
  49. "─ My userscript utility library: https://github.com/Sv443-Network/UserUtils",
  50. "─ This library for semver comparison: https://github.com/omichelsen/compare-versions",
  51. "─ This tiny event listener library: https://github.com/ai/nanoevents",
  52. "─ This markdown parser library: https://github.com/markedjs/marked",
  53. "─ This fuzzy search library: https://github.com/krisk/Fuse",
  54. ].join("\n"));
  55. }
  56. //#region preInit
  57. /** Stuff that needs to be called ASAP, before anything async happens */
  58. function preInit() {
  59. try {
  60. log("Session ID:", getSessionId());
  61. initInterface();
  62. setLogLevel(defaultLogLevel);
  63. if(getDomain() === "ytm")
  64. initBeforeUnloadHook();
  65. init();
  66. }
  67. catch(err) {
  68. return error("Fatal pre-init error:", err);
  69. }
  70. }
  71. //#region init
  72. async function init() {
  73. try {
  74. const domain = getDomain();
  75. const features = await initConfig();
  76. setLogLevel(features.logLevel);
  77. await initLyricsCache();
  78. await initTranslations(features.locale ?? "en_US");
  79. setLocale(features.locale ?? "en_US");
  80. emitInterface("bytm:registerPlugins");
  81. if(features.disableBeforeUnloadPopup && domain === "ytm")
  82. disableBeforeUnload();
  83. if(features.rememberSongTime)
  84. initRememberSongTime();
  85. if(!domLoaded)
  86. document.addEventListener("DOMContentLoaded", onDomLoad, { once: true });
  87. else
  88. onDomLoad();
  89. }
  90. catch(err) {
  91. error("Fatal error:", err);
  92. }
  93. }
  94. //#region onDomLoad
  95. /** Called when the DOM has finished loading and can be queried and altered by the userscript */
  96. async function onDomLoad() {
  97. const domain = getDomain();
  98. const feats = getFeatures();
  99. const ftInit = [] as [string, Promise<void>][];
  100. // for being able to apply domain-specific styles (prefix any CSS selector with "body.bytm-dom-yt" or "body.bytm-dom-ytm")
  101. document.body.classList.add(`bytm-dom-${domain}`);
  102. try {
  103. initObservers();
  104. await Promise.allSettled([
  105. insertGlobalStyle(),
  106. initVersionCheck(),
  107. ]);
  108. }
  109. catch(err) {
  110. error("Fatal error in feature pre-init:", err);
  111. return;
  112. }
  113. log(`DOM loaded and feature pre-init finished, now initializing all features for domain "${domain}"...`);
  114. try {
  115. //#region welcome dlg
  116. if(typeof await GM.getValue("bytm-installed") !== "string") {
  117. // open welcome menu with language selector
  118. const dlg = await getWelcomeDialog();
  119. dlg.on("close", () => GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version })));
  120. info("Showing welcome menu");
  121. await dlg.open();
  122. }
  123. if(domain === "ytm") {
  124. //#region (ytm) layout
  125. if(feats.watermarkEnabled)
  126. ftInit.push(["addWatermark", addWatermark()]);
  127. if(feats.fixSpacing)
  128. ftInit.push(["fixSpacing", fixSpacing()]);
  129. ftInit.push(["thumbnailOverlay", initThumbnailOverlay()]);
  130. if(feats.hideCursorOnIdle)
  131. ftInit.push(["hideCursorOnIdle", initHideCursorOnIdle()]);
  132. if(feats.fixHdrIssues)
  133. ftInit.push(["fixHdrIssues", fixHdrIssues()]);
  134. if(feats.showVotes)
  135. ftInit.push(["showVotes", initShowVotes()]);
  136. //#region (ytm) volume
  137. ftInit.push(["volumeFeatures", initVolumeFeatures()]);
  138. //#region (ytm) song lists
  139. if(feats.lyricsQueueButton || feats.deleteFromQueueButton)
  140. ftInit.push(["queueButtons", initQueueButtons()]);
  141. ftInit.push(["aboveQueueBtns", initAboveQueueBtns()]);
  142. //#region (ytm) behavior
  143. if(feats.closeToastsTimeout > 0)
  144. ftInit.push(["autoCloseToasts", initAutoCloseToasts()]);
  145. //#region (ytm) input
  146. ftInit.push(["arrowKeySkip", initArrowKeySkip()]);
  147. if(feats.anchorImprovements)
  148. ftInit.push(["anchorImprovements", addAnchorImprovements()]);
  149. ftInit.push(["numKeysSkip", initNumKeysSkip()]);
  150. //#region (ytm) lyrics
  151. if(feats.geniusLyrics)
  152. ftInit.push(["playerBarLyricsBtn", addPlayerBarLyricsBtn()]);
  153. }
  154. //#region (ytm+yt) cfg menu
  155. try {
  156. if(domain === "ytm") {
  157. addSelectorListener("body", "tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
  158. listener: addConfigMenuOptionYTM,
  159. });
  160. }
  161. else if(domain === "yt") {
  162. addSelectorListener<0, "yt">("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", {
  163. listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement),
  164. });
  165. }
  166. }
  167. catch(err) {
  168. error("Couldn't add config menu option:", err);
  169. }
  170. if(["ytm", "yt"].includes(domain)) {
  171. //#region general
  172. ftInit.push(["initSiteEvents", initSiteEvents()]);
  173. //#region (ytm+yt) layout
  174. if(feats.disableDarkReaderSites !== "none")
  175. disableDarkReader();
  176. if(feats.removeShareTrackingParamSites && (feats.removeShareTrackingParamSites === domain || feats.removeShareTrackingParamSites === "all"))
  177. ftInit.push(["initRemShareTrackParam", initRemShareTrackParam()]);
  178. //#region (ytm+yt) input
  179. ftInit.push(["siteSwitch", initSiteSwitch(domain)]);
  180. if(feats.autoLikeChannels)
  181. ftInit.push(["autoLikeChannels", initAutoLike()]);
  182. }
  183. emitInterface("bytm:featureInitStarted");
  184. try {
  185. initPlugins();
  186. }
  187. catch(err) {
  188. error("Plugin loading error:", err);
  189. emitInterface("bytm:fatalError", "Error while loading plugins");
  190. }
  191. const initStartTs = Date.now();
  192. // wait for feature init or timeout (in case an init function is hung up on a promise)
  193. await Promise.race([
  194. pauseFor(feats.initTimeout > 0 ? feats.initTimeout * 1000 : 8_000),
  195. Promise.allSettled(
  196. ftInit.map(([name, prom]) =>
  197. new Promise(async (res) => {
  198. const v = await prom;
  199. emitInterface("bytm:featureInitialized", name);
  200. res(v);
  201. })
  202. )
  203. ),
  204. ]);
  205. emitInterface("bytm:ready");
  206. info(`Done initializing all ${ftInit.length} features after ${Math.floor(Date.now() - initStartTs)}ms`);
  207. try {
  208. registerDevMenuCommands();
  209. }
  210. catch(e) {
  211. warn("Couldn't register dev menu commands:", e);
  212. }
  213. }
  214. catch(err) {
  215. error("Feature error:", err);
  216. emitInterface("bytm:fatalError", "Error while initializing features");
  217. }
  218. }
  219. //#region insert css bundle
  220. /** Inserts the bundled CSS files imported throughout the script into a <style> element in the <head> */
  221. async function insertGlobalStyle() {
  222. if(!await addStyleFromResource("css-bundle"))
  223. error("Couldn't add global CSS bundle due to an error");
  224. }
  225. //#region dev menu cmds
  226. /** Registers dev commands using `GM.registerMenuCommand` */
  227. function registerDevMenuCommands() {
  228. if(mode !== "development")
  229. return;
  230. GM.registerMenuCommand("Reset config", async () => {
  231. if(confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) {
  232. await clearConfig();
  233. disableBeforeUnload();
  234. location.reload();
  235. }
  236. }, "r");
  237. GM.registerMenuCommand("Fix config values", async () => {
  238. const oldFeats = JSON.parse(JSON.stringify(getFeatures())) as FeatureConfig;
  239. await setFeatures(fixCfgKeys(oldFeats));
  240. dbg("Fixed missing or extraneous config values.\nFrom:", oldFeats, "\n\nTo:", getFeatures());
  241. if(confirm("All missing or config values were set to their default values and extraneous ones were removed.\nDo you want to reload the page now?"))
  242. location.reload();
  243. });
  244. GM.registerMenuCommand("List GM values in console with decompression", async () => {
  245. const keys = await GM.listValues();
  246. dbg(`GM values (${keys.length}):`);
  247. if(keys.length === 0)
  248. dbg(" No values found.");
  249. const values = {} as Record<string, Stringifiable | undefined>;
  250. let longestKey = 0;
  251. for(const key of keys) {
  252. const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  253. const val = await GM.getValue(key, undefined);
  254. values[key] = typeof val !== "undefined" && isEncoded ? await decompress(val, compressionFormat, "string") : val;
  255. longestKey = Math.max(longestKey, key.length);
  256. }
  257. for(const [key, finalVal] of Object.entries(values)) {
  258. const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  259. const lengthStr = String(finalVal).length > 50 ? `(${String(finalVal).length} chars) ` : "";
  260. dbg(` "${key}"${" ".repeat(longestKey - key.length)} -${isEncoded ? "-[decoded]-" : ""}> ${lengthStr}${finalVal}`);
  261. }
  262. }, "l");
  263. GM.registerMenuCommand("List GM values in console, without decompression", async () => {
  264. const keys = await GM.listValues();
  265. dbg(`GM values (${keys.length}):`);
  266. if(keys.length === 0)
  267. dbg(" No values found.");
  268. const values = {} as Record<string, Stringifiable | undefined>;
  269. let longestKey = 0;
  270. for(const key of keys) {
  271. const val = await GM.getValue(key, undefined);
  272. values[key] = val;
  273. longestKey = Math.max(longestKey, key.length);
  274. }
  275. for(const [key, val] of Object.entries(values)) {
  276. const lengthStr = String(val).length >= 16 ? `(${String(val).length} chars) ` : "";
  277. dbg(` "${key}"${" ".repeat(longestKey - key.length)} -> ${lengthStr}${val}`);
  278. }
  279. });
  280. GM.registerMenuCommand("Delete all GM values", async () => {
  281. const keys = await GM.listValues();
  282. if(confirm(`Clear all ${keys.length} GM values?\nSee console for details.`)) {
  283. dbg(`Clearing ${keys.length} GM values:`);
  284. if(keys.length === 0)
  285. dbg(" No values found.");
  286. for(const key of keys) {
  287. await GM.deleteValue(key);
  288. dbg(` Deleted ${key}`);
  289. }
  290. }
  291. }, "d");
  292. GM.registerMenuCommand("Delete GM values by name (comma separated)", async () => {
  293. const keys = prompt("Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation.");
  294. if(!keys)
  295. return;
  296. for(const key of keys?.split(",") ?? []) {
  297. if(key && key.length > 0) {
  298. const truncLength = 400;
  299. const oldVal = await GM.getValue(key);
  300. await GM.deleteValue(key);
  301. dbg(`Deleted GM value '${key}' with previous value '${oldVal && String(oldVal).length > truncLength ? String(oldVal).substring(0, truncLength) + `… (${String(oldVal).length} / ${truncLength} chars.)` : oldVal}'`);
  302. }
  303. }
  304. }, "n");
  305. GM.registerMenuCommand("Reset install timestamp", async () => {
  306. await GM.deleteValue("bytm-installed");
  307. dbg("Reset install time.");
  308. }, "t");
  309. GM.registerMenuCommand("Reset version check timestamp", async () => {
  310. await GM.deleteValue("bytm-version-check");
  311. dbg("Reset version check time.");
  312. }, "v");
  313. GM.registerMenuCommand("List active selector listeners in console", async () => {
  314. const lines = [] as string[];
  315. let listenersAmt = 0;
  316. for(const [obsName, obs] of Object.entries(globservers)) {
  317. const listeners = obs.getAllListeners();
  318. lines.push(`- "${obsName}" (${listeners.size} listeners):`);
  319. [...listeners].forEach(([k, v]) => {
  320. listenersAmt += v.length;
  321. lines.push(` [${v.length}] ${k}`);
  322. v.forEach(({ all, continuous }, i) => {
  323. lines.push(` ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}${all ? ", multiple" : ""}`);
  324. });
  325. });
  326. }
  327. dbg(`Showing currently active listeners for ${Object.keys(globservers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
  328. }, "s");
  329. GM.registerMenuCommand("Compress value", async () => {
  330. const input = prompt("Enter the value to compress.\nSee console for output.");
  331. if(input && input.length > 0) {
  332. const compressed = await compress(input, compressionFormat);
  333. dbg(`Compression result (${input.length} chars -> ${compressed.length} chars)\nValue: ${compressed}`);
  334. }
  335. });
  336. GM.registerMenuCommand("Decompress value", async () => {
  337. const input = prompt("Enter the value to decompress.\nSee console for output.");
  338. if(input && input.length > 0) {
  339. const decompressed = await decompress(input, compressionFormat);
  340. dbg(`Decompresion result (${input.length} chars -> ${decompressed.length} chars)\nValue: ${decompressed}`);
  341. }
  342. });
  343. log("Registered dev menu commands");
  344. }
  345. preInit();