menu_old.ts 30 KB

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