menu_old.ts 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337
  1. import { compress, decompress, debounce, isScrollable } from "@sv443-network/userutils";
  2. import { defaultConfig, getFeatures, migrations, saveFeatures, setDefaultFeatures } from "../config";
  3. import { buildNumber, compressionFormat, host, mode, scriptInfo } from "../constants";
  4. import { featInfo, disableBeforeUnload } from "../features/index";
  5. import { error, getResourceUrl, info, log, resourceToHTMLString, warn, getLocale, hasKey, initTranslations, setLocale, t, parseMarkdown, getChangelogMd, compressionSupported } from "../utils";
  6. import { formatVersion } from "../config";
  7. import { emitSiteEvent, siteEvents } from "../siteEvents";
  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. /**
  23. * Adds an element to open the BetterYTM menu
  24. * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23
  25. */
  26. async function addCfgMenu() {
  27. if(isCfgMenuAdded)
  28. return;
  29. isCfgMenuAdded = true;
  30. initLocale = getFeatures().locale;
  31. initConfig = JSON.stringify(getFeatures());
  32. const initLangReloadText = t("lang_changed_prompt_reload");
  33. //#SECTION backdrop & menu container
  34. const backgroundElem = document.createElement("div");
  35. backgroundElem.id = "bytm-cfg-menu-bg";
  36. backgroundElem.classList.add("bytm-menu-bg");
  37. backgroundElem.ariaLabel = backgroundElem.title = t("close_menu_tooltip");
  38. backgroundElem.style.visibility = "hidden";
  39. backgroundElem.style.display = "none";
  40. backgroundElem.addEventListener("click", (e) => {
  41. if(isCfgMenuOpen && (e.target as HTMLElement)?.id === "bytm-cfg-menu-bg")
  42. closeCfgMenu(e);
  43. });
  44. document.body.addEventListener("keydown", (e) => {
  45. if(isCfgMenuOpen && e.key === "Escape")
  46. closeCfgMenu(e);
  47. });
  48. const menuContainer = document.createElement("div");
  49. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  50. menuContainer.classList.add("bytm-menu");
  51. menuContainer.id = "bytm-cfg-menu";
  52. //#SECTION title bar
  53. const headerElem = document.createElement("div");
  54. headerElem.classList.add("bytm-menu-header");
  55. const titleCont = document.createElement("div");
  56. titleCont.className = "bytm-menu-titlecont";
  57. titleCont.role = "heading";
  58. titleCont.ariaLevel = "1";
  59. const titleElem = document.createElement("h2");
  60. titleElem.className = "bytm-menu-title";
  61. const titleTextElem = document.createElement("div");
  62. titleTextElem.textContent = t("config_menu_title", scriptInfo.name);
  63. titleElem.appendChild(titleTextElem);
  64. const linksCont = document.createElement("div");
  65. linksCont.id = "bytm-menu-linkscont";
  66. linksCont.role = "navigation";
  67. const addLink = (imgSrc: string, href: string, title: string) => {
  68. const anchorElem = document.createElement("a");
  69. anchorElem.className = "bytm-menu-link bytm-no-select";
  70. anchorElem.rel = "noopener noreferrer";
  71. anchorElem.href = href;
  72. anchorElem.target = "_blank";
  73. anchorElem.tabIndex = 0;
  74. anchorElem.role = "button";
  75. anchorElem.ariaLabel = anchorElem.title = title;
  76. const imgElem = document.createElement("img");
  77. imgElem.className = "bytm-menu-img";
  78. imgElem.src = imgSrc;
  79. imgElem.style.width = "32px";
  80. imgElem.style.height = "32px";
  81. anchorElem.appendChild(imgElem);
  82. linksCont.appendChild(anchorElem);
  83. };
  84. addLink(await getResourceUrl("img-discord"), "https://dc.sv443.net/", t("open_discord"));
  85. const links: [name: string, ...Parameters<typeof addLink>][] = [
  86. ["github", await getResourceUrl("img-github"), scriptInfo.namespace, t("open_github", scriptInfo.name)],
  87. ["greasyfork", await getResourceUrl("img-greasyfork"), pkg.hosts.greasyfork, t("open_greasyfork", scriptInfo.name)],
  88. ["openuserjs", await getResourceUrl("img-openuserjs"), pkg.hosts.openuserjs, t("open_openuserjs", scriptInfo.name)],
  89. ];
  90. const hostLink = links.find(([name]) => name === host);
  91. const otherLinks = links.filter(([name]) => name !== host);
  92. const reorderedLinks = hostLink ? [hostLink, ...otherLinks] : links;
  93. for(const [, ...args] of reorderedLinks)
  94. addLink(...args);
  95. const closeElem = document.createElement("img");
  96. closeElem.classList.add("bytm-menu-close");
  97. closeElem.role = "button";
  98. closeElem.tabIndex = 0;
  99. closeElem.src = await getResourceUrl("img-close");
  100. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  101. closeElem.addEventListener("click", closeCfgMenu);
  102. closeElem.addEventListener("keydown", ({ key }) => key === "Enter" && closeCfgMenu());
  103. titleCont.appendChild(titleElem);
  104. titleCont.appendChild(linksCont);
  105. headerElem.appendChild(titleCont);
  106. headerElem.appendChild(closeElem);
  107. //#SECTION footer
  108. const footerCont = document.createElement("div");
  109. footerCont.className = "bytm-menu-footer-cont";
  110. const footerElemCont = document.createElement("div");
  111. const footerElem = document.createElement("div");
  112. footerElem.classList.add("bytm-menu-footer", "hidden");
  113. footerElem.textContent = t("reload_hint");
  114. const reloadElem = document.createElement("button");
  115. reloadElem.classList.add("bytm-btn");
  116. reloadElem.style.marginLeft = "10px";
  117. reloadElem.textContent = t("reload_now");
  118. reloadElem.ariaLabel = reloadElem.title = t("reload_tooltip");
  119. reloadElem.addEventListener("click", () => {
  120. closeCfgMenu();
  121. disableBeforeUnload();
  122. location.reload();
  123. });
  124. footerElem.appendChild(reloadElem);
  125. footerElemCont.appendChild(footerElem);
  126. const resetElem = document.createElement("button");
  127. resetElem.classList.add("bytm-btn");
  128. resetElem.ariaLabel = resetElem.title = t("reset_tooltip");
  129. resetElem.textContent = t("reset");
  130. resetElem.addEventListener("click", async () => {
  131. if(confirm(t("reset_confirm"))) {
  132. await setDefaultFeatures();
  133. closeCfgMenu();
  134. disableBeforeUnload();
  135. location.reload();
  136. }
  137. });
  138. const exportElem = document.createElement("button");
  139. exportElem.classList.add("bytm-btn");
  140. exportElem.ariaLabel = exportElem.title = t("export_tooltip");
  141. exportElem.textContent = t("export");
  142. exportElem.addEventListener("click", async () => {
  143. await openExportMenu();
  144. closeCfgMenu(undefined, false);
  145. });
  146. const importElem = document.createElement("button");
  147. importElem.classList.add("bytm-btn");
  148. importElem.ariaLabel = importElem.title = t("import_tooltip");
  149. importElem.textContent = t("import");
  150. importElem.addEventListener("click", async () => {
  151. await openImportMenu();
  152. closeCfgMenu(undefined, false);
  153. });
  154. const buttonsCont = document.createElement("div");
  155. buttonsCont.id = "bytm-menu-footer-buttons-cont";
  156. buttonsCont.appendChild(exportElem);
  157. buttonsCont.appendChild(importElem);
  158. buttonsCont.appendChild(resetElem);
  159. footerCont.appendChild(footerElemCont);
  160. footerCont.appendChild(buttonsCont);
  161. //#SECTION feature list
  162. const featuresCont = document.createElement("div");
  163. featuresCont.id = "bytm-menu-opts";
  164. /** Gets called whenever the feature config is changed */
  165. const confChanged = debounce(async (key: keyof typeof defaultConfig, initialVal: number | boolean | Record<string, unknown>, newVal: number | boolean | Record<string, unknown>) => {
  166. const fmt = (val: unknown) => typeof val === "object" ? JSON.stringify(val) : String(val);
  167. info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`);
  168. const featConf = JSON.parse(JSON.stringify(getFeatures()));
  169. featConf[key] = newVal as never;
  170. await saveFeatures(featConf);
  171. if(initConfig !== JSON.stringify(featConf))
  172. footerElem.classList.remove("hidden");
  173. else
  174. footerElem.classList.add("hidden");
  175. if(initLocale !== featConf.locale) {
  176. await initTranslations(featConf.locale);
  177. setLocale(featConf.locale);
  178. const newText = t("lang_changed_prompt_reload");
  179. const confirmText = newText !== initLangReloadText ? `${newText}\n\n────────────────────────────────\n\n${initLangReloadText}` : newText;
  180. if(confirm(confirmText)) {
  181. closeCfgMenu();
  182. disableBeforeUnload();
  183. location.reload();
  184. }
  185. }
  186. else if(getLocale() !== featConf.locale)
  187. setLocale(featConf.locale);
  188. });
  189. const featureCfg = getFeatures();
  190. const featureCfgWithCategories = Object.entries(featInfo)
  191. .reduce(
  192. (acc, [key, { category }]) => {
  193. if(!acc[category])
  194. acc[category] = {} as Record<FeatureKey, unknown>;
  195. acc[category][key as FeatureKey] = featureCfg[key as FeatureKey];
  196. return acc;
  197. },
  198. {} as Record<FeatureCategory, Record<FeatureKey, unknown>>,
  199. );
  200. const fmtVal = (v: unknown) => typeof v === "object" ? JSON.stringify(v) : String(v).trim();
  201. for(const category in featureCfgWithCategories) {
  202. const featObj = featureCfgWithCategories[category as FeatureCategory];
  203. const catHeaderElem = document.createElement("h3");
  204. catHeaderElem.classList.add("bytm-ftconf-category-header");
  205. catHeaderElem.role = "heading";
  206. catHeaderElem.ariaLevel = "2";
  207. catHeaderElem.textContent = `${t(`feature_category_${category}`)}:`;
  208. featuresCont.appendChild(catHeaderElem);
  209. for(const featKey in featObj) {
  210. const ftInfo = featInfo[featKey as keyof typeof featureCfg] as FeatureInfo[keyof typeof featureCfg];
  211. // @ts-ignore
  212. if(!ftInfo || ftInfo.hidden === true)
  213. continue;
  214. if(ftInfo.advanced && !featureCfg.advancedMode)
  215. continue;
  216. const { type, default: ftDefault } = ftInfo;
  217. // @ts-ignore
  218. const step = ftInfo?.step ?? undefined;
  219. const val = featureCfg[featKey as keyof typeof featureCfg];
  220. const initialVal = val ?? ftDefault ?? undefined;
  221. const ftConfElem = document.createElement("div");
  222. ftConfElem.classList.add("bytm-ftitem");
  223. {
  224. const featLeftSideElem = document.createElement("div");
  225. featLeftSideElem.classList.add("bytm-ftitem-leftside");
  226. if(getFeatures().advancedMode)
  227. featLeftSideElem.title = `${featKey}${ftInfo.advanced ? " (advanced)" : ""} - Default: ${fmtVal(ftDefault)}`;
  228. const textElem = document.createElement("span");
  229. textElem.textContent = t(`feature_desc_${featKey}`);
  230. let adornmentElem: undefined | HTMLElement;
  231. const adornContent = ftInfo.textAdornment?.();
  232. const adornContentAw = adornContent instanceof Promise ? await adornContent : adornContent;
  233. if((typeof adornContent === "string" || adornContent instanceof Promise) && typeof adornContentAw !== "undefined") {
  234. adornmentElem = document.createElement("span");
  235. adornmentElem.id = `bytm-ftitem-${featKey}-adornment`;
  236. adornmentElem.classList.add("bytm-ftitem-adornment");
  237. adornmentElem.innerHTML = adornContentAw;
  238. }
  239. let helpElem: undefined | HTMLDivElement;
  240. // @ts-ignore
  241. const hasHelpTextFunc = typeof featInfo[featKey as keyof typeof featInfo]?.helpText === "function";
  242. // @ts-ignore
  243. const helpTextVal: string | undefined = hasHelpTextFunc && featInfo[featKey as keyof typeof featInfo]!.helpText();
  244. if(hasKey(`feature_helptext_${featKey}`) || (helpTextVal && hasKey(helpTextVal))) {
  245. const helpElemImgHtml = await resourceToHTMLString("img-help");
  246. if(helpElemImgHtml) {
  247. helpElem = document.createElement("div");
  248. helpElem.classList.add("bytm-ftitem-help-btn", "bytm-generic-btn");
  249. helpElem.ariaLabel = helpElem.title = t("feature_help_button_tooltip");
  250. helpElem.role = "button";
  251. helpElem.tabIndex = 0;
  252. helpElem.innerHTML = helpElemImgHtml;
  253. const helpElemClicked = (e: MouseEvent | KeyboardEvent) => {
  254. e.preventDefault();
  255. e.stopPropagation();
  256. openHelpDialog(featKey as FeatureKey);
  257. };
  258. helpElem.addEventListener("click", helpElemClicked);
  259. helpElem.addEventListener("keydown", (e) => e.key === "Enter" && helpElemClicked(e));
  260. }
  261. else {
  262. error(`Couldn't create help button SVG element for feature '${featKey}'`);
  263. }
  264. }
  265. adornmentElem && featLeftSideElem.appendChild(adornmentElem);
  266. featLeftSideElem.appendChild(textElem);
  267. helpElem && featLeftSideElem.appendChild(helpElem);
  268. ftConfElem.appendChild(featLeftSideElem);
  269. }
  270. {
  271. let inputType: string | undefined = "text";
  272. let inputTag: string | undefined = "input";
  273. switch(type)
  274. {
  275. case "toggle":
  276. inputTag = undefined;
  277. inputType = undefined;
  278. break;
  279. case "slider":
  280. inputType = "range";
  281. break;
  282. case "number":
  283. inputType = "number";
  284. break;
  285. case "text":
  286. inputType = "text";
  287. break;
  288. case "select":
  289. inputTag = "select";
  290. inputType = undefined;
  291. break;
  292. case "hotkey":
  293. inputTag = undefined;
  294. inputType = undefined;
  295. break;
  296. case "button":
  297. inputTag = undefined;
  298. inputType = undefined;
  299. break;
  300. }
  301. const inputElemId = `bytm-ftconf-${featKey}-input`;
  302. const ctrlElem = document.createElement("span");
  303. ctrlElem.classList.add("bytm-ftconf-ctrl");
  304. if(inputTag) {
  305. // standard input element:
  306. const inputElem = document.createElement(inputTag) as HTMLInputElement;
  307. inputElem.classList.add("bytm-ftconf-input");
  308. inputElem.id = inputElemId;
  309. if(inputType)
  310. inputElem.type = inputType;
  311. // @ts-ignore
  312. if(typeof ftInfo.min !== "undefined")// @ts-ignore
  313. inputElem.min = ftInfo.min;
  314. // @ts-ignore
  315. if(ftInfo.max !== "undefined") // @ts-ignore
  316. inputElem.max = ftInfo.max;
  317. if(typeof initialVal !== "undefined")
  318. inputElem.value = String(initialVal);
  319. if(type === "number" || type === "slider" && step)
  320. inputElem.step = String(step);
  321. if(type === "toggle" && typeof initialVal !== "undefined")
  322. inputElem.checked = Boolean(initialVal);
  323. // @ts-ignore
  324. const unitTxt = (typeof ftInfo.unit === "string" ? ftInfo.unit : (
  325. // @ts-ignore
  326. typeof ftInfo.unit === "function" ? ftInfo.unit(Number(inputElem.value)) : ""
  327. ));
  328. let labelElem: HTMLLabelElement | undefined;
  329. let lastDisplayedVal: string | undefined;
  330. if(type === "slider") {
  331. labelElem = document.createElement("label");
  332. labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label");
  333. labelElem.textContent = `${fmtVal(initialVal)} ${unitTxt}`;
  334. inputElem.addEventListener("input", () => {
  335. if(labelElem && lastDisplayedVal !== inputElem.value) {
  336. labelElem.textContent = `${fmtVal(inputElem.value)} ${unitTxt}`;
  337. lastDisplayedVal = inputElem.value;
  338. }
  339. });
  340. }
  341. else if(type === "select") {
  342. const ftOpts = typeof ftInfo.options === "function"
  343. ? ftInfo.options()
  344. : ftInfo.options;
  345. for(const { value, label } of ftOpts) {
  346. const optionElem = document.createElement("option");
  347. optionElem.value = String(value);
  348. optionElem.textContent = label;
  349. if(value === initialVal)
  350. optionElem.selected = true;
  351. inputElem.appendChild(optionElem);
  352. }
  353. }
  354. if(type === "text") {
  355. let lastValue: string | undefined = inputElem.value && inputElem.value.length > 0 ? inputElem.value : ftInfo.default;
  356. const textInputUpdate = () => {
  357. let v: string | number = String(inputElem.value).trim();
  358. if(type === "text" && ftInfo.normalize)
  359. v = inputElem.value = ftInfo.normalize(String(v));
  360. if(v === lastValue)
  361. return;
  362. lastValue = v;
  363. if(v === "")
  364. v = ftInfo.default;
  365. if(typeof initialVal !== "undefined")
  366. confChanged(featKey as keyof FeatureConfig, initialVal, v);
  367. };
  368. inputElem.addEventListener("blur", () => textInputUpdate());
  369. inputElem.addEventListener("keydown", (e) => e.key === "Tab" && textInputUpdate());
  370. }
  371. else {
  372. inputElem.addEventListener("input", () => {
  373. let v: string | number = String(inputElem.value).trim();
  374. if(["number", "slider"].includes(type) || v.match(/^-?\d+$/))
  375. v = Number(v);
  376. if(typeof initialVal !== "undefined")
  377. confChanged(featKey as keyof FeatureConfig, initialVal, (type !== "toggle" ? v : inputElem.checked));
  378. });
  379. }
  380. if(labelElem) {
  381. labelElem.id = `bytm-ftconf-${featKey}-label`;
  382. labelElem.htmlFor = inputElemId;
  383. ctrlElem.appendChild(labelElem);
  384. }
  385. ctrlElem.appendChild(inputElem);
  386. }
  387. else {
  388. // custom input element:
  389. let wrapperElem: HTMLElement | undefined;
  390. switch(type) {
  391. case "hotkey":
  392. wrapperElem = createHotkeyInput({
  393. initialValue: typeof initialVal === "object" ? initialVal as HotkeyObj : undefined,
  394. onChange: (hotkey) => confChanged(featKey as keyof FeatureConfig, initialVal, hotkey),
  395. });
  396. break;
  397. case "toggle":
  398. wrapperElem = await createToggleInput({
  399. initialValue: Boolean(initialVal),
  400. onChange: (checked) => confChanged(featKey as keyof FeatureConfig, initialVal, checked),
  401. id: `ftconf-${featKey}`,
  402. labelPos: "left",
  403. });
  404. break;
  405. case "button":
  406. wrapperElem = document.createElement("button");
  407. wrapperElem.tabIndex = 0;
  408. wrapperElem.textContent = wrapperElem.ariaLabel = wrapperElem.title = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action");
  409. wrapperElem.addEventListener("click", () => ftInfo.click());
  410. break;
  411. }
  412. ctrlElem.appendChild(wrapperElem!);
  413. }
  414. ftConfElem.appendChild(ctrlElem);
  415. }
  416. featuresCont.appendChild(ftConfElem);
  417. }
  418. }
  419. //#SECTION set values of inputs on external change
  420. siteEvents.on("rebuildCfgMenu", (newConfig) => {
  421. for(const ftKey in featInfo) {
  422. const ftElem = document.querySelector<HTMLInputElement>(`#bytm-ftconf-${ftKey}-input`);
  423. const labelElem = document.querySelector<HTMLLabelElement>(`#bytm-ftconf-${ftKey}-label`);
  424. if(!ftElem)
  425. continue;
  426. const ftInfo = featInfo[ftKey as keyof typeof featInfo];
  427. const value = newConfig[ftKey as keyof FeatureConfig];
  428. if(ftInfo.type === "toggle")
  429. ftElem.checked = Boolean(value);
  430. else
  431. ftElem.value = String(value);
  432. if(!labelElem)
  433. continue;
  434. // @ts-ignore
  435. const unitTxt = " " + (typeof ftInfo.unit === "string" ? ftInfo.unit : (
  436. // @ts-ignore
  437. typeof ftInfo.unit === "function" ? ftInfo.unit(Number(ftElem.value)) : ""
  438. ));
  439. if(ftInfo.type === "slider")
  440. labelElem.textContent = `${fmtVal(Number(value))} ${unitTxt}`;
  441. }
  442. info("Rebuilt config menu");
  443. });
  444. //#SECTION scroll indicator
  445. const scrollIndicator = document.createElement("img");
  446. scrollIndicator.id = "bytm-menu-scroll-indicator";
  447. scrollIndicator.src = await getResourceUrl("img-arrow_down");
  448. scrollIndicator.role = "button";
  449. scrollIndicator.ariaLabel = scrollIndicator.title = t("scroll_to_bottom");
  450. featuresCont.appendChild(scrollIndicator);
  451. scrollIndicator.addEventListener("click", () => {
  452. const bottomAnchor = document.querySelector("#bytm-menu-bottom-anchor");
  453. bottomAnchor?.scrollIntoView({
  454. behavior: "smooth",
  455. });
  456. });
  457. featuresCont.addEventListener("scroll", (evt: Event) => {
  458. const scrollPos = (evt.target as HTMLDivElement)?.scrollTop ?? 0;
  459. const scrollIndicator = document.querySelector<HTMLImageElement>("#bytm-menu-scroll-indicator");
  460. if(!scrollIndicator)
  461. return;
  462. if(scrollIndicatorEnabled && scrollPos > scrollIndicatorOffsetThreshold && !scrollIndicator.classList.contains("bytm-hidden")) {
  463. scrollIndicator.classList.add("bytm-hidden");
  464. }
  465. else if(scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) {
  466. scrollIndicator.classList.remove("bytm-hidden");
  467. }
  468. });
  469. const bottomAnchor = document.createElement("div");
  470. bottomAnchor.id = "bytm-menu-bottom-anchor";
  471. featuresCont.appendChild(bottomAnchor);
  472. //#SECTION finalize
  473. menuContainer.appendChild(headerElem);
  474. menuContainer.appendChild(featuresCont);
  475. const versionElemCont = document.createElement("div");
  476. versionElemCont.id = "bytm-menu-version";
  477. const versionElem = document.createElement("a");
  478. versionElem.classList.add("bytm-link");
  479. versionElem.role = "button";
  480. versionElem.tabIndex = 0;
  481. versionElem.ariaLabel = versionElem.title = t("version_tooltip", scriptInfo.version, buildNumber);
  482. versionElem.textContent = `v${scriptInfo.version} (${buildNumber})${mode === "development" ? " [dev build]" : ""}`;
  483. const versionElemClicked = async (e: MouseEvent | KeyboardEvent) => {
  484. e.preventDefault();
  485. e.stopPropagation();
  486. await openChangelogMenu("cfgMenu");
  487. closeCfgMenu(undefined, false);
  488. };
  489. versionElem.addEventListener("click", versionElemClicked);
  490. versionElem.addEventListener("keydown", (e) => e.key === "Enter" && versionElemClicked(e));
  491. menuContainer.appendChild(footerCont);
  492. versionElemCont.appendChild(versionElem);
  493. titleElem.appendChild(versionElemCont);
  494. backgroundElem.appendChild(menuContainer);
  495. document.body.appendChild(backgroundElem);
  496. window.addEventListener("resize", debounce(checkToggleScrollIndicator, 150));
  497. log("Added menu element");
  498. // ensure stuff is reset if menu was opened before being added
  499. isCfgMenuOpen = false;
  500. document.body.classList.remove("bytm-disable-scroll");
  501. document.querySelector("ytmusic-app")?.removeAttribute("inert");
  502. backgroundElem.style.visibility = "hidden";
  503. backgroundElem.style.display = "none";
  504. }
  505. /** Closes the config menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  506. export function closeCfgMenu(evt?: MouseEvent | KeyboardEvent, enableScroll = true) {
  507. if(!isCfgMenuOpen)
  508. return;
  509. isCfgMenuOpen = false;
  510. evt?.bubbles && evt.stopPropagation();
  511. if(enableScroll) {
  512. document.body.classList.remove("bytm-disable-scroll");
  513. document.querySelector("ytmusic-app")?.removeAttribute("inert");
  514. }
  515. const menuBg = document.querySelector<HTMLElement>("#bytm-cfg-menu-bg");
  516. siteEvents.emit("cfgMenuClosed");
  517. if(!menuBg)
  518. return;
  519. menuBg.style.visibility = "hidden";
  520. menuBg.style.display = "none";
  521. }
  522. /** Opens the config menu if it is closed */
  523. export async function openCfgMenu() {
  524. if(!isCfgMenuAdded)
  525. await addCfgMenu();
  526. if(isCfgMenuOpen)
  527. return;
  528. isCfgMenuOpen = true;
  529. document.body.classList.add("bytm-disable-scroll");
  530. document.querySelector("ytmusic-app")?.setAttribute("inert", "true");
  531. const menuBg = document.querySelector<HTMLElement>("#bytm-cfg-menu-bg");
  532. if(!menuBg)
  533. return;
  534. menuBg.style.visibility = "visible";
  535. menuBg.style.display = "block";
  536. checkToggleScrollIndicator();
  537. }
  538. /** Checks if the features container is scrollable and toggles the scroll indicator accordingly */
  539. function checkToggleScrollIndicator() {
  540. const featuresCont = document.querySelector<HTMLElement>("#bytm-menu-opts");
  541. const scrollIndicator = document.querySelector<HTMLElement>("#bytm-menu-scroll-indicator");
  542. // disable scroll indicator if container doesn't scroll
  543. if(featuresCont && scrollIndicator) {
  544. const verticalScroll = isScrollable(featuresCont).vertical;
  545. /** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */
  546. const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold;
  547. if(!underThreshold && verticalScroll && !scrollIndicatorEnabled) {
  548. scrollIndicatorEnabled = true;
  549. scrollIndicator.classList.remove("bytm-hidden");
  550. }
  551. if((!verticalScroll && scrollIndicatorEnabled) || underThreshold) {
  552. scrollIndicatorEnabled = false;
  553. scrollIndicator.classList.add("bytm-hidden");
  554. }
  555. }
  556. }
  557. //#MARKER help dialog
  558. let isHelpDialogOpen = false;
  559. /** Key of the feature currently loaded in the help dialog */
  560. let helpDialogCurFeature: FeatureKey | undefined;
  561. /** Opens the feature help dialog for the given feature */
  562. async function openHelpDialog(featureKey: FeatureKey) {
  563. if(isHelpDialogOpen)
  564. return;
  565. isHelpDialogOpen = true;
  566. let menuBgElem: HTMLElement;
  567. if(!helpDialogCurFeature) {
  568. // create menu
  569. const headerElem = document.createElement("div");
  570. headerElem.classList.add("bytm-menu-header", "small");
  571. const titleCont = document.createElement("div");
  572. titleCont.className = "bytm-menu-titlecont-no-title";
  573. titleCont.role = "heading";
  574. titleCont.ariaLevel = "1";
  575. const helpIconSvg = await resourceToHTMLString("img-help");
  576. if(helpIconSvg)
  577. titleCont.innerHTML = helpIconSvg;
  578. const closeElem = document.createElement("img");
  579. closeElem.classList.add("bytm-menu-close", "small");
  580. closeElem.role = "button";
  581. closeElem.tabIndex = 0;
  582. closeElem.src = await getResourceUrl("img-close");
  583. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  584. closeElem.addEventListener("click", (e) => closeHelpDialog(e));
  585. closeElem.addEventListener("keydown", (e) => e.key === "Enter" && closeHelpDialog(e));
  586. headerElem.appendChild(titleCont);
  587. headerElem.appendChild(closeElem);
  588. menuBgElem = document.createElement("div");
  589. menuBgElem.id = "bytm-feat-help-menu-bg";
  590. menuBgElem.classList.add("bytm-menu-bg");
  591. menuBgElem.ariaLabel = menuBgElem.title = t("close_menu_tooltip");
  592. menuBgElem.style.visibility = "hidden";
  593. menuBgElem.style.display = "none";
  594. menuBgElem.addEventListener("click", (e) => {
  595. if(isHelpDialogOpen && (e.target as HTMLElement)?.id === "bytm-feat-help-menu-bg")
  596. closeHelpDialog(e);
  597. });
  598. document.body.addEventListener("keydown", (e) => {
  599. if(isHelpDialogOpen && e.key === "Escape")
  600. closeHelpDialog(e);
  601. });
  602. const menuContainer = document.createElement("div");
  603. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  604. menuContainer.classList.add("bytm-menu");
  605. menuContainer.id = "bytm-feat-help-menu";
  606. const featDescElem = document.createElement("h3");
  607. featDescElem.id = "bytm-feat-help-menu-desc";
  608. const helpTextElem = document.createElement("div");
  609. helpTextElem.id = "bytm-feat-help-menu-text";
  610. menuContainer.appendChild(headerElem);
  611. menuContainer.appendChild(featDescElem);
  612. menuContainer.appendChild(helpTextElem);
  613. menuBgElem.appendChild(menuContainer);
  614. document.body.appendChild(menuBgElem);
  615. }
  616. else
  617. menuBgElem = document.querySelector<HTMLElement>("#bytm-feat-help-menu-bg")!;
  618. if(helpDialogCurFeature !== featureKey) {
  619. // update help text
  620. const featDescElem = menuBgElem.querySelector<HTMLElement>("#bytm-feat-help-menu-desc")!;
  621. const helpTextElem = menuBgElem.querySelector<HTMLElement>("#bytm-feat-help-menu-text")!;
  622. featDescElem.textContent = t(`feature_desc_${featureKey}`);
  623. // @ts-ignore
  624. const helpText: string | undefined = featInfo[featureKey]?.helpText?.();
  625. helpTextElem.textContent = helpText ?? t(`feature_helptext_${featureKey}`);
  626. }
  627. // show menu
  628. const menuBg = document.querySelector<HTMLElement>("#bytm-feat-help-menu-bg");
  629. if(!menuBg)
  630. return warn("Couldn't find feature help dialog background element");
  631. helpDialogCurFeature = featureKey;
  632. menuBg.style.visibility = "visible";
  633. menuBg.style.display = "block";
  634. document.querySelector("#bytm-cfg-menu")?.setAttribute("inert", "true");
  635. }
  636. function closeHelpDialog(evt?: MouseEvent | KeyboardEvent) {
  637. if(!isHelpDialogOpen)
  638. return;
  639. isHelpDialogOpen = false;
  640. evt?.bubbles && evt.stopPropagation();
  641. const menuBg = document.querySelector<HTMLElement>("#bytm-feat-help-menu-bg");
  642. if(!menuBg)
  643. return warn("Couldn't find feature help dialog background element");
  644. menuBg.style.visibility = "hidden";
  645. menuBg.style.display = "none";
  646. document.querySelector("#bytm-cfg-menu")?.removeAttribute("inert");
  647. }
  648. //#MARKER export menu
  649. let isExportMenuAdded = false;
  650. let isExportMenuOpen = false;
  651. let copiedTxtTimeout: number | undefined = undefined;
  652. let lastUncompressedCfgString: string | undefined;
  653. /** Adds a menu to copy the current configuration as compressed (if supported) or uncompressed JSON (hidden by default) */
  654. async function addExportMenu() {
  655. const canCompress = await compressionSupported();
  656. const menuBgElem = document.createElement("div");
  657. menuBgElem.id = "bytm-export-menu-bg";
  658. menuBgElem.classList.add("bytm-menu-bg");
  659. menuBgElem.ariaLabel = menuBgElem.title = t("close_menu_tooltip");
  660. menuBgElem.style.visibility = "hidden";
  661. menuBgElem.style.display = "none";
  662. menuBgElem.addEventListener("click", (e) => {
  663. if(isExportMenuOpen && (e.target as HTMLElement)?.id === "bytm-export-menu-bg") {
  664. closeExportMenu(e);
  665. openCfgMenu();
  666. }
  667. });
  668. document.body.addEventListener("keydown", (e) => {
  669. if(isExportMenuOpen && e.key === "Escape") {
  670. closeExportMenu(e);
  671. openCfgMenu();
  672. }
  673. });
  674. const menuContainer = document.createElement("div");
  675. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  676. menuContainer.classList.add("bytm-menu");
  677. menuContainer.id = "bytm-export-menu";
  678. //#SECTION title bar
  679. const headerElem = document.createElement("div");
  680. headerElem.classList.add("bytm-menu-header");
  681. const titleCont = document.createElement("div");
  682. titleCont.className = "bytm-menu-titlecont";
  683. titleCont.role = "heading";
  684. titleCont.ariaLevel = "1";
  685. const titleElem = document.createElement("h2");
  686. titleElem.className = "bytm-menu-title";
  687. titleElem.textContent = t("export_menu_title", scriptInfo.name);
  688. const closeElem = document.createElement("img");
  689. closeElem.classList.add("bytm-menu-close");
  690. closeElem.role = "button";
  691. closeElem.tabIndex = 0;
  692. closeElem.src = await getResourceUrl("img-close");
  693. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  694. const closeExportMenuClicked = (e: MouseEvent | KeyboardEvent) => {
  695. closeExportMenu(e);
  696. openCfgMenu();
  697. };
  698. closeElem.addEventListener("click", (e) => closeExportMenuClicked(e));
  699. closeElem.addEventListener("keydown", (e) => e.key === "Enter" && closeExportMenuClicked(e));
  700. titleCont.appendChild(titleElem);
  701. headerElem.appendChild(titleCont);
  702. headerElem.appendChild(closeElem);
  703. //#SECTION body
  704. const menuBodyElem = document.createElement("div");
  705. menuBodyElem.classList.add("bytm-menu-body");
  706. const textElem = document.createElement("div");
  707. textElem.id = "bytm-export-menu-text";
  708. textElem.textContent = t("export_hint");
  709. const textAreaElem = document.createElement("textarea");
  710. textAreaElem.id = "bytm-export-menu-textarea";
  711. textAreaElem.readOnly = true;
  712. const cfgString = JSON.stringify({ formatVersion, data: getFeatures() });
  713. lastUncompressedCfgString = JSON.stringify({ formatVersion, data: getFeatures() }, undefined, 2);
  714. textAreaElem.value = canCompress ? await compress(cfgString, compressionFormat, "string") : cfgString;
  715. siteEvents.on("configChanged", async (data) => {
  716. const textAreaElem = document.querySelector<HTMLTextAreaElement>("#bytm-export-menu-textarea");
  717. const cfgString = JSON.stringify({ formatVersion, data });
  718. lastUncompressedCfgString = JSON.stringify({ formatVersion, data }, undefined, 2);
  719. if(textAreaElem)
  720. textAreaElem.value = canCompress ? await compress(cfgString, compressionFormat, "string") : cfgString;
  721. });
  722. //#SECTION footer
  723. const footerElem = document.createElement("div");
  724. footerElem.classList.add("bytm-menu-footer-right");
  725. const copyBtnElem = document.createElement("button");
  726. copyBtnElem.classList.add("bytm-btn");
  727. copyBtnElem.textContent = t("copy_to_clipboard");
  728. copyBtnElem.ariaLabel = copyBtnElem.title = t("copy_config_tooltip");
  729. const copiedTextElem = document.createElement("span");
  730. copiedTextElem.id = "bytm-export-menu-copied-txt";
  731. copiedTextElem.classList.add("bytm-menu-footer-copied");
  732. copiedTextElem.textContent = t("copied_notice");
  733. copiedTextElem.style.display = "none";
  734. const copyBtnClicked = async (evt: MouseEvent | KeyboardEvent) => {
  735. evt?.bubbles && evt.stopPropagation();
  736. const textAreaElem = document.querySelector<HTMLTextAreaElement>("#bytm-export-menu-textarea");
  737. if(textAreaElem) {
  738. GM.setClipboard(String(evt?.shiftKey || evt?.ctrlKey ? lastUncompressedCfgString : textAreaElem.value));
  739. copiedTextElem.style.display = "inline-block";
  740. if(typeof copiedTxtTimeout === "undefined") {
  741. copiedTxtTimeout = setTimeout(() => {
  742. copiedTextElem.style.display = "none";
  743. copiedTxtTimeout = undefined;
  744. }, 3000) as unknown as number;
  745. }
  746. }
  747. };
  748. copyBtnElem.addEventListener("click", copyBtnClicked);
  749. copyBtnElem.addEventListener("keydown", (e) => e.key === "Enter" && copyBtnClicked(e));
  750. // flex-direction is row-reverse
  751. footerElem.appendChild(copyBtnElem);
  752. footerElem.appendChild(copiedTextElem);
  753. //#SECTION finalize
  754. menuBodyElem.appendChild(textElem);
  755. menuBodyElem.appendChild(textAreaElem);
  756. menuBodyElem.appendChild(footerElem);
  757. menuContainer.appendChild(headerElem);
  758. menuContainer.appendChild(menuBodyElem);
  759. menuBgElem.appendChild(menuContainer);
  760. document.body.appendChild(menuBgElem);
  761. }
  762. /** Closes the export menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  763. function closeExportMenu(evt: MouseEvent | KeyboardEvent) {
  764. if(!isExportMenuOpen)
  765. return;
  766. isExportMenuOpen = false;
  767. evt?.bubbles && evt.stopPropagation();
  768. const menuBg = document.querySelector<HTMLElement>("#bytm-export-menu-bg");
  769. if(!menuBg)
  770. return warn("Couldn't find export menu background element");
  771. menuBg.style.visibility = "hidden";
  772. menuBg.style.display = "none";
  773. const copiedTxt = document.querySelector<HTMLElement>("#bytm-export-menu-copied-txt");
  774. if(copiedTxt) {
  775. copiedTxt.style.display = "none";
  776. if(typeof copiedTxtTimeout === "number") {
  777. clearTimeout(copiedTxtTimeout);
  778. copiedTxtTimeout = undefined;
  779. }
  780. }
  781. }
  782. /** Opens the export menu if it is closed */
  783. async function openExportMenu() {
  784. if(!isExportMenuAdded)
  785. await addExportMenu();
  786. isExportMenuAdded = true;
  787. if(isExportMenuOpen)
  788. return;
  789. isExportMenuOpen = true;
  790. document.body.classList.add("bytm-disable-scroll");
  791. document.querySelector("ytmusic-app")?.setAttribute("inert", "true");
  792. const menuBg = document.querySelector<HTMLElement>("#bytm-export-menu-bg");
  793. if(!menuBg)
  794. return warn("Couldn't find export menu background element");
  795. menuBg.style.visibility = "visible";
  796. menuBg.style.display = "block";
  797. }
  798. //#MARKER import menu
  799. let isImportMenuAdded = false;
  800. let isImportMenuOpen = false;
  801. /** Adds a menu to import a configuration from compressed or uncompressed JSON (hidden by default) */
  802. async function addImportMenu() {
  803. const menuBgElem = document.createElement("div");
  804. menuBgElem.id = "bytm-import-menu-bg";
  805. menuBgElem.classList.add("bytm-menu-bg");
  806. menuBgElem.ariaLabel = menuBgElem.title = t("close_menu_tooltip");
  807. menuBgElem.style.visibility = "hidden";
  808. menuBgElem.style.display = "none";
  809. menuBgElem.addEventListener("click", (e) => {
  810. if(isImportMenuOpen && (e.target as HTMLElement)?.id === "bytm-import-menu-bg") {
  811. closeImportMenu(e);
  812. openCfgMenu();
  813. }
  814. });
  815. document.body.addEventListener("keydown", (e) => {
  816. if(isImportMenuOpen && e.key === "Escape") {
  817. closeImportMenu(e);
  818. openCfgMenu();
  819. }
  820. });
  821. const menuContainer = document.createElement("div");
  822. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  823. menuContainer.classList.add("bytm-menu");
  824. menuContainer.id = "bytm-import-menu";
  825. //#SECTION title bar
  826. const headerElem = document.createElement("div");
  827. headerElem.classList.add("bytm-menu-header");
  828. const titleCont = document.createElement("div");
  829. titleCont.className = "bytm-menu-titlecont";
  830. titleCont.role = "heading";
  831. titleCont.ariaLevel = "1";
  832. const titleElem = document.createElement("h2");
  833. titleElem.className = "bytm-menu-title";
  834. titleElem.textContent = t("import_menu_title", scriptInfo.name);
  835. const closeElem = document.createElement("img");
  836. closeElem.classList.add("bytm-menu-close");
  837. closeElem.role = "button";
  838. closeElem.tabIndex = 0;
  839. closeElem.src = await getResourceUrl("img-close");
  840. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  841. const closeImportMenuClicked = (e: MouseEvent | KeyboardEvent) => {
  842. closeImportMenu(e);
  843. openCfgMenu();
  844. };
  845. closeElem.addEventListener("click", closeImportMenuClicked);
  846. closeElem.addEventListener("keydown", (e) => e.key === "Enter" && closeImportMenuClicked(e));
  847. titleCont.appendChild(titleElem);
  848. headerElem.appendChild(titleCont);
  849. headerElem.appendChild(closeElem);
  850. //#SECTION body
  851. const menuBodyElem = document.createElement("div");
  852. menuBodyElem.classList.add("bytm-menu-body");
  853. const textElem = document.createElement("div");
  854. textElem.id = "bytm-import-menu-text";
  855. textElem.textContent = t("import_hint");
  856. const textAreaElem = document.createElement("textarea");
  857. textAreaElem.id = "bytm-import-menu-textarea";
  858. //#SECTION footer
  859. const footerElem = document.createElement("div");
  860. footerElem.classList.add("bytm-menu-footer-right");
  861. const importBtnElem = document.createElement("button");
  862. importBtnElem.classList.add("bytm-btn");
  863. importBtnElem.textContent = t("import");
  864. importBtnElem.ariaLabel = importBtnElem.title = t("start_import_tooltip");
  865. importBtnElem.addEventListener("click", async (evt) => {
  866. evt?.bubbles && evt.stopPropagation();
  867. const textAreaElem = document.querySelector<HTMLTextAreaElement>("#bytm-import-menu-textarea");
  868. if(!textAreaElem)
  869. return warn("Couldn't find import menu textarea element");
  870. try {
  871. /** Tries to parse an uncompressed or compressed input string as a JSON object */
  872. const decode = async (input: string) => {
  873. try {
  874. return JSON.parse(input);
  875. }
  876. catch {
  877. try {
  878. return JSON.parse(await decompress(input, compressionFormat, "string"));
  879. }
  880. catch(err) {
  881. warn("Couldn't import configuration:", err);
  882. return null;
  883. }
  884. }
  885. };
  886. const parsed = await decode(textAreaElem.value.trim());
  887. if(typeof parsed !== "object")
  888. return alert(t("import_error_invalid"));
  889. if(typeof parsed.formatVersion !== "number")
  890. return alert(t("import_error_no_format_version"));
  891. if(typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0)
  892. return alert(t("import_error_no_data"));
  893. if(parsed.formatVersion < formatVersion) {
  894. let newData = JSON.parse(JSON.stringify(parsed.data));
  895. const sortedMigrations = Object.entries(migrations)
  896. .sort(([a], [b]) => Number(a) - Number(b));
  897. let curFmtVer = Number(parsed.formatVersion);
  898. for(const [fmtVer, migrationFunc] of sortedMigrations) {
  899. const ver = Number(fmtVer);
  900. if(curFmtVer < formatVersion && curFmtVer < ver) {
  901. try {
  902. const migRes = JSON.parse(JSON.stringify(migrationFunc(newData)));
  903. newData = migRes instanceof Promise ? await migRes : migRes;
  904. curFmtVer = ver;
  905. }
  906. catch(err) {
  907. console.error(`Error while running migration function for format version ${fmtVer}:`, err);
  908. }
  909. }
  910. }
  911. parsed.formatVersion = curFmtVer;
  912. parsed.data = newData;
  913. }
  914. else if(parsed.formatVersion !== formatVersion)
  915. return alert(t("import_error_wrong_format_version", formatVersion, parsed.formatVersion));
  916. await saveFeatures({ ...getFeatures(), ...parsed.data });
  917. if(confirm(t("import_success_confirm_reload"))) {
  918. disableBeforeUnload();
  919. return location.reload();
  920. }
  921. emitSiteEvent("rebuildCfgMenu", parsed.data);
  922. closeImportMenu();
  923. openCfgMenu();
  924. }
  925. catch(err) {
  926. warn("Couldn't import configuration:", err);
  927. alert(t("import_error_invalid"));
  928. }
  929. });
  930. footerElem.appendChild(importBtnElem);
  931. //#SECTION finalize
  932. menuBodyElem.appendChild(textElem);
  933. menuBodyElem.appendChild(textAreaElem);
  934. menuBodyElem.appendChild(footerElem);
  935. menuContainer.appendChild(headerElem);
  936. menuContainer.appendChild(menuBodyElem);
  937. menuBgElem.appendChild(menuContainer);
  938. document.body.appendChild(menuBgElem);
  939. }
  940. /** Closes the import menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  941. function closeImportMenu(evt?: MouseEvent | KeyboardEvent) {
  942. if(!isImportMenuOpen)
  943. return;
  944. isImportMenuOpen = false;
  945. evt?.bubbles && evt.stopPropagation();
  946. const menuBg = document.querySelector<HTMLElement>("#bytm-import-menu-bg");
  947. const textAreaElem = document.querySelector<HTMLTextAreaElement>("#bytm-import-menu-textarea");
  948. if(textAreaElem)
  949. textAreaElem.value = "";
  950. if(!menuBg)
  951. return warn("Couldn't find import menu background element");
  952. menuBg.style.visibility = "hidden";
  953. menuBg.style.display = "none";
  954. }
  955. /** Opens the import menu if it is closed */
  956. async function openImportMenu() {
  957. if(!isImportMenuAdded)
  958. await addImportMenu();
  959. isImportMenuAdded = true;
  960. if(isImportMenuOpen)
  961. return;
  962. isImportMenuOpen = true;
  963. document.body.classList.add("bytm-disable-scroll");
  964. document.querySelector("ytmusic-app")?.setAttribute("inert", "true");
  965. const menuBg = document.querySelector<HTMLElement>("#bytm-import-menu-bg");
  966. if(!menuBg)
  967. return warn("Couldn't find import menu background element");
  968. menuBg.style.visibility = "visible";
  969. menuBg.style.display = "block";
  970. }
  971. //#MARKER changelog menu
  972. let isChangelogMenuAdded = false;
  973. let isChangelogMenuOpen = false;
  974. /** Adds a changelog menu (hidden by default) */
  975. async function addChangelogMenu() {
  976. const menuBgElem = document.createElement("div");
  977. menuBgElem.id = "bytm-changelog-menu-bg";
  978. menuBgElem.classList.add("bytm-menu-bg");
  979. menuBgElem.ariaLabel = menuBgElem.title = t("close_menu_tooltip");
  980. menuBgElem.style.visibility = "hidden";
  981. menuBgElem.style.display = "none";
  982. menuBgElem.addEventListener("click", (e) => {
  983. if(isChangelogMenuOpen && (e.target as HTMLElement)?.id === "bytm-changelog-menu-bg") {
  984. closeChangelogMenu(e);
  985. if(menuBgElem.dataset.returnTo === "cfgMenu")
  986. openCfgMenu();
  987. }
  988. });
  989. document.body.addEventListener("keydown", (e) => {
  990. if(isChangelogMenuOpen && e.key === "Escape") {
  991. closeChangelogMenu(e);
  992. if(menuBgElem.dataset.returnTo === "cfgMenu")
  993. openCfgMenu();
  994. }
  995. });
  996. const menuContainer = document.createElement("div");
  997. menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
  998. menuContainer.classList.add("bytm-menu");
  999. menuContainer.id = "bytm-changelog-menu";
  1000. //#SECTION title bar
  1001. const headerElem = document.createElement("div");
  1002. headerElem.classList.add("bytm-menu-header");
  1003. const titleCont = document.createElement("div");
  1004. titleCont.className = "bytm-menu-titlecont";
  1005. titleCont.role = "heading";
  1006. titleCont.ariaLevel = "1";
  1007. const titleElem = document.createElement("h2");
  1008. titleElem.className = "bytm-menu-title";
  1009. titleElem.textContent = t("changelog_menu_title", scriptInfo.name);
  1010. const closeElem = document.createElement("img");
  1011. closeElem.classList.add("bytm-menu-close");
  1012. closeElem.role = "button";
  1013. closeElem.tabIndex = 0;
  1014. closeElem.src = await getResourceUrl("img-close");
  1015. closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip");
  1016. const closeChangelogMenuClicked = (e: MouseEvent | KeyboardEvent) => {
  1017. closeChangelogMenu(e);
  1018. if(menuBgElem.dataset.returnTo === "cfgMenu")
  1019. openCfgMenu();
  1020. };
  1021. closeElem.addEventListener("click", closeChangelogMenuClicked);
  1022. closeElem.addEventListener("keydown", (e) => e.key === "Enter" && closeChangelogMenuClicked(e));
  1023. titleCont.appendChild(titleElem);
  1024. headerElem.appendChild(titleCont);
  1025. headerElem.appendChild(closeElem);
  1026. //#SECTION body
  1027. const getChangelogHtml = (async () => {
  1028. try {
  1029. const changelogMd = await getChangelogMd();
  1030. return await parseMarkdown(changelogMd);
  1031. }
  1032. catch(err) {
  1033. return `Error: ${err}`;
  1034. }
  1035. });
  1036. const menuBodyElem = document.createElement("div");
  1037. menuBodyElem.id = "bytm-changelog-menu-body";
  1038. menuBodyElem.classList.add("bytm-menu-body");
  1039. const textElem = document.createElement("div");
  1040. textElem.id = "bytm-changelog-menu-text";
  1041. textElem.classList.add("bytm-markdown-container");
  1042. textElem.innerHTML = await getChangelogHtml();
  1043. //#SECTION finalize
  1044. menuBodyElem.appendChild(textElem);
  1045. menuContainer.appendChild(headerElem);
  1046. menuContainer.appendChild(menuBodyElem);
  1047. menuBgElem.appendChild(menuContainer);
  1048. document.body.appendChild(menuBgElem);
  1049. const anchors = document.querySelectorAll<HTMLAnchorElement>("#bytm-changelog-menu-text a");
  1050. for(const anchor of anchors) {
  1051. anchor.ariaLabel = anchor.title = anchor.href;
  1052. anchor.target = "_blank";
  1053. }
  1054. }
  1055. /** Closes the changelog menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
  1056. function closeChangelogMenu(evt?: MouseEvent | KeyboardEvent) {
  1057. if(!isChangelogMenuOpen)
  1058. return;
  1059. isChangelogMenuOpen = false;
  1060. evt?.bubbles && evt.stopPropagation();
  1061. const menuBg = document.querySelector<HTMLElement>("#bytm-changelog-menu-bg");
  1062. if(!menuBg)
  1063. return warn("Couldn't find changelog menu background element");
  1064. menuBg.style.visibility = "hidden";
  1065. menuBg.style.display = "none";
  1066. }
  1067. /**
  1068. * Opens the changelog menu if it is closed
  1069. * @param returnTo What menu to open after the changelog menu is closed
  1070. */
  1071. export async function openChangelogMenu(returnTo: "cfgMenu" | "exit" = "cfgMenu") {
  1072. if(!isChangelogMenuAdded)
  1073. await addChangelogMenu();
  1074. isChangelogMenuAdded = true;
  1075. if(isChangelogMenuOpen)
  1076. return;
  1077. isChangelogMenuOpen = true;
  1078. document.body.classList.add("bytm-disable-scroll");
  1079. document.querySelector("ytmusic-app")?.setAttribute("inert", "true");
  1080. const menuBg = document.querySelector<HTMLElement>("#bytm-changelog-menu-bg");
  1081. if(!menuBg)
  1082. return warn("Couldn't find changelog menu background element");
  1083. menuBg.dataset.returnTo = returnTo;
  1084. menuBg.style.visibility = "visible";
  1085. menuBg.style.display = "block";
  1086. }