index.ts 18 KB

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