menu_old.ts 32 KB

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