index.ts 20 KB

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