index.ts 16 KB

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