index.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695
  1. import { getPreferredLocale, getResourceUrl, resourceAsString, t, tp } from "../utils/index.js";
  2. import { clearLyricsCache, getLyricsCache } from "./lyricsCache.js";
  3. import { doVersionCheck } from "./versionCheck.js";
  4. import { getFeature, 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 resourceAsString(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 resourceAsString("icon-globe_small") ?? "",
  47. alert: async (title: string) => getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""),
  48. reloadRequired: async () => getFeature("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. thumbnailOverlayBehavior: {
  139. type: "select",
  140. category: "layout",
  141. options: () => [
  142. { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") },
  143. { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") },
  144. { value: "always", label: t("thumbnail_overlay_behavior_always") },
  145. { value: "never", label: t("thumbnail_overlay_behavior_never") },
  146. ],
  147. default: "songsOnly",
  148. reloadRequired: false,
  149. enable: noop,
  150. },
  151. thumbnailOverlayToggleBtnShown: {
  152. type: "toggle",
  153. category: "layout",
  154. default: true,
  155. textAdornment: adornments.reloadRequired,
  156. },
  157. thumbnailOverlayShowIndicator: {
  158. type: "toggle",
  159. category: "layout",
  160. default: true,
  161. textAdornment: adornments.reloadRequired,
  162. },
  163. thumbnailOverlayIndicatorOpacity: {
  164. type: "slider",
  165. category: "layout",
  166. min: 5,
  167. max: 100,
  168. step: 5,
  169. default: 40,
  170. unit: "%",
  171. advanced: true,
  172. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  173. },
  174. thumbnailOverlayImageFit: {
  175. type: "select",
  176. category: "layout",
  177. options: () => [
  178. { value: "cover", label: t("thumbnail_overlay_image_fit_crop") },
  179. { value: "contain", label: t("thumbnail_overlay_image_fit_full") },
  180. { value: "fill", label: t("thumbnail_overlay_image_fit_stretch") },
  181. ],
  182. default: "cover",
  183. advanced: true,
  184. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  185. },
  186. hideCursorOnIdle: {
  187. type: "toggle",
  188. category: "layout",
  189. default: true,
  190. reloadRequired: false,
  191. enable: noop,
  192. },
  193. hideCursorOnIdleDelay: {
  194. type: "slider",
  195. category: "layout",
  196. min: 0.5,
  197. max: 10,
  198. step: 0.25,
  199. default: 2,
  200. unit: "s",
  201. advanced: true,
  202. textAdornment: adornments.advanced,
  203. reloadRequired: false,
  204. enable: noop,
  205. },
  206. fixHdrIssues: {
  207. type: "toggle",
  208. category: "layout",
  209. default: true,
  210. advanced: true,
  211. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  212. },
  213. disableDarkReaderSites: {
  214. type: "select",
  215. category: "layout",
  216. options: options.siteSelectionOrNone,
  217. default: "all",
  218. advanced: true,
  219. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  220. },
  221. showVotes: {
  222. type: "toggle",
  223. category: "layout",
  224. default: true,
  225. textAdornment: adornments.reloadRequired,
  226. },
  227. showVotesFormat: {
  228. type: "select",
  229. category: "layout",
  230. options: () => [
  231. { value: "long", label: t("votes_format_full") },
  232. { value: "short", label: t("votes_format_short") },
  233. ],
  234. default: "short",
  235. reloadRequired: false,
  236. enable: noop,
  237. },
  238. // archived idea for future version:
  239. // showVoteRatio: {
  240. // type: "select",
  241. // category: "layout",
  242. // options: () => [
  243. // { value: "disabled", label: t("vote_ratio_disabled") },
  244. // { value: "greenRed", label: t("vote_ratio_green_red") },
  245. // { value: "blueGray", label: t("vote_ratio_blue_gray") },
  246. // ],
  247. // default: "disabled",
  248. // textAdornment: adornments.reloadRequired,
  249. // },
  250. //#region volume
  251. volumeSliderLabel: {
  252. type: "toggle",
  253. category: "volume",
  254. default: true,
  255. textAdornment: adornments.reloadRequired,
  256. },
  257. volumeSliderSize: {
  258. type: "number",
  259. category: "volume",
  260. min: 50,
  261. max: 500,
  262. step: 5,
  263. default: 150,
  264. unit: "px",
  265. textAdornment: adornments.reloadRequired,
  266. },
  267. volumeSliderStep: {
  268. type: "slider",
  269. category: "volume",
  270. min: 1,
  271. max: 25,
  272. default: 2,
  273. unit: "%",
  274. textAdornment: adornments.reloadRequired,
  275. },
  276. volumeSliderScrollStep: {
  277. type: "slider",
  278. category: "volume",
  279. min: 1,
  280. max: 25,
  281. default: 4,
  282. unit: "%",
  283. textAdornment: adornments.reloadRequired,
  284. },
  285. volumeSharedBetweenTabs: {
  286. type: "toggle",
  287. category: "volume",
  288. default: false,
  289. textAdornment: adornments.reloadRequired,
  290. },
  291. setInitialTabVolume: {
  292. type: "toggle",
  293. category: "volume",
  294. default: false,
  295. textAdornment: () => getFeature("volumeSharedBetweenTabs")
  296. ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reloadRequired])
  297. : adornments.reloadRequired(),
  298. },
  299. initialTabVolumeLevel: {
  300. type: "slider",
  301. category: "volume",
  302. min: 0,
  303. max: 100,
  304. step: 1,
  305. default: 100,
  306. unit: "%",
  307. textAdornment: () => getFeature("volumeSharedBetweenTabs")
  308. ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reloadRequired])
  309. : adornments.reloadRequired(),
  310. reloadRequired: false,
  311. enable: noop,
  312. },
  313. //#region song lists
  314. lyricsQueueButton: {
  315. type: "toggle",
  316. category: "songLists",
  317. default: true,
  318. textAdornment: adornments.reloadRequired,
  319. },
  320. deleteFromQueueButton: {
  321. type: "toggle",
  322. category: "songLists",
  323. default: true,
  324. textAdornment: adornments.reloadRequired,
  325. },
  326. listButtonsPlacement: {
  327. type: "select",
  328. category: "songLists",
  329. options: () => [
  330. { value: "queueOnly", label: t("list_button_placement_queue_only") },
  331. { value: "everywhere", label: t("list_button_placement_everywhere") },
  332. ],
  333. default: "everywhere",
  334. advanced: true,
  335. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  336. },
  337. scrollToActiveSongBtn: {
  338. type: "toggle",
  339. category: "songLists",
  340. default: true,
  341. textAdornment: adornments.reloadRequired,
  342. },
  343. clearQueueBtn: {
  344. type: "toggle",
  345. category: "songLists",
  346. default: true,
  347. textAdornment: adornments.reloadRequired,
  348. },
  349. //#region behavior
  350. disableBeforeUnloadPopup: {
  351. type: "toggle",
  352. category: "behavior",
  353. default: false,
  354. textAdornment: adornments.reloadRequired,
  355. },
  356. closeToastsTimeout: {
  357. type: "number",
  358. category: "behavior",
  359. min: 0,
  360. max: 30,
  361. step: 0.5,
  362. default: 3,
  363. unit: "s",
  364. reloadRequired: false,
  365. enable: noop,
  366. },
  367. rememberSongTime: {
  368. type: "toggle",
  369. category: "behavior",
  370. default: true,
  371. helpText: () => tp("feature_helptext_rememberSongTime", getFeature("rememberSongTimeMinPlayTime"), getFeature("rememberSongTimeMinPlayTime")),
  372. textAdornment: adornments.reloadRequired,
  373. },
  374. rememberSongTimeSites: {
  375. type: "select",
  376. category: "behavior",
  377. options: options.siteSelection,
  378. default: "all",
  379. textAdornment: adornments.reloadRequired,
  380. },
  381. rememberSongTimeDuration: {
  382. type: "number",
  383. category: "behavior",
  384. min: 1,
  385. max: 60 * 60 * 24 * 7,
  386. step: 1,
  387. default: 60,
  388. unit: "s",
  389. advanced: true,
  390. textAdornment: adornments.advanced,
  391. reloadRequired: false,
  392. enable: noop,
  393. },
  394. rememberSongTimeReduction: {
  395. type: "number",
  396. category: "behavior",
  397. min: 0,
  398. max: 30,
  399. step: 0.05,
  400. default: 0.2,
  401. unit: "s",
  402. advanced: true,
  403. textAdornment: adornments.advanced,
  404. reloadRequired: false,
  405. enable: noop,
  406. },
  407. rememberSongTimeMinPlayTime: {
  408. type: "slider",
  409. category: "behavior",
  410. min: 0.5,
  411. max: 30,
  412. step: 0.5,
  413. default: 10,
  414. unit: "s",
  415. advanced: true,
  416. textAdornment: adornments.advanced,
  417. reloadRequired: false,
  418. enable: noop,
  419. },
  420. //#region input
  421. arrowKeySupport: {
  422. type: "toggle",
  423. category: "input",
  424. default: true,
  425. reloadRequired: false,
  426. enable: noop,
  427. },
  428. arrowKeySkipBy: {
  429. type: "number",
  430. category: "input",
  431. min: 0.5,
  432. max: 60,
  433. step: 0.5,
  434. default: 5,
  435. reloadRequired: false,
  436. enable: noop,
  437. },
  438. switchBetweenSites: {
  439. type: "toggle",
  440. category: "input",
  441. default: true,
  442. reloadRequired: false,
  443. enable: noop,
  444. },
  445. switchSitesHotkey: {
  446. type: "hotkey",
  447. category: "input",
  448. default: {
  449. code: "F9",
  450. shift: false,
  451. ctrl: false,
  452. alt: false,
  453. },
  454. reloadRequired: false,
  455. enable: noop,
  456. },
  457. anchorImprovements: {
  458. type: "toggle",
  459. category: "input",
  460. default: true,
  461. textAdornment: adornments.reloadRequired,
  462. },
  463. numKeysSkipToTime: {
  464. type: "toggle",
  465. category: "input",
  466. default: true,
  467. reloadRequired: false,
  468. enable: noop,
  469. },
  470. autoLikeChannels: {
  471. type: "toggle",
  472. category: "input",
  473. default: true,
  474. textAdornment: adornments.reloadRequired,
  475. },
  476. autoLikeChannelToggleBtn: {
  477. type: "toggle",
  478. category: "input",
  479. default: true,
  480. reloadRequired: false,
  481. enable: noop,
  482. },
  483. // TODO(v2.2):
  484. // autoLikePlayerBarToggleBtn: {
  485. // type: "toggle",
  486. // category: "input",
  487. // default: false,
  488. // textAdornment: adornments.reloadRequired,
  489. // },
  490. autoLikeTimeout: {
  491. type: "slider",
  492. category: "input",
  493. min: 3,
  494. max: 30,
  495. step: 0.5,
  496. default: 5,
  497. unit: "s",
  498. advanced: true,
  499. reloadRequired: false,
  500. enable: noop,
  501. textAdornment: adornments.advanced,
  502. },
  503. autoLikeShowToast: {
  504. type: "toggle",
  505. category: "input",
  506. default: true,
  507. reloadRequired: false,
  508. advanced: true,
  509. enable: noop,
  510. textAdornment: adornments.advanced,
  511. },
  512. autoLikeOpenMgmtDialog: {
  513. type: "button",
  514. category: "input",
  515. click: () => getAutoLikeDialog().then(d => d.open()),
  516. },
  517. //#region lyrics
  518. geniusLyrics: {
  519. type: "toggle",
  520. category: "lyrics",
  521. default: true,
  522. },
  523. geniUrlBase: {
  524. type: "text",
  525. category: "lyrics",
  526. default: "https://api.sv443.net/geniurl",
  527. normalize: (val: string) => val.trim().replace(/\/+$/, ""),
  528. advanced: true,
  529. textAdornment: adornments.advanced,
  530. reloadRequired: false,
  531. enable: noop,
  532. },
  533. geniUrlToken: {
  534. type: "text",
  535. valueHidden: true,
  536. category: "lyrics",
  537. default: "",
  538. normalize: (val: string) => val.trim(),
  539. advanced: true,
  540. textAdornment: adornments.advanced,
  541. reloadRequired: false,
  542. enable: noop,
  543. },
  544. lyricsCacheMaxSize: {
  545. type: "slider",
  546. category: "lyrics",
  547. default: 1000,
  548. min: 100,
  549. max: 5000,
  550. step: 100,
  551. unit: (val: number) => " " + tp("unit_entries", val),
  552. advanced: true,
  553. textAdornment: adornments.advanced,
  554. reloadRequired: false,
  555. enable: noop,
  556. },
  557. lyricsCacheTTL: {
  558. type: "slider",
  559. category: "lyrics",
  560. default: 21,
  561. min: 1,
  562. max: 100,
  563. step: 1,
  564. unit: (val: number) => " " + tp("unit_days", val),
  565. advanced: true,
  566. textAdornment: adornments.advanced,
  567. reloadRequired: false,
  568. enable: noop,
  569. },
  570. clearLyricsCache: {
  571. type: "button",
  572. category: "lyrics",
  573. async click() {
  574. const entries = getLyricsCache().length;
  575. if(confirm(tp("lyrics_clear_cache_confirm_prompt", entries, entries))) {
  576. await clearLyricsCache();
  577. alert(t("lyrics_clear_cache_success"));
  578. }
  579. },
  580. advanced: true,
  581. textAdornment: adornments.advanced,
  582. },
  583. advancedLyricsFilter: {
  584. type: "toggle",
  585. category: "lyrics",
  586. default: false,
  587. change: () => setTimeout(() => confirm(t("lyrics_cache_changed_clear_confirm")) && clearLyricsCache(), 200),
  588. advanced: true,
  589. textAdornment: adornments.experimental,
  590. reloadRequired: false,
  591. enable: noop,
  592. },
  593. //#region general
  594. locale: {
  595. type: "select",
  596. category: "general",
  597. options: options.locale,
  598. default: getPreferredLocale(),
  599. textAdornment: () => combineAdornments([adornments.globe, adornments.reloadRequired]),
  600. },
  601. localeFallback: {
  602. type: "toggle",
  603. category: "general",
  604. default: true,
  605. advanced: true,
  606. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  607. },
  608. versionCheck: {
  609. type: "toggle",
  610. category: "general",
  611. default: true,
  612. textAdornment: adornments.reloadRequired,
  613. },
  614. checkVersionNow: {
  615. type: "button",
  616. category: "general",
  617. click: () => doVersionCheck(true),
  618. },
  619. logLevel: {
  620. type: "select",
  621. category: "general",
  622. options: () => [
  623. { value: 0, label: t("log_level_debug") },
  624. { value: 1, label: t("log_level_info") },
  625. ],
  626. default: 1,
  627. textAdornment: adornments.reloadRequired,
  628. },
  629. initTimeout: {
  630. type: "number",
  631. category: "general",
  632. min: 3,
  633. max: 30,
  634. default: 8,
  635. step: 0.1,
  636. unit: "s",
  637. advanced: true,
  638. textAdornment: () => combineAdornments([adornments.advanced, adornments.reloadRequired]),
  639. },
  640. toastDuration: {
  641. type: "slider",
  642. category: "general",
  643. min: 0,
  644. max: 15,
  645. default: 3,
  646. step: 0.5,
  647. unit: "s",
  648. reloadRequired: false,
  649. advanced: true,
  650. textAdornment: adornments.advanced,
  651. enable: noop,
  652. change: () => showIconToast({
  653. duration: getFeature("toastDuration") * 1000,
  654. message: "Example",
  655. iconSrc: getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`),
  656. }),
  657. },
  658. resetConfig: {
  659. type: "button",
  660. category: "general",
  661. click: promptResetConfig,
  662. textAdornment: adornments.reloadRequired,
  663. },
  664. advancedMode: {
  665. type: "toggle",
  666. category: "general",
  667. default: false,
  668. textAdornment: () => getFeature("advancedMode") ? adornments.advanced() : undefined,
  669. change: (_key, prevValue, newValue) =>
  670. prevValue !== newValue &&
  671. emitSiteEvent("recreateCfgMenu"),
  672. },
  673. } as const satisfies FeatureInfo;
  674. function noop() {
  675. void 0;
  676. }
  677. void [noop];