index.ts 14 KB

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