index.ts 17 KB

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