index.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  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 { getWelcomeDialog, 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 { downloadData, getStoreSerializer } from "./serializer.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(38, 160, 172) 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 Cousine font: https://fonts.google.com/specimen/Cousine`;
  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 getWelcomeDialog();
  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. const hideThemeSongLogo = addStyleFromResource("css-hide_themesong_logo");
  180. if(feats.themeSongIntegration)
  181. ftInit.push(["themeSongIntegration", Promise.allSettled([fixThemeSong(), hideThemeSongLogo])]);
  182. else
  183. ftInit.push(["themeSongIntegration", Promise.allSettled([fixPlayerPageTheming(), hideThemeSongLogo])]);
  184. }
  185. //#region (ytm+yt) cfg menu
  186. try {
  187. if(domain === "ytm") {
  188. addSelectorListener("body", "tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
  189. listener: addConfigMenuOptionYTM,
  190. });
  191. }
  192. else if(domain === "yt") {
  193. addSelectorListener<0, "yt">("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", {
  194. listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement),
  195. });
  196. }
  197. }
  198. catch(err) {
  199. error("Couldn't add config menu option:", err);
  200. }
  201. if(["ytm", "yt"].includes(domain)) {
  202. //#region general
  203. ftInit.push(["initSiteEvents", initSiteEvents()]);
  204. //#region (ytm+yt) layout
  205. if(feats.removeShareTrackingParamSites && (feats.removeShareTrackingParamSites === domain || feats.removeShareTrackingParamSites === "all"))
  206. ftInit.push(["initRemShareTrackParam", initRemShareTrackParam()]);
  207. //#region (ytm+yt) input
  208. ftInit.push(["siteSwitch", initSiteSwitch(domain)]);
  209. if(feats.autoLikeChannels)
  210. ftInit.push(["autoLikeChannels", initAutoLike()]);
  211. //#region (ytm+yt) integrations
  212. if(feats.disableDarkReaderSites !== "none")
  213. ftInit.push(["disableDarkReaderSites", disableDarkReader()]);
  214. }
  215. emitInterface("bytm:featureInitStarted");
  216. const initStartTs = Date.now();
  217. // wait for feature init or timeout (in case an init function is hung up on a promise)
  218. await Promise.race([
  219. pauseFor(feats.initTimeout > 0 ? feats.initTimeout * 1000 : 8_000),
  220. Promise.allSettled(
  221. ftInit.map(([name, prom]) =>
  222. new Promise(async (res) => {
  223. const v = await prom;
  224. emitInterface("bytm:featureInitialized", name);
  225. res(v);
  226. })
  227. )
  228. ),
  229. ]);
  230. emitInterface("bytm:ready");
  231. info(`Done initializing ${ftInit.length} features after ${Math.floor(Date.now() - initStartTs)}ms`);
  232. try {
  233. registerDevCommands();
  234. }
  235. catch(e) {
  236. warn("Couldn't register dev menu commands:", e);
  237. }
  238. }
  239. catch(err) {
  240. error("Feature error:", err);
  241. emitInterface("bytm:fatalError", "Error while initializing features");
  242. }
  243. }
  244. //#region css
  245. /** Inserts the bundled CSS files imported throughout the script into a <style> element in the <head> */
  246. async function injectCssBundle() {
  247. if(!await addStyleFromResource("css-bundle"))
  248. error("Couldn't inject CSS bundle due to an error");
  249. }
  250. /** Initializes global CSS variables */
  251. function initGlobalCssVars() {
  252. try {
  253. loadFonts();
  254. const applyVars = () => {
  255. setGlobalCssVars({
  256. "inner-height": `${window.innerHeight}px`,
  257. "outer-height": `${window.outerHeight}px`,
  258. "inner-width": `${window.innerWidth}px`,
  259. "outer-width": `${window.outerWidth}px`,
  260. });
  261. };
  262. window.addEventListener("resize", applyVars);
  263. applyVars();
  264. }
  265. catch(err) {
  266. error("Couldn't initialize global CSS variables:", err);
  267. }
  268. }
  269. async function loadFonts() {
  270. const fonts = {
  271. "Cousine": {
  272. woff: await getResourceUrl("font-cousine_woff"),
  273. woff2: await getResourceUrl("font-cousine_woff2"),
  274. ttf: await getResourceUrl("font-cousine_ttf"),
  275. },
  276. };
  277. let css = "";
  278. for(const [font, urls] of Object.entries(fonts))
  279. css += `\
  280. @font-face {
  281. font-family: "${font}";
  282. src: url("${urls.woff2}") format("woff2"),
  283. url("${urls.woff}") format("woff"),
  284. url("${urls.ttf}") format("truetype");
  285. font-weight: normal;
  286. font-style: normal;
  287. font-display: swap;
  288. }
  289. `;
  290. addStyle(css, "fonts");
  291. }
  292. //#region dev menu cmds
  293. /** Registers dev commands using `GM.registerMenuCommand` */
  294. function registerDevCommands() {
  295. if(mode !== "development")
  296. return;
  297. GM.registerMenuCommand("Reset config", async () => {
  298. if(confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) {
  299. await clearConfig();
  300. disableBeforeUnload();
  301. location.reload();
  302. }
  303. }, "r");
  304. GM.registerMenuCommand("Fix config values", async () => {
  305. const oldFeats = JSON.parse(JSON.stringify(getFeatures())) as FeatureConfig;
  306. await setFeatures(fixCfgKeys(oldFeats));
  307. dbg("Fixed missing or extraneous config values.\nFrom:", oldFeats, "\n\nTo:", getFeatures());
  308. 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?"))
  309. location.reload();
  310. });
  311. GM.registerMenuCommand("List GM values in console with decompression", async () => {
  312. const keys = await GM.listValues();
  313. dbg(`GM values (${keys.length}):`);
  314. if(keys.length === 0)
  315. dbg(" No values found.");
  316. const values = {} as Record<string, Stringifiable | undefined>;
  317. let longestKey = 0;
  318. for(const key of keys) {
  319. const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  320. const val = await GM.getValue(key, undefined);
  321. values[key] = typeof val !== "undefined" && isEncoded ? await decompress(val, compressionFormat, "string") : val;
  322. longestKey = Math.max(longestKey, key.length);
  323. }
  324. for(const [key, finalVal] of Object.entries(values)) {
  325. const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
  326. const lengthStr = String(finalVal).length > 50 ? `(${String(finalVal).length} chars) ` : "";
  327. dbg(` "${key}"${" ".repeat(longestKey - key.length)} -${isEncoded ? "-[decoded]-" : ""}> ${lengthStr}${finalVal}`);
  328. }
  329. }, "l");
  330. GM.registerMenuCommand("List GM values in console, without decompression", async () => {
  331. const keys = await GM.listValues();
  332. dbg(`GM values (${keys.length}):`);
  333. if(keys.length === 0)
  334. dbg(" No values found.");
  335. const values = {} as Record<string, Stringifiable | undefined>;
  336. let longestKey = 0;
  337. for(const key of keys) {
  338. const val = await GM.getValue(key, undefined);
  339. values[key] = val;
  340. longestKey = Math.max(longestKey, key.length);
  341. }
  342. for(const [key, val] of Object.entries(values)) {
  343. const lengthStr = String(val).length >= 16 ? `(${String(val).length} chars) ` : "";
  344. dbg(` "${key}"${" ".repeat(longestKey - key.length)} -> ${lengthStr}${val}`);
  345. }
  346. });
  347. GM.registerMenuCommand("Delete all GM values", async () => {
  348. const keys = await GM.listValues();
  349. if(confirm(`Clear all ${keys.length} GM values?\nSee console for details.`)) {
  350. dbg(`Clearing ${keys.length} GM values:`);
  351. if(keys.length === 0)
  352. dbg(" No values found.");
  353. for(const key of keys) {
  354. await GM.deleteValue(key);
  355. dbg(` Deleted ${key}`);
  356. }
  357. }
  358. }, "d");
  359. GM.registerMenuCommand("Delete GM values by name (comma separated)", async () => {
  360. const keys = await showPrompt({ type: "prompt", message: "Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation." });
  361. if(!keys)
  362. return;
  363. for(const key of keys?.split(",") ?? []) {
  364. if(key && key.length > 0) {
  365. const truncLength = 400;
  366. const oldVal = await GM.getValue(key);
  367. await GM.deleteValue(key);
  368. dbg(`Deleted GM value '${key}' with previous value '${oldVal && String(oldVal).length > truncLength ? String(oldVal).substring(0, truncLength) + `… (${String(oldVal).length} / ${truncLength} chars.)` : oldVal}'`);
  369. }
  370. }
  371. }, "n");
  372. GM.registerMenuCommand("Reset install timestamp", async () => {
  373. await GM.deleteValue("bytm-installed");
  374. dbg("Reset install time.");
  375. }, "t");
  376. GM.registerMenuCommand("Reset version check timestamp", async () => {
  377. await GM.deleteValue("bytm-version-check");
  378. dbg("Reset version check time.");
  379. }, "v");
  380. GM.registerMenuCommand("List active selector listeners in console", async () => {
  381. const lines = [] as string[];
  382. let listenersAmt = 0;
  383. for(const [obsName, obs] of Object.entries(globservers)) {
  384. const listeners = obs.getAllListeners();
  385. lines.push(`- "${obsName}" (${listeners.size} listeners):`);
  386. [...listeners].forEach(([k, v]) => {
  387. listenersAmt += v.length;
  388. lines.push(` [${v.length}] ${k}`);
  389. v.forEach(({ all, continuous }, i) => {
  390. lines.push(` ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}${all ? ", multiple" : ""}`);
  391. });
  392. });
  393. }
  394. dbg(`Showing currently active listeners for ${Object.keys(globservers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
  395. }, "s");
  396. GM.registerMenuCommand("Compress value", async () => {
  397. const input = await showPrompt({ type: "prompt", message: "Enter the value to compress.\nSee console for output." });
  398. if(input && input.length > 0) {
  399. const compressed = await compress(input, compressionFormat);
  400. dbg(`Compression result (${input.length} chars -> ${compressed.length} chars)\nValue: ${compressed}`);
  401. }
  402. });
  403. GM.registerMenuCommand("Decompress value", async () => {
  404. const input = await showPrompt({ type: "prompt", message: "Enter the value to decompress.\nSee console for output." });
  405. if(input && input.length > 0) {
  406. const decompressed = await decompress(input, compressionFormat);
  407. dbg(`Decompresion result (${input.length} chars -> ${decompressed.length} chars)\nValue: ${decompressed}`);
  408. }
  409. });
  410. GM.registerMenuCommand("Export all data using DataStoreSerializer", async () => {
  411. const ser = await getStoreSerializer().serialize();
  412. dbg("Serialized data stores:", JSON.stringify(JSON.parse(ser)));
  413. alert("See console.");
  414. });
  415. GM.registerMenuCommand("Import all data using DataStoreSerializer", async () => {
  416. const input = await showPrompt({ type: "prompt", message: "Enter the serialized data to import:" });
  417. if(input && input.length > 0) {
  418. await getStoreSerializer().deserialize(input);
  419. alert("Imported data. Reload the page to apply changes.");
  420. }
  421. });
  422. GM.registerMenuCommand("Throw specific Error", () => error("Test error thrown by user command:", new SyntaxError("Test error")));
  423. GM.registerMenuCommand("Throw generic Error", () => error());
  424. GM.registerMenuCommand("Example MarkdownDialog", async () => {
  425. const mdDlg = new MarkdownDialog({
  426. id: "example",
  427. width: 500,
  428. height: 400,
  429. renderHeader() {
  430. const header = document.createElement("h1");
  431. header.textContent = "Example Markdown Dialog";
  432. return header;
  433. },
  434. 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",
  435. });
  436. await mdDlg.open();
  437. });
  438. GM.registerMenuCommand("Download DataStoreSerializer file", () => downloadData());
  439. log("Registered dev menu commands");
  440. }
  441. preInit();