index.ts 20 KB

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