index.ts 24 KB

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