index.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. import { getPreferredLocale, getResourceUrl, resourceToHTMLString, t, tp } from "../utils/index.js";
  2. import { clearLyricsCache, getLyricsCache } from "./lyricsCache.js";
  3. import { doVersionCheck } from "./versionCheck.js";
  4. import { getFeatures, promptResetConfig } from "../config.js";
  5. import { FeatureInfo, type ResourceKey, type SiteSelection, type SiteSelectionOrNone } from "../types.js";
  6. import { emitSiteEvent } from "../siteEvents.js";
  7. import langMapping from "../../assets/locales.json" with { type: "json" };
  8. import { getAutoLikeDialog } from "../dialogs/index.js";
  9. import { showIconToast } from "../components/index.js";
  10. import { mode } from "../constants.js";
  11. export * from "./layout.js";
  12. export * from "./behavior.js";
  13. export * from "./input.js";
  14. export * from "./lyrics.js";
  15. export * from "./lyricsCache.js";
  16. export * from "./songLists.js";
  17. export * from "./versionCheck.js";
  18. export * from "./volume.js";
  19. interface SelectOption<TValue = number | string> {
  20. value: TValue;
  21. label: string;
  22. }
  23. //#region dependencies
  24. /** Creates an HTML string for the given adornment properties */
  25. const getAdornHtml = async (className: string, title: string, resource: ResourceKey, extraParams?: string) =>
  26. `<span class="${className} bytm-adorn-icon" title="${title}" aria-label="${title}"${extraParams ? " " + extraParams : ""}>${await resourceToHTMLString(resource) ?? ""}</span>`;
  27. /** Combines multiple async functions or promises that resolve with an adornment HTML string into a single string */
  28. const combineAdornments = (
  29. adornments: Array<(
  30. | (() => Promise<string | undefined>)
  31. | Promise<string | undefined>
  32. )>
  33. ) =>
  34. new Promise<string>(async (resolve) => {
  35. const html = [] as string[];
  36. for(const adornment of adornments) {
  37. const val = typeof adornment === "function" ? await adornment() : await adornment;
  38. val && html.push(val);
  39. }
  40. resolve(html.join(""));
  41. });
  42. /** Decoration elements that can be added next to the label */
  43. const adornments = {
  44. advanced: async () => getAdornHtml("bytm-advanced-mode-icon", t("advanced_mode"), "icon-advanced_mode"),
  45. experimental: async () => getAdornHtml("bytm-experimental-icon", t("experimental_feature"), "icon-experimental"),
  46. globe: async () => await resourceToHTMLString("icon-globe_small") ?? "",
  47. alert: async (title: string) => getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""),
  48. reloadRequired: async () => getFeatures().advancedMode ? getAdornHtml("bytm-reload-icon", t("feature_requires_reload"), "icon-reload") : undefined,
  49. } satisfies Record<string, (...args: any[]) => Promise<string | undefined>>;
  50. /** Common options for config items of type "select" */
  51. const options = {
  52. siteSelection: (): SelectOption<SiteSelection>[] => [
  53. { value: "all", label: t("site_selection_both_sites") },
  54. { value: "yt", label: t("site_selection_only_yt") },
  55. { value: "ytm", label: t("site_selection_only_ytm") },
  56. ],
  57. siteSelectionOrNone: (): SelectOption<SiteSelectionOrNone>[] => [
  58. { value: "all", label: t("site_selection_both_sites") },
  59. { value: "yt", label: t("site_selection_only_yt") },
  60. { value: "ytm", label: t("site_selection_only_ytm") },
  61. { value: "none", label: t("site_selection_none") },
  62. ],
  63. locale: () => Object.entries(langMapping)
  64. .reduce((a, [locale, { name }]) => {
  65. return [...a, {
  66. value: locale,
  67. label: name,
  68. }];
  69. }, [] as SelectOption[])
  70. .sort((a, b) => a.label.localeCompare(b.label)),
  71. };
  72. //#region features
  73. /**
  74. * Contains all possible features with their default values and other configuration.
  75. *
  76. * **Required props:**
  77. * <!-------------------------------------------------------------------------------------------------------------------------------------------------------->
  78. * | Property | Description |
  79. * | :------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
  80. * | `type` | type of the feature configuration element - use autocomplete or check `FeatureTypeProps` in `src/types.ts` |
  81. * | `category` | category of the feature - use autocomplete or check `FeatureCategory` in `src/types.ts` |
  82. * | `default` | default value of the feature - type of the value depends on the given `type` |
  83. * | `enable(value: any)` | (required if reloadRequired = false) - function that will be called when the feature is enabled / initialized for the first time |
  84. *
  85. *
  86. * **Optional props:**
  87. * <!-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------->
  88. * | Property | Description |
  89. * | :------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- |
  90. * | `disable: (newValue: any) => void` | for type `toggle` only - function that will be called when the feature is disabled - can be a synchronous or asynchronous function |
  91. * | `change: (key: string, prevValue: any, newValue: any)` => void | for types `number`, `select`, `slider` and `hotkey` only - function that will be called when the value is changed |
  92. * | `click: () => void` | for type `button` only - function that will be called when the button is clicked |
  93. * | `helpText: string / () => string` | function that returns an HTML string or the literal string itself that will be the help text for this feature - writing as function is useful for pluralizing or inserting values into the translation at runtime - if not set, translation with key `feature_helptext_featureKey` will be used instead, if available |
  94. * | `textAdornment: () => string / Promise<string>` | function that returns an HTML string that will be appended to the text in the config menu as an adornment element |
  95. * | `unit: string / (val: number) => string` | Only if type is `number` or `slider` - The unit text that is displayed next to the input element, i.e. " px" - a leading space need to be added by hand! |
  96. * | `min: number` | Only if type is `number` or `slider` - Overwrites the default of the `min` property of the HTML input element |
  97. * | `max: number` | Only if type is `number` or `slider` - Overwrites the default of the `max` property of the HTML input element |
  98. * | `step: number` | Only if type is `number` or `slider` - Overwrites the default of the `step` property of the HTML input element |
  99. * | `options: SelectOption[] / () => SelectOption[]` | Only if type is `select` - function that returns an array of objects with `value` and `label` properties |
  100. * | `reloadRequired: boolean` | if true (default), the page needs to be reloaded for the changes to take effect - if false, `enable()` needs to be provided |
  101. * | `advanced: boolean` | if true, the feature will only be shown if the advanced mode feature has been turned on |
  102. * | `hidden: boolean` | if true, the feature will not be shown in the settings - default is undefined (false) |
  103. * | `valueHidden: boolean` | If true, the value of the feature will be hidden in the settings and via the plugin interface - default is undefined (false) |
  104. * | `normalize: (val: any) => any` | Function that will be called to normalize the value before it is saved - useful for trimming strings or other simple operations |
  105. * | `renderValue: (val: any) => string` | If provided, is called before rendering the value's label in the config menu |
  106. *
  107. * TODO: go through all features and set as many as possible to reloadRequired = false
  108. */
  109. export const featInfo = {
  110. //#region layout
  111. watermarkEnabled: {
  112. type: "toggle",
  113. category: "layout",
  114. default: true,
  115. textAdornment: adornments.reloadRequired,
  116. },
  117. removeShareTrackingParam: {
  118. type: "toggle",
  119. category: "layout",
  120. default: true,
  121. textAdornment: adornments.reloadRequired,
  122. },
  123. removeShareTrackingParamSites: {
  124. type: "select",
  125. category: "layout",
  126. options: options.siteSelection,
  127. default: "all",
  128. advanced: true,
  129. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  130. },
  131. fixSpacing: {
  132. type: "toggle",
  133. category: "layout",
  134. default: true,
  135. advanced: true,
  136. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  137. },
  138. removeUpgradeTab: {
  139. type: "toggle",
  140. category: "layout",
  141. default: true,
  142. textAdornment: adornments.reloadRequired,
  143. },
  144. thumbnailOverlayBehavior: {
  145. type: "select",
  146. category: "layout",
  147. options: () => [
  148. { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") },
  149. { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") },
  150. { value: "always", label: t("thumbnail_overlay_behavior_always") },
  151. { value: "never", label: t("thumbnail_overlay_behavior_never") },
  152. ],
  153. default: "songsOnly",
  154. reloadRequired: false,
  155. enable: noop,
  156. },
  157. thumbnailOverlayToggleBtnShown: {
  158. type: "toggle",
  159. category: "layout",
  160. default: true,
  161. textAdornment: adornments.reloadRequired,
  162. },
  163. thumbnailOverlayShowIndicator: {
  164. type: "toggle",
  165. category: "layout",
  166. default: true,
  167. textAdornment: adornments.reloadRequired,
  168. },
  169. thumbnailOverlayIndicatorOpacity: {
  170. type: "slider",
  171. category: "layout",
  172. min: 5,
  173. max: 100,
  174. step: 5,
  175. default: 40,
  176. unit: "%",
  177. advanced: true,
  178. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  179. },
  180. thumbnailOverlayImageFit: {
  181. type: "select",
  182. category: "layout",
  183. options: () => [
  184. { value: "cover", label: t("thumbnail_overlay_image_fit_crop") },
  185. { value: "contain", label: t("thumbnail_overlay_image_fit_full") },
  186. { value: "fill", label: t("thumbnail_overlay_image_fit_stretch") },
  187. ],
  188. default: "cover",
  189. advanced: true,
  190. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  191. },
  192. hideCursorOnIdle: {
  193. type: "toggle",
  194. category: "layout",
  195. default: true,
  196. reloadRequired: false,
  197. enable: noop,
  198. },
  199. hideCursorOnIdleDelay: {
  200. type: "slider",
  201. category: "layout",
  202. min: 0.5,
  203. max: 10,
  204. step: 0.25,
  205. default: 2,
  206. unit: "s",
  207. advanced: true,
  208. textAdornment: adornments.advanced,
  209. reloadRequired: false,
  210. enable: noop,
  211. },
  212. fixHdrIssues: {
  213. type: "toggle",
  214. category: "layout",
  215. default: true,
  216. advanced: true,
  217. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  218. },
  219. disableDarkReaderSites: {
  220. type: "select",
  221. category: "layout",
  222. options: options.siteSelectionOrNone,
  223. default: "all",
  224. advanced: true,
  225. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  226. },
  227. //#region volume
  228. volumeSliderLabel: {
  229. type: "toggle",
  230. category: "volume",
  231. default: true,
  232. textAdornment: adornments.reloadRequired,
  233. },
  234. volumeSliderSize: {
  235. type: "number",
  236. category: "volume",
  237. min: 50,
  238. max: 500,
  239. step: 5,
  240. default: 150,
  241. unit: "px",
  242. textAdornment: adornments.reloadRequired,
  243. },
  244. volumeSliderStep: {
  245. type: "slider",
  246. category: "volume",
  247. min: 1,
  248. max: 25,
  249. default: 2,
  250. unit: "%",
  251. textAdornment: adornments.reloadRequired,
  252. },
  253. volumeSliderScrollStep: {
  254. type: "slider",
  255. category: "volume",
  256. min: 1,
  257. max: 25,
  258. default: 4,
  259. unit: "%",
  260. textAdornment: adornments.reloadRequired,
  261. },
  262. volumeSharedBetweenTabs: {
  263. type: "toggle",
  264. category: "volume",
  265. default: false,
  266. textAdornment: adornments.reloadRequired,
  267. },
  268. setInitialTabVolume: {
  269. type: "toggle",
  270. category: "volume",
  271. default: false,
  272. textAdornment: () => getFeatures().volumeSharedBetweenTabs
  273. ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reloadRequired])
  274. : adornments.reloadRequired(),
  275. },
  276. initialTabVolumeLevel: {
  277. type: "slider",
  278. category: "volume",
  279. min: 0,
  280. max: 100,
  281. step: 1,
  282. default: 100,
  283. unit: "%",
  284. textAdornment: () => getFeatures().volumeSharedBetweenTabs
  285. ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reloadRequired])
  286. : adornments.reloadRequired(),
  287. reloadRequired: false,
  288. enable: noop,
  289. },
  290. //#region song lists
  291. lyricsQueueButton: {
  292. type: "toggle",
  293. category: "songLists",
  294. default: true,
  295. textAdornment: adornments.reloadRequired,
  296. },
  297. deleteFromQueueButton: {
  298. type: "toggle",
  299. category: "songLists",
  300. default: true,
  301. textAdornment: adornments.reloadRequired,
  302. },
  303. listButtonsPlacement: {
  304. type: "select",
  305. category: "songLists",
  306. options: () => [
  307. { value: "queueOnly", label: t("list_button_placement_queue_only") },
  308. { value: "everywhere", label: t("list_button_placement_everywhere") },
  309. ],
  310. default: "everywhere",
  311. advanced: true,
  312. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  313. },
  314. scrollToActiveSongBtn: {
  315. type: "toggle",
  316. category: "songLists",
  317. default: true,
  318. textAdornment: adornments.reloadRequired,
  319. },
  320. clearQueueBtn: {
  321. type: "toggle",
  322. category: "songLists",
  323. default: true,
  324. textAdornment: adornments.reloadRequired,
  325. },
  326. //#region behavior
  327. disableBeforeUnloadPopup: {
  328. type: "toggle",
  329. category: "behavior",
  330. default: false,
  331. textAdornment: adornments.reloadRequired,
  332. },
  333. closeToastsTimeout: {
  334. type: "number",
  335. category: "behavior",
  336. min: 0,
  337. max: 30,
  338. step: 0.5,
  339. default: 3,
  340. unit: "s",
  341. reloadRequired: false,
  342. enable: noop,
  343. },
  344. rememberSongTime: {
  345. type: "toggle",
  346. category: "behavior",
  347. default: true,
  348. helpText: () => tp("feature_helptext_rememberSongTime", getFeatures().rememberSongTimeMinPlayTime, getFeatures().rememberSongTimeMinPlayTime),
  349. textAdornment: adornments.reloadRequired,
  350. },
  351. rememberSongTimeSites: {
  352. type: "select",
  353. category: "behavior",
  354. options: options.siteSelection,
  355. default: "all",
  356. textAdornment: adornments.reloadRequired,
  357. },
  358. rememberSongTimeDuration: {
  359. type: "number",
  360. category: "behavior",
  361. min: 1,
  362. max: 60 * 60 * 24 * 7,
  363. step: 1,
  364. default: 60,
  365. unit: "s",
  366. advanced: true,
  367. textAdornment: adornments.advanced,
  368. reloadRequired: false,
  369. enable: noop,
  370. },
  371. rememberSongTimeReduction: {
  372. type: "number",
  373. category: "behavior",
  374. min: 0,
  375. max: 30,
  376. step: 0.05,
  377. default: 0.2,
  378. unit: "s",
  379. advanced: true,
  380. textAdornment: adornments.advanced,
  381. reloadRequired: false,
  382. enable: noop,
  383. },
  384. rememberSongTimeMinPlayTime: {
  385. type: "slider",
  386. category: "behavior",
  387. min: 0.5,
  388. max: 30,
  389. step: 0.5,
  390. default: 10,
  391. unit: "s",
  392. advanced: true,
  393. textAdornment: adornments.advanced,
  394. reloadRequired: false,
  395. enable: noop,
  396. },
  397. //#region input
  398. arrowKeySupport: {
  399. type: "toggle",
  400. category: "input",
  401. default: true,
  402. reloadRequired: false,
  403. enable: noop,
  404. },
  405. arrowKeySkipBy: {
  406. type: "number",
  407. category: "input",
  408. min: 0.5,
  409. max: 60,
  410. step: 0.5,
  411. default: 5,
  412. reloadRequired: false,
  413. enable: noop,
  414. },
  415. switchBetweenSites: {
  416. type: "toggle",
  417. category: "input",
  418. default: true,
  419. reloadRequired: false,
  420. enable: noop,
  421. },
  422. switchSitesHotkey: {
  423. type: "hotkey",
  424. category: "input",
  425. default: {
  426. code: "F9",
  427. shift: false,
  428. ctrl: false,
  429. alt: false,
  430. },
  431. reloadRequired: false,
  432. enable: noop,
  433. },
  434. anchorImprovements: {
  435. type: "toggle",
  436. category: "input",
  437. default: true,
  438. textAdornment: adornments.reloadRequired,
  439. },
  440. numKeysSkipToTime: {
  441. type: "toggle",
  442. category: "input",
  443. default: true,
  444. reloadRequired: false,
  445. enable: noop,
  446. },
  447. autoLikeChannels: {
  448. type: "toggle",
  449. category: "input",
  450. default: true,
  451. textAdornment: adornments.reloadRequired,
  452. },
  453. autoLikeChannelToggleBtn: {
  454. type: "toggle",
  455. category: "input",
  456. default: true,
  457. reloadRequired: false,
  458. enable: noop,
  459. },
  460. autoLikePlayerBarToggleBtn: {
  461. type: "toggle",
  462. category: "input",
  463. default: false,
  464. textAdornment: adornments.reloadRequired,
  465. },
  466. autoLikeTimeout: {
  467. type: "slider",
  468. category: "input",
  469. min: 3,
  470. max: 30,
  471. step: 0.5,
  472. default: 5,
  473. unit: "s",
  474. advanced: true,
  475. reloadRequired: false,
  476. enable: noop,
  477. textAdornment: adornments.advanced,
  478. },
  479. autoLikeShowToast: {
  480. type: "toggle",
  481. category: "input",
  482. default: true,
  483. reloadRequired: false,
  484. advanced: true,
  485. enable: noop,
  486. textAdornment: adornments.advanced,
  487. },
  488. autoLikeOpenMgmtDialog: {
  489. type: "button",
  490. category: "input",
  491. click: () => getAutoLikeDialog().then(d => d.open()),
  492. },
  493. //#region lyrics
  494. geniusLyrics: {
  495. type: "toggle",
  496. category: "lyrics",
  497. default: true,
  498. },
  499. geniUrlBase: {
  500. type: "text",
  501. category: "lyrics",
  502. default: "https://api.sv443.net/geniurl",
  503. normalize: (val: string) => val.trim().replace(/\/+$/, ""),
  504. advanced: true,
  505. textAdornment: adornments.advanced,
  506. reloadRequired: false,
  507. enable: noop,
  508. },
  509. geniUrlToken: {
  510. type: "text",
  511. valueHidden: true,
  512. category: "lyrics",
  513. default: "",
  514. normalize: (val: string) => val.trim(),
  515. advanced: true,
  516. textAdornment: adornments.advanced,
  517. reloadRequired: false,
  518. enable: noop,
  519. },
  520. lyricsCacheMaxSize: {
  521. type: "slider",
  522. category: "lyrics",
  523. default: 1000,
  524. min: 100,
  525. max: 5000,
  526. step: 100,
  527. unit: (val: number) => " " + tp("unit_entries", val),
  528. advanced: true,
  529. textAdornment: adornments.advanced,
  530. reloadRequired: false,
  531. enable: noop,
  532. },
  533. lyricsCacheTTL: {
  534. type: "slider",
  535. category: "lyrics",
  536. default: 21,
  537. min: 1,
  538. max: 100,
  539. step: 1,
  540. unit: (val: number) => " " + tp("unit_days", val),
  541. advanced: true,
  542. textAdornment: adornments.advanced,
  543. reloadRequired: false,
  544. enable: noop,
  545. },
  546. clearLyricsCache: {
  547. type: "button",
  548. category: "lyrics",
  549. async click() {
  550. const entries = getLyricsCache().length;
  551. if(confirm(tp("lyrics_clear_cache_confirm_prompt", entries, entries))) {
  552. await clearLyricsCache();
  553. alert(t("lyrics_clear_cache_success"));
  554. }
  555. },
  556. advanced: true,
  557. textAdornment: adornments.advanced,
  558. },
  559. advancedLyricsFilter: {
  560. type: "toggle",
  561. category: "lyrics",
  562. default: false,
  563. change: () => setTimeout(() => confirm(t("lyrics_cache_changed_clear_confirm")) && clearLyricsCache(), 200),
  564. advanced: true,
  565. textAdornment: adornments.experimental,
  566. reloadRequired: false,
  567. enable: noop,
  568. },
  569. //#region general
  570. locale: {
  571. type: "select",
  572. category: "general",
  573. options: options.locale,
  574. default: getPreferredLocale(),
  575. textAdornment: () => combineAdornments([adornments.globe, adornments.reloadRequired]),
  576. },
  577. localeFallback: {
  578. type: "toggle",
  579. category: "general",
  580. default: true,
  581. advanced: true,
  582. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  583. },
  584. versionCheck: {
  585. type: "toggle",
  586. category: "general",
  587. default: true,
  588. textAdornment: adornments.reloadRequired,
  589. },
  590. checkVersionNow: {
  591. type: "button",
  592. category: "general",
  593. click: () => doVersionCheck(true),
  594. },
  595. logLevel: {
  596. type: "select",
  597. category: "general",
  598. options: () => [
  599. { value: 0, label: t("log_level_debug") },
  600. { value: 1, label: t("log_level_info") },
  601. ],
  602. default: 1,
  603. textAdornment: adornments.reloadRequired,
  604. },
  605. initTimeout: {
  606. type: "number",
  607. category: "general",
  608. min: 3,
  609. max: 30,
  610. default: 8,
  611. step: 0.1,
  612. unit: "s",
  613. advanced: true,
  614. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  615. },
  616. toastDuration: {
  617. type: "slider",
  618. category: "general",
  619. min: 0,
  620. max: 15,
  621. default: 3,
  622. step: 0.5,
  623. unit: "s",
  624. reloadRequired: false,
  625. advanced: true,
  626. textAdornment: adornments.advanced,
  627. enable: noop,
  628. change: () => showIconToast({
  629. duration: getFeatures().toastDuration * 1000,
  630. message: "Example",
  631. iconSrc: getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`),
  632. }),
  633. },
  634. resetConfig: {
  635. type: "button",
  636. category: "general",
  637. click: promptResetConfig,
  638. textAdornment: adornments.reloadRequired,
  639. },
  640. advancedMode: {
  641. type: "toggle",
  642. category: "general",
  643. default: false,
  644. textAdornment: () => getFeatures().advancedMode ? adornments.advanced() : undefined,
  645. change: (_key, prevValue, newValue) =>
  646. prevValue !== newValue &&
  647. emitSiteEvent("recreateCfgMenu"),
  648. },
  649. } as const satisfies FeatureInfo;
  650. function noop() {
  651. void 0;
  652. }
  653. void [noop];