menu_old.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930
  1. import { compress, debounce, isScrollable, type Stringifiable } from "@sv443-network/userutils";
  2. import { defaultData, formatVersion, getFeature, getFeatures, migrations, setFeatures } from "../config.js";
  3. import { buildNumber, compressionFormat, host, mode, scriptInfo } from "../constants.js";
  4. import { featInfo, disableBeforeUnload } from "../features/index.js";
  5. import { error, getResourceUrl, info, log, resourceToHTMLString, getLocale, hasKey, initTranslations, setLocale, t, arrayWithSeparators, tp, type TrKey, onInteraction, getDomain, copyToClipboard, warn, compressionSupported, tryToDecompressAndParse } from "../utils/index.js";
  6. import { emitSiteEvent, siteEvents } from "../siteEvents.js";
  7. import { getChangelogDialog, getFeatHelpDialog } from "../dialogs/index.js";
  8. import type { FeatureCategory, FeatureKey, FeatureConfig, HotkeyObj, FeatureInfo } from "../types.js";
  9. import { BytmDialog, ExImDialog, createHotkeyInput, createToggleInput, openDialogs, setCurrentDialogId } from "../components/index.js";
  10. import { emitInterface } from "../interface.js";
  11. import pkg from "../../package.json" with { type: "json" };
  12. import "./menu_old.css";
  13. //#region create menu
  14. let isCfgMenuMounted = false;
  15. export let isCfgMenuOpen = false;
  16. /** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */
  17. const scrollIndicatorOffsetThreshold = 50;
  18. let scrollIndicatorEnabled = true;
  19. /** Locale at the point of initializing the config menu */
  20. let initLocale: string | undefined;
  21. /** Stringified config at the point of initializing the config menu */
  22. let initConfig: FeatureConfig | undefined;
  23. /** Timeout id for the "copied" text in the hidden value copy button */
  24. let hiddenCopiedTxtTimeout: ReturnType<typeof setTimeout> | undefined;
  25. /**
  26. * Adds an element to open the BetterYTM menu
  27. * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23
  28. */
  29. async function mountCfgMenu() {
  30. if(isCfgMenuMounted)
  31. return;
  32. isCfgMenuMounted = true;
  33. initLocale = getFeature("locale");
  34. initConfig = getFeatures();
  35. const initLangReloadText = t("lang_changed_prompt_reload");
  36. //#region bg & container
  37. const backgroundElem = document.createElement("div");
  38. backgroundElem.id = "bytm-cfg-menu-bg";
  39. backgroundElem.classList.add("bytm-menu-bg");
  40. backgroundElem.ariaLabel = backgroundElem.title = t("close_menu_tooltip");
  41. backgroundElem.style.visibility = "hidden";
  42. backgroundElem.style.display = "none";
  43. backgroundElem.addEventListener("click", (e) => {
  44. if(isCfgMenuOpen && (e.target as HTMLElement)?.id === "bytm-cfg-menu-bg")
  45. closeCfgMenu(e);
  46. });
  47. document.body.addEventListener("keydown", (e) => {
  48. if(isCfgMenuOpen && e.key === "Escape" && BytmDialog.getCurrentDialogId() === "cfg-menu")
  49. closeCfgMenu(e);
  50. });
  51. const menuContainer = document.createElement("div");
  52. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  53. menuContainer.classList.add("bytm-menu");
  54. menuContainer.id = "bytm-cfg-menu";
  55. //#region title bar
  56. const headerElem = document.createElement("div");
  57. headerElem.classList.add("bytm-menu-header");
  58. const titleLogoHeaderCont = document.createElement("div");
  59. titleLogoHeaderCont.classList.add("bytm-menu-title-logo-header-cont");
  60. const titleCont = document.createElement("div");
  61. titleCont.classList.add("bytm-menu-titlecont");
  62. titleCont.role = "heading";
  63. titleCont.ariaLevel = "1";
  64. const titleLogoElem = document.createElement("img");
  65. const logoSrc = await getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`);
  66. titleLogoElem.classList.add("bytm-cfg-menu-logo", "bytm-no-select");
  67. if(logoSrc)
  68. titleLogoElem.src = logoSrc;
  69. titleLogoHeaderCont.appendChild(titleLogoElem);
  70. const titleElem = document.createElement("h2");
  71. titleElem.classList.add("bytm-menu-title");
  72. const titleTextElem = document.createElement("div");
  73. titleTextElem.textContent = t("config_menu_title", scriptInfo.name);
  74. titleElem.appendChild(titleTextElem);
  75. const linksCont = document.createElement("div");
  76. linksCont.id = "bytm-menu-linkscont";
  77. linksCont.role = "navigation";
  78. const linkTitlesShort = {
  79. github: "GitHub",
  80. greasyfork: "GreasyFork",
  81. openuserjs: "OpenUserJS",
  82. discord: "Discord",
  83. };
  84. const addLink = (imgSrc: string, href: string, title: string, titleKey: keyof typeof linkTitlesShort) => {
  85. const anchorElem = document.createElement("a");
  86. anchorElem.classList.add("bytm-menu-link", "bytm-no-select");
  87. anchorElem.rel = "noopener noreferrer";
  88. anchorElem.href = href;
  89. anchorElem.target = "_blank";
  90. anchorElem.tabIndex = 0;
  91. anchorElem.role = "button";
  92. anchorElem.ariaLabel = anchorElem.title = title;
  93. const extendedAnchorEl = document.createElement("a");
  94. extendedAnchorEl.classList.add("bytm-menu-link", "extended-link", "bytm-no-select");
  95. extendedAnchorEl.rel = "noopener noreferrer";
  96. extendedAnchorEl.href = href;
  97. extendedAnchorEl.target = "_blank";
  98. extendedAnchorEl.tabIndex = -1;
  99. extendedAnchorEl.textContent = linkTitlesShort[titleKey];
  100. extendedAnchorEl.ariaLabel = extendedAnchorEl.title = title;
  101. const imgElem = document.createElement("img");
  102. imgElem.classList.add("bytm-menu-img");
  103. imgElem.src = imgSrc;
  104. anchorElem.appendChild(imgElem);
  105. anchorElem.appendChild(extendedAnchorEl);
  106. linksCont.appendChild(anchorElem);
  107. };
  108. const links: [name: string, ...Parameters<typeof addLink>][] = [
  109. ["github", await getResourceUrl("img-github"), scriptInfo.namespace, t("open_github", scriptInfo.name), "github"],
  110. ["greasyfork", await getResourceUrl("img-greasyfork"), pkg.hosts.greasyfork, t("open_greasyfork", scriptInfo.name), "greasyfork"],
  111. ["openuserjs", await getResourceUrl("img-openuserjs"), pkg.hosts.openuserjs, t("open_openuserjs", scriptInfo.name), "openuserjs"],
  112. ];
  113. const hostLink = links.find(([name]) => name === host);
  114. const otherLinks = links.filter(([name]) => name !== host);
  115. const reorderedLinks = hostLink ? [hostLink, ...otherLinks] : links;
  116. for(const [, ...args] of reorderedLinks)
  117. addLink(...args);
  118. addLink(await getResourceUrl("img-discord"), "https://dc.sv443.net/", t("open_discord"), "discord");
  119. const closeElem = document.createElement("img");
  120. closeElem.classList.add("bytm-menu-close");
  121. closeElem.role = "button";
  122. closeElem.tabIndex = 0;
  123. closeElem.src = await getResourceUrl("img-close");
  124. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  125. onInteraction(closeElem, closeCfgMenu);
  126. titleCont.appendChild(titleElem);
  127. titleCont.appendChild(linksCont);
  128. titleLogoHeaderCont.appendChild(titleCont);
  129. headerElem.appendChild(titleLogoHeaderCont);
  130. headerElem.appendChild(closeElem);
  131. //#region footer
  132. const footerCont = document.createElement("div");
  133. footerCont.classList.add("bytm-menu-footer-cont");
  134. const reloadFooterCont = document.createElement("div");
  135. const reloadFooterEl = document.createElement("div");
  136. reloadFooterEl.classList.add("bytm-menu-footer", "hidden");
  137. reloadFooterEl.setAttribute("aria-hidden", "true");
  138. reloadFooterEl.textContent = t("reload_hint");
  139. reloadFooterEl.role = "alert";
  140. reloadFooterEl.ariaLive = "polite";
  141. const reloadTxtEl = document.createElement("button");
  142. reloadTxtEl.classList.add("bytm-btn");
  143. reloadTxtEl.style.marginLeft = "10px";
  144. reloadTxtEl.textContent = t("reload_now");
  145. reloadTxtEl.ariaLabel = reloadTxtEl.title = t("reload_tooltip");
  146. reloadTxtEl.addEventListener("click", () => {
  147. closeCfgMenu();
  148. disableBeforeUnload();
  149. location.reload();
  150. });
  151. reloadFooterEl.appendChild(reloadTxtEl);
  152. reloadFooterCont.appendChild(reloadFooterEl);
  153. /** For copying plain when shift-clicking the copy button or when compression is not supported */
  154. const exportDataSpecial = () => JSON.stringify({ formatVersion, data: getFeatures() });
  155. const exImDlg = new ExImDialog({
  156. id: "bytm-config-import-export",
  157. width: 800,
  158. height: 600,
  159. // try to compress the data if possible
  160. exportData: async () => await compressionSupported()
  161. ? await compress(JSON.stringify({ formatVersion, data: getFeatures() }), compressionFormat, "string")
  162. : exportDataSpecial(),
  163. exportDataSpecial,
  164. async onImport(data) {
  165. try {
  166. const parsed = await tryToDecompressAndParse<{ data: FeatureConfig, formatVersion: number }>(data.trim());
  167. log("Trying to import configuration:", parsed);
  168. if(!parsed || typeof parsed !== "object")
  169. return alert(t("import_error_invalid"));
  170. if(typeof parsed.formatVersion !== "number")
  171. return alert(t("import_error_no_format_version"));
  172. if(typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0)
  173. return alert(t("import_error_no_data"));
  174. if(parsed.formatVersion < formatVersion) {
  175. let newData = JSON.parse(JSON.stringify(parsed.data));
  176. const sortedMigrations = Object.entries(migrations)
  177. .sort(([a], [b]) => Number(a) - Number(b));
  178. let curFmtVer = Number(parsed.formatVersion);
  179. for(const [fmtVer, migrationFunc] of sortedMigrations) {
  180. const ver = Number(fmtVer);
  181. if(curFmtVer < formatVersion && curFmtVer < ver) {
  182. try {
  183. const migRes = JSON.parse(JSON.stringify(migrationFunc(newData)));
  184. newData = migRes instanceof Promise ? await migRes : migRes;
  185. curFmtVer = ver;
  186. }
  187. catch(err) {
  188. error(`Error while running migration function for format version ${fmtVer}:`, err);
  189. }
  190. }
  191. }
  192. parsed.formatVersion = curFmtVer;
  193. parsed.data = newData;
  194. }
  195. else if(parsed.formatVersion !== formatVersion)
  196. return alert(t("import_error_wrong_format_version", formatVersion, parsed.formatVersion));
  197. await setFeatures({ ...getFeatures(), ...parsed.data });
  198. if(confirm(t("import_success_confirm_reload"))) {
  199. disableBeforeUnload();
  200. return location.reload();
  201. }
  202. exImDlg.unmount();
  203. emitSiteEvent("rebuildCfgMenu", parsed.data);
  204. }
  205. catch(err) {
  206. warn("Couldn't import configuration:", err);
  207. alert(t("import_error_invalid"));
  208. }
  209. },
  210. title: () => t("bytm_config_export_import_title"),
  211. descImport: () => t("bytm_config_import_desc"),
  212. descExport: () => t("bytm_config_export_desc"),
  213. });
  214. const exportImportBtn = document.createElement("button");
  215. exportImportBtn.classList.add("bytm-btn");
  216. exportImportBtn.textContent = exportImportBtn.ariaLabel = exportImportBtn.title = t("export_import");
  217. onInteraction(exportImportBtn, async () => await exImDlg.open());
  218. const buttonsCont = document.createElement("div");
  219. buttonsCont.classList.add("bytm-menu-footer-buttons-cont");
  220. buttonsCont.appendChild(exportImportBtn);
  221. footerCont.appendChild(reloadFooterCont);
  222. footerCont.appendChild(buttonsCont);
  223. //#region feature list
  224. const featuresCont = document.createElement("div");
  225. featuresCont.id = "bytm-menu-opts";
  226. const onCfgChange = async (
  227. key: keyof typeof defaultData,
  228. initialVal: string | number | boolean | HotkeyObj,
  229. newVal: string | number | boolean | HotkeyObj,
  230. ) => {
  231. const fmt = (val: unknown) => typeof val === "object" ? JSON.stringify(val) : String(val);
  232. info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
  233. const featConf = JSON.parse(JSON.stringify(getFeatures())) as FeatureConfig;
  234. featConf[key] = newVal as never;
  235. const changedKeys = initConfig ? Object.keys(featConf).filter((k) =>
  236. typeof featConf[k as FeatureKey] !== "object"
  237. && featConf[k as FeatureKey] !== initConfig![k as FeatureKey]
  238. ) : [];
  239. const requiresReload =
  240. // @ts-ignore
  241. changedKeys.some((k) => featInfo[k as keyof typeof featInfo]?.reloadRequired !== false);
  242. await setFeatures(featConf);
  243. // @ts-ignore
  244. featInfo[key]?.change?.(key, initialVal, newVal);
  245. if(requiresReload) {
  246. reloadFooterEl.classList.remove("hidden");
  247. reloadFooterEl.setAttribute("aria-hidden", "false");
  248. }
  249. else if(!requiresReload) {
  250. reloadFooterEl.classList.add("hidden");
  251. reloadFooterEl.setAttribute("aria-hidden", "true");
  252. }
  253. if(initLocale !== featConf.locale) {
  254. await initTranslations(featConf.locale);
  255. setLocale(featConf.locale);
  256. const newText = t("lang_changed_prompt_reload");
  257. const confirmText = newText !== initLangReloadText ? `${newText}\n\n────────────────────────────────\n\n${initLangReloadText}` : newText;
  258. if(confirm(confirmText)) {
  259. closeCfgMenu();
  260. disableBeforeUnload();
  261. location.reload();
  262. }
  263. }
  264. else if(getLocale() !== featConf.locale)
  265. setLocale(featConf.locale);
  266. siteEvents.emit("configOptionChanged", key, initialVal, newVal);
  267. };
  268. /** Call whenever the feature config is changed */
  269. const confChanged = debounce(onCfgChange, 333, "falling");
  270. const featureCfg = getFeatures();
  271. const featureCfgWithCategories = Object.entries(featInfo)
  272. .reduce(
  273. (acc, [key, { category }]) => {
  274. if(!acc[category])
  275. acc[category] = {} as Record<FeatureKey, unknown>;
  276. acc[category][key as FeatureKey] = featureCfg[key as FeatureKey];
  277. return acc;
  278. },
  279. {} as Record<FeatureCategory, Record<FeatureKey, unknown>>,
  280. );
  281. /**
  282. * Formats the value `v` based on the provided `key` using the `featInfo` object.
  283. * If a custom `renderValue` function is defined for the `key`, it will be used to format the value.
  284. * If no custom `renderValue` function is defined, the value will be converted to a string and trimmed.
  285. * If the value is an object, it will be converted to a JSON string representation.
  286. * If an error occurs during formatting (like when passing objects with circular references), the original value will be returned as a string (trimmed).
  287. */
  288. const fmtVal = (v: unknown, key: FeatureKey) => {
  289. try {
  290. // @ts-ignore
  291. const renderValue = typeof featInfo?.[key]?.renderValue === "function" ? featInfo[key].renderValue : undefined;
  292. const retVal = (typeof v === "object" ? JSON.stringify(v) : String(v)).trim();
  293. return renderValue ? renderValue(retVal) : retVal;
  294. }
  295. catch {
  296. // absolute last resort fallback because stringify throws on circular refs
  297. return String(v).trim();
  298. }
  299. };
  300. for(const category in featureCfgWithCategories) {
  301. const featObj = featureCfgWithCategories[category as FeatureCategory];
  302. const catHeaderElem = document.createElement("h3");
  303. catHeaderElem.classList.add("bytm-ftconf-category-header");
  304. catHeaderElem.role = "heading";
  305. catHeaderElem.ariaLevel = "2";
  306. catHeaderElem.tabIndex = 0;
  307. catHeaderElem.textContent = `${t(`feature_category_${category}`)}:`;
  308. featuresCont.appendChild(catHeaderElem);
  309. for(const featKey in featObj) {
  310. const ftInfo = featInfo[featKey as FeatureKey] as FeatureInfo[keyof typeof featureCfg];
  311. if(!ftInfo || ("hidden" in ftInfo && ftInfo.hidden === true))
  312. continue;
  313. if(ftInfo.advanced && !featureCfg.advancedMode)
  314. continue;
  315. const { type, default: ftDefault } = ftInfo;
  316. const step = "step" in ftInfo ? ftInfo.step : undefined;
  317. const val = featureCfg[featKey as FeatureKey];
  318. const initialVal = val ?? ftDefault ?? undefined;
  319. const ftConfElem = document.createElement("div");
  320. ftConfElem.classList.add("bytm-ftitem");
  321. {
  322. const featLeftSideElem = document.createElement("div");
  323. featLeftSideElem.classList.add("bytm-ftitem-leftside");
  324. if(getFeature("advancedMode")) {
  325. const defVal = fmtVal(ftDefault, featKey as FeatureKey);
  326. const extraTxts = [
  327. `default: ${defVal.length === 0 ? "(undefined)" : defVal}`,
  328. ];
  329. "min" in ftInfo && extraTxts.push(`min: ${ftInfo.min}`);
  330. "max" in ftInfo && extraTxts.push(`max: ${ftInfo.max}`);
  331. "step" in ftInfo && extraTxts.push(`step: ${ftInfo.step}`);
  332. const rel = "reloadRequired" in ftInfo && ftInfo.reloadRequired !== false ? " (reload required)" : "";
  333. const adv = ftInfo.advanced ? " (advanced feature)" : "";
  334. featLeftSideElem.title = `${featKey}${rel}${adv}${extraTxts.length > 0 ? `\n${extraTxts.join(" - ")}` : ""}`;
  335. }
  336. const textElem = document.createElement("span");
  337. textElem.tabIndex = 0;
  338. textElem.textContent = t(`feature_desc_${featKey}`);
  339. let adornmentElem: undefined | HTMLElement;
  340. const adornContent = ftInfo.textAdornment?.();
  341. const adornContentAw = adornContent instanceof Promise ? await adornContent : adornContent;
  342. if((typeof adornContent === "string" || adornContent instanceof Promise) && typeof adornContentAw !== "undefined") {
  343. adornmentElem = document.createElement("span");
  344. adornmentElem.id = `bytm-ftitem-${featKey}-adornment`;
  345. adornmentElem.classList.add("bytm-ftitem-adornment");
  346. adornmentElem.innerHTML = adornContentAw;
  347. }
  348. let helpElem: undefined | HTMLDivElement;
  349. // @ts-ignore
  350. const hasHelpTextFunc = typeof featInfo[featKey as keyof typeof featInfo]?.helpText === "function";
  351. // @ts-ignore
  352. const helpTextVal: string | undefined = hasHelpTextFunc && featInfo[featKey as keyof typeof featInfo]!.helpText();
  353. if(hasKey(`feature_helptext_${featKey}`) || (helpTextVal && hasKey(helpTextVal))) {
  354. const helpElemImgHtml = await resourceToHTMLString("icon-help");
  355. if(helpElemImgHtml) {
  356. helpElem = document.createElement("div");
  357. helpElem.classList.add("bytm-ftitem-help-btn", "bytm-generic-btn");
  358. helpElem.ariaLabel = helpElem.title = t("feature_help_button_tooltip");
  359. helpElem.role = "button";
  360. helpElem.tabIndex = 0;
  361. helpElem.innerHTML = helpElemImgHtml;
  362. onInteraction(helpElem, async (e: MouseEvent | KeyboardEvent) => {
  363. e.preventDefault();
  364. e.stopPropagation();
  365. await (await getFeatHelpDialog({ featKey: featKey as FeatureKey })).open();
  366. });
  367. }
  368. else {
  369. error(`Couldn't create help button SVG element for feature '${featKey}'`);
  370. }
  371. }
  372. adornmentElem && featLeftSideElem.appendChild(adornmentElem);
  373. featLeftSideElem.appendChild(textElem);
  374. helpElem && featLeftSideElem.appendChild(helpElem);
  375. ftConfElem.appendChild(featLeftSideElem);
  376. }
  377. {
  378. let inputType: string | undefined = "text";
  379. let inputTag: string | undefined = "input";
  380. switch(type)
  381. {
  382. case "toggle":
  383. inputTag = undefined;
  384. inputType = undefined;
  385. break;
  386. case "slider":
  387. inputType = "range";
  388. break;
  389. case "number":
  390. inputType = "number";
  391. break;
  392. case "text":
  393. inputType = "text";
  394. break;
  395. case "select":
  396. inputTag = "select";
  397. inputType = undefined;
  398. break;
  399. case "hotkey":
  400. inputTag = undefined;
  401. inputType = undefined;
  402. break;
  403. case "button":
  404. inputTag = undefined;
  405. inputType = undefined;
  406. break;
  407. }
  408. const inputElemId = `bytm-ftconf-${featKey}-input`;
  409. const ctrlElem = document.createElement("span");
  410. ctrlElem.classList.add("bytm-ftconf-ctrl");
  411. let advCopyHiddenCont: HTMLElement | undefined;
  412. if((getFeature("advancedMode") || mode === "development") && ftInfo.valueHidden) {
  413. const advCopyHintElem = document.createElement("span");
  414. advCopyHintElem.classList.add("bytm-ftconf-adv-copy-hint");
  415. advCopyHintElem.textContent = t("copied");
  416. advCopyHintElem.role = "status";
  417. advCopyHintElem.style.display = "none";
  418. const advCopyHiddenBtn = document.createElement("button");
  419. advCopyHiddenBtn.classList.add("bytm-ftconf-adv-copy-btn", "bytm-btn");
  420. advCopyHiddenBtn.tabIndex = 0;
  421. advCopyHiddenBtn.textContent = t("copy_hidden");
  422. advCopyHiddenBtn.ariaLabel = advCopyHiddenBtn.title = t("copy_hidden_tooltip");
  423. const copyHiddenInteraction = (e: MouseEvent | KeyboardEvent) => {
  424. e.preventDefault();
  425. e.stopPropagation();
  426. copyToClipboard(getFeatures()[featKey as keyof FeatureConfig] as Stringifiable);
  427. advCopyHintElem.style.display = "inline";
  428. if(typeof hiddenCopiedTxtTimeout === "undefined") {
  429. hiddenCopiedTxtTimeout = setTimeout(() => {
  430. advCopyHintElem.style.display = "none";
  431. hiddenCopiedTxtTimeout = undefined;
  432. }, 3000);
  433. }
  434. };
  435. onInteraction(advCopyHiddenBtn, copyHiddenInteraction);
  436. advCopyHiddenCont = document.createElement("span");
  437. advCopyHiddenCont.appendChild(advCopyHintElem);
  438. advCopyHiddenCont.appendChild(advCopyHiddenBtn);
  439. }
  440. advCopyHiddenCont && ctrlElem.appendChild(advCopyHiddenCont);
  441. if(inputTag) {
  442. // standard input element:
  443. const inputElem = document.createElement(inputTag) as HTMLInputElement;
  444. inputElem.classList.add("bytm-ftconf-input");
  445. inputElem.id = inputElemId;
  446. if(inputType)
  447. inputElem.type = inputType;
  448. if("min" in ftInfo && typeof ftInfo.min !== "undefined")
  449. inputElem.min = String(ftInfo.min);
  450. if("max" in ftInfo && typeof ftInfo.max !== "undefined")
  451. inputElem.max = String(ftInfo.max);
  452. if(typeof initialVal !== "undefined")
  453. inputElem.value = String(initialVal);
  454. if(type === "text" && ftInfo.valueHidden) {
  455. inputElem.type = "password";
  456. inputElem.autocomplete = "off";
  457. }
  458. if(type === "number" || type === "slider" && step)
  459. inputElem.step = String(step);
  460. if(type === "toggle" && typeof initialVal !== "undefined")
  461. inputElem.checked = Boolean(initialVal);
  462. const unitTxt = (
  463. "unit" in ftInfo && typeof ftInfo.unit === "string"
  464. ? ftInfo.unit
  465. : (
  466. "unit" in ftInfo && typeof ftInfo.unit === "function"
  467. ? ftInfo.unit(Number(inputElem.value))
  468. : ""
  469. )
  470. );
  471. let labelElem: HTMLLabelElement | undefined;
  472. let lastDisplayedVal: string | undefined;
  473. if(type === "slider") {
  474. labelElem = document.createElement("label");
  475. labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label");
  476. labelElem.textContent = `${fmtVal(initialVal, featKey as FeatureKey)}${unitTxt}`;
  477. inputElem.addEventListener("input", () => {
  478. if(labelElem && lastDisplayedVal !== inputElem.value) {
  479. labelElem.textContent = `${fmtVal(inputElem.value, featKey as FeatureKey)}${unitTxt}`;
  480. lastDisplayedVal = inputElem.value;
  481. }
  482. });
  483. }
  484. else if(type === "select") {
  485. const ftOpts = typeof ftInfo.options === "function"
  486. ? ftInfo.options()
  487. : ftInfo.options;
  488. for(const { value, label } of ftOpts) {
  489. const optionElem = document.createElement("option");
  490. optionElem.value = String(value);
  491. optionElem.textContent = label;
  492. if(value === initialVal)
  493. optionElem.selected = true;
  494. inputElem.appendChild(optionElem);
  495. }
  496. }
  497. if(type === "text") {
  498. let lastValue: string | undefined = inputElem.value && inputElem.value.length > 0 ? inputElem.value : ftInfo.default;
  499. const textInputUpdate = () => {
  500. let v: string | number = String(inputElem.value).trim();
  501. if(type === "text" && ftInfo.normalize)
  502. v = inputElem.value = ftInfo.normalize(String(v));
  503. if(v === lastValue)
  504. return;
  505. lastValue = v;
  506. if(v === "")
  507. v = ftInfo.default;
  508. if(typeof initialVal !== "undefined")
  509. confChanged(featKey as keyof FeatureConfig, initialVal, v);
  510. };
  511. const unsub = siteEvents.on("cfgMenuClosed", () => {
  512. unsub();
  513. textInputUpdate();
  514. });
  515. inputElem.addEventListener("blur", () => textInputUpdate());
  516. inputElem.addEventListener("keydown", (e) => e.key === "Tab" && textInputUpdate());
  517. }
  518. else {
  519. inputElem.addEventListener("input", () => {
  520. let v: string | number = String(inputElem.value).trim();
  521. if(["number", "slider"].includes(type) || v.match(/^-?\d+$/))
  522. v = Number(v);
  523. if(typeof initialVal !== "undefined")
  524. confChanged(featKey as keyof FeatureConfig, initialVal, (type !== "toggle" ? v : inputElem.checked));
  525. });
  526. }
  527. if(labelElem) {
  528. labelElem.id = `bytm-ftconf-${featKey}-label`;
  529. labelElem.htmlFor = inputElemId;
  530. ctrlElem.appendChild(labelElem);
  531. }
  532. ctrlElem.appendChild(inputElem);
  533. }
  534. else {
  535. // custom input element:
  536. let customInputEl: HTMLElement | undefined;
  537. switch(type) {
  538. case "hotkey":
  539. customInputEl = createHotkeyInput({
  540. initialValue: typeof initialVal === "object" ? initialVal as HotkeyObj : undefined,
  541. onChange: (hotkey) => confChanged(featKey as keyof FeatureConfig, initialVal, hotkey),
  542. });
  543. break;
  544. case "toggle":
  545. customInputEl = await createToggleInput({
  546. initialValue: Boolean(initialVal),
  547. onChange: (checked) => confChanged(featKey as keyof FeatureConfig, initialVal, checked),
  548. id: `ftconf-${featKey}`,
  549. labelPos: "left",
  550. });
  551. break;
  552. case "button":
  553. customInputEl = document.createElement("button");
  554. customInputEl.classList.add("bytm-btn");
  555. customInputEl.tabIndex = 0;
  556. customInputEl.textContent = customInputEl.ariaLabel = customInputEl.title = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
  557. onInteraction(customInputEl, async () => {
  558. if((customInputEl as HTMLButtonElement).disabled)
  559. return;
  560. const startTs = Date.now();
  561. const res = ftInfo.click();
  562. (customInputEl as HTMLButtonElement).disabled = true;
  563. customInputEl!.classList.add("bytm-busy");
  564. customInputEl!.textContent = customInputEl!.ariaLabel = customInputEl!.title = hasKey(`feature_btn_${featKey}_running`) ? t(`feature_btn_${featKey}_running`) : t("trigger_btn_action_running");
  565. if(res instanceof Promise)
  566. await res;
  567. const finalize = () => {
  568. (customInputEl as HTMLButtonElement).disabled = false;
  569. customInputEl!.classList.remove("bytm-busy");
  570. customInputEl!.textContent = customInputEl!.ariaLabel = customInputEl!.title = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
  571. };
  572. // artificial timeout ftw
  573. if(Date.now() - startTs < 350)
  574. setTimeout(finalize, 350 - (Date.now() - startTs));
  575. else
  576. finalize();
  577. });
  578. break;
  579. }
  580. ctrlElem.appendChild(customInputEl!);
  581. }
  582. ftConfElem.appendChild(ctrlElem);
  583. }
  584. featuresCont.appendChild(ftConfElem);
  585. }
  586. }
  587. //#region reset inputs on external change
  588. siteEvents.on("rebuildCfgMenu", (newConfig) => {
  589. for(const ftKey in featInfo) {
  590. const ftElem = document.querySelector<HTMLInputElement>(`#bytm-ftconf-${ftKey}-input`);
  591. const labelElem = document.querySelector<HTMLLabelElement>(`#bytm-ftconf-${ftKey}-label`);
  592. if(!ftElem)
  593. continue;
  594. const ftInfo = featInfo[ftKey as keyof typeof featInfo];
  595. const value = newConfig[ftKey as keyof FeatureConfig];
  596. if(ftInfo.type === "toggle")
  597. ftElem.checked = Boolean(value);
  598. else
  599. ftElem.value = String(value);
  600. if(!labelElem)
  601. continue;
  602. const unitTxt = (
  603. "unit" in ftInfo && typeof ftInfo.unit === "string"
  604. ? ftInfo.unit
  605. : (
  606. "unit" in ftInfo && typeof ftInfo.unit === "function"
  607. ? ftInfo.unit(Number(ftElem.value))
  608. : ""
  609. )
  610. );
  611. if(ftInfo.type === "slider")
  612. labelElem.textContent = `${fmtVal(Number(value), ftKey as FeatureKey)}${unitTxt}`;
  613. }
  614. info("Rebuilt config menu");
  615. });
  616. //#region scroll indicator
  617. const scrollIndicator = document.createElement("img");
  618. scrollIndicator.id = "bytm-menu-scroll-indicator";
  619. scrollIndicator.src = await getResourceUrl("icon-arrow_down");
  620. scrollIndicator.role = "button";
  621. scrollIndicator.ariaLabel = scrollIndicator.title = t("scroll_to_bottom");
  622. featuresCont.appendChild(scrollIndicator);
  623. scrollIndicator.addEventListener("click", () => {
  624. const bottomAnchor = document.querySelector("#bytm-menu-bottom-anchor");
  625. bottomAnchor?.scrollIntoView({
  626. behavior: "smooth",
  627. });
  628. });
  629. featuresCont.addEventListener("scroll", (evt: Event) => {
  630. const scrollPos = (evt.target as HTMLDivElement)?.scrollTop ?? 0;
  631. const scrollIndicator = document.querySelector<HTMLImageElement>("#bytm-menu-scroll-indicator");
  632. if(!scrollIndicator)
  633. return;
  634. if(scrollIndicatorEnabled && scrollPos > scrollIndicatorOffsetThreshold && !scrollIndicator.classList.contains("bytm-hidden")) {
  635. scrollIndicator.classList.add("bytm-hidden");
  636. }
  637. else if(scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) {
  638. scrollIndicator.classList.remove("bytm-hidden");
  639. }
  640. });
  641. const bottomAnchor = document.createElement("div");
  642. bottomAnchor.id = "bytm-menu-bottom-anchor";
  643. featuresCont.appendChild(bottomAnchor);
  644. //#region finalize
  645. menuContainer.appendChild(headerElem);
  646. menuContainer.appendChild(featuresCont);
  647. const subtitleElemCont = document.createElement("div");
  648. subtitleElemCont.id = "bytm-menu-subtitle-cont";
  649. const versionEl = document.createElement("a");
  650. versionEl.id = "bytm-menu-version-anchor";
  651. versionEl.classList.add("bytm-link");
  652. versionEl.role = "button";
  653. versionEl.tabIndex = 0;
  654. versionEl.ariaLabel = versionEl.title = t("version_tooltip", scriptInfo.version, buildNumber);
  655. versionEl.textContent = `v${scriptInfo.version} (#${buildNumber})`;
  656. onInteraction(versionEl, async (e: MouseEvent | KeyboardEvent) => {
  657. e.preventDefault();
  658. e.stopPropagation();
  659. const dlg = await getChangelogDialog();
  660. dlg.on("close", openCfgMenu);
  661. await dlg.mount();
  662. closeCfgMenu(undefined, false);
  663. await dlg.open();
  664. });
  665. subtitleElemCont.appendChild(versionEl);
  666. titleElem.appendChild(subtitleElemCont);
  667. const modeItems = [] as TrKey[];
  668. mode === "development" && modeItems.push("dev_mode");
  669. getFeature("advancedMode") && modeItems.push("advanced_mode");
  670. if(modeItems.length > 0) {
  671. const modeDisplayEl = document.createElement("span");
  672. modeDisplayEl.id = "bytm-menu-mode-display";
  673. modeDisplayEl.textContent = `[${t("active_mode_display", arrayWithSeparators(modeItems.map(v => t(`${v}_short`)), ", ", " & "))}]`;
  674. modeDisplayEl.ariaLabel = modeDisplayEl.title = tp("active_mode_tooltip", modeItems, arrayWithSeparators(modeItems.map(t), ", ", " & "));
  675. subtitleElemCont.appendChild(modeDisplayEl);
  676. }
  677. menuContainer.appendChild(footerCont);
  678. backgroundElem.appendChild(menuContainer);
  679. document.body.appendChild(backgroundElem);
  680. window.addEventListener("resize", debounce(checkToggleScrollIndicator, 250, "rising"));
  681. log("Added menu element");
  682. // ensure stuff is reset if menu was opened before being added
  683. isCfgMenuOpen = false;
  684. document.body.classList.remove("bytm-disable-scroll");
  685. document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")?.removeAttribute("inert");
  686. backgroundElem.style.visibility = "hidden";
  687. backgroundElem.style.display = "none";
  688. siteEvents.on("recreateCfgMenu", async () => {
  689. const bgElem = document.querySelector("#bytm-cfg-menu-bg");
  690. if(!bgElem)
  691. return;
  692. closeCfgMenu();
  693. bgElem.remove();
  694. isCfgMenuMounted = false;
  695. await mountCfgMenu();
  696. await openCfgMenu();
  697. });
  698. }
  699. //#region open & close
  700. /** Closes the config menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  701. export function closeCfgMenu(evt?: MouseEvent | KeyboardEvent, enableScroll = true) {
  702. if(!isCfgMenuOpen)
  703. return;
  704. isCfgMenuOpen = false;
  705. evt?.bubbles && evt.stopPropagation();
  706. if(enableScroll) {
  707. document.body.classList.remove("bytm-disable-scroll");
  708. document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")?.removeAttribute("inert");
  709. }
  710. const menuBg = document.querySelector<HTMLElement>("#bytm-cfg-menu-bg");
  711. clearTimeout(hiddenCopiedTxtTimeout);
  712. openDialogs.splice(openDialogs.indexOf("cfg-menu"), 1);
  713. setCurrentDialogId(openDialogs?.[0] ?? null);
  714. // since this menu doesn't have a BytmDialog instance, it's undefined here
  715. emitInterface("bytm:dialogClosed", undefined as unknown as BytmDialog);
  716. emitInterface("bytm:dialogClosed:cfg-menu" as "bytm:dialogClosed:id", undefined as unknown as BytmDialog);
  717. if(!menuBg)
  718. return warn("Couldn't close config menu because background element couldn't be found. The config menu is considered closed but might still be open. In this case please reload the page. If the issue persists, please create an issue on GitHub.");
  719. menuBg.querySelectorAll<HTMLElement>(".bytm-ftconf-adv-copy-hint")?.forEach((el) => el.style.display = "none");
  720. menuBg.style.visibility = "hidden";
  721. menuBg.style.display = "none";
  722. }
  723. /** Opens the config menu if it is closed */
  724. export async function openCfgMenu() {
  725. if(!isCfgMenuMounted)
  726. await mountCfgMenu();
  727. if(isCfgMenuOpen)
  728. return;
  729. isCfgMenuOpen = true;
  730. document.body.classList.add("bytm-disable-scroll");
  731. document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")?.setAttribute("inert", "true");
  732. const menuBg = document.querySelector<HTMLElement>("#bytm-cfg-menu-bg");
  733. setCurrentDialogId("cfg-menu");
  734. openDialogs.unshift("cfg-menu");
  735. // since this menu doesn't have a BytmDialog instance, it's undefined here
  736. emitInterface("bytm:dialogOpened", undefined as unknown as BytmDialog);
  737. emitInterface("bytm:dialogOpened:cfg-menu" as "bytm:dialogOpened:id", undefined as unknown as BytmDialog);
  738. checkToggleScrollIndicator();
  739. if(!menuBg)
  740. return warn("Couldn't open config menu because background element couldn't be found. The config menu is considered open but might still be closed. In this case please reload the page. If the issue persists, please create an issue on GitHub.");
  741. menuBg.style.visibility = "visible";
  742. menuBg.style.display = "block";
  743. }
  744. //#region chk scroll indicator
  745. /** Checks if the features container is scrollable and toggles the scroll indicator accordingly */
  746. function checkToggleScrollIndicator() {
  747. const featuresCont = document.querySelector<HTMLElement>("#bytm-menu-opts");
  748. const scrollIndicator = document.querySelector<HTMLElement>("#bytm-menu-scroll-indicator");
  749. // disable scroll indicator if container doesn't scroll
  750. if(featuresCont && scrollIndicator) {
  751. const verticalScroll = isScrollable(featuresCont).vertical;
  752. /** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */
  753. const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold;
  754. if(!underThreshold && verticalScroll && !scrollIndicatorEnabled) {
  755. scrollIndicatorEnabled = true;
  756. scrollIndicator.classList.remove("bytm-hidden");
  757. }
  758. if((!verticalScroll && scrollIndicatorEnabled) || underThreshold) {
  759. scrollIndicatorEnabled = false;
  760. scrollIndicator.classList.add("bytm-hidden");
  761. }
  762. }
  763. }