index.ts 19 KB

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