config.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import { DataStore, compress, type DataMigrationsDict, decompress, type LooseUnion, clamp } from "@sv443-network/userutils";
  2. import { disableBeforeUnload, featInfo } from "./features/index.js";
  3. import { compressionSupported, error, getVideoTime, info, log, t } from "./utils/index.js";
  4. import { emitSiteEvent } from "./siteEvents.js";
  5. import { compressionFormat, mode } from "./constants.js";
  6. import { emitInterface } from "./interface.js";
  7. import { closeCfgMenu } from "./menu/menu_old.js";
  8. import type { FeatureConfig, FeatureKey } from "./types.js";
  9. import { showPrompt } from "./dialogs/prompt.js";
  10. /** If this number is incremented, the features object data will be migrated to the new format */
  11. export const formatVersion = 7;
  12. export const defaultData = (Object.keys(featInfo) as (keyof typeof featInfo)[])
  13. // @ts-ignore
  14. .filter((ftKey) => featInfo?.[ftKey]?.default !== undefined)
  15. .reduce<Partial<FeatureConfig>>((acc, key) => {
  16. // @ts-ignore
  17. acc[key] = featInfo?.[key]?.default as unknown as undefined;
  18. return acc;
  19. }, {}) as FeatureConfig;
  20. /** Config data format migration dictionary */
  21. export const migrations: DataMigrationsDict = {
  22. // 1 -> 2 (<=v1.0)
  23. 2: (oldData: Record<string, unknown>) => {
  24. const queueBtnsEnabled = Boolean(oldData.queueButtons);
  25. delete oldData.queueButtons;
  26. return {
  27. ...oldData,
  28. deleteFromQueueButton: queueBtnsEnabled,
  29. lyricsQueueButton: queueBtnsEnabled,
  30. };
  31. },
  32. // 2 -> 3 (v1.0)
  33. 3: (oldData: FeatureConfig) => useDefaultConfig(oldData, [
  34. "removeShareTrackingParam", "numKeysSkipToTime",
  35. "fixSpacing", "scrollToActiveSongBtn", "logLevel",
  36. ]),
  37. // 3 -> 4 (v1.1)
  38. 4: (oldData: FeatureConfig) => {
  39. const oldSwitchSitesHotkey = oldData.switchSitesHotkey as Record<string, unknown>;
  40. return {
  41. ...useDefaultConfig(oldData, [
  42. "rememberSongTime", "rememberSongTimeSites",
  43. "volumeSliderScrollStep", "locale", "versionCheck",
  44. ]),
  45. arrowKeySkipBy: 10,
  46. switchSitesHotkey: {
  47. code: oldSwitchSitesHotkey.key ?? "F9",
  48. shift: Boolean(oldSwitchSitesHotkey.shift ?? false),
  49. ctrl: Boolean(oldSwitchSitesHotkey.ctrl ?? false),
  50. alt: Boolean(oldSwitchSitesHotkey.meta ?? false),
  51. },
  52. listButtonsPlacement: "queueOnly",
  53. };
  54. },
  55. // 4 -> 5 (v2.0)
  56. 5: (oldData: FeatureConfig) => useDefaultConfig(oldData, [
  57. "localeFallback", "geniUrlBase", "geniUrlToken",
  58. "lyricsCacheMaxSize", "lyricsCacheTTL",
  59. "clearLyricsCache", "advancedMode",
  60. "checkVersionNow", "advancedLyricsFilter",
  61. "rememberSongTimeDuration", "rememberSongTimeReduction",
  62. "rememberSongTimeMinPlayTime", "volumeSharedBetweenTabs",
  63. "setInitialTabVolume", "initialTabVolumeLevel",
  64. "thumbnailOverlayBehavior", "thumbnailOverlayToggleBtnShown",
  65. "thumbnailOverlayShowIndicator", "thumbnailOverlayIndicatorOpacity",
  66. "thumbnailOverlayImageFit", "removeShareTrackingParamSites",
  67. "fixHdrIssues", "clearQueueBtn",
  68. "closeToastsTimeout", "disableDarkReaderSites",
  69. ]),
  70. // 5 -> 6 (v2.1)
  71. 6: (oldData: FeatureConfig) => {
  72. const newData = useNewDefaultIfUnchanged(
  73. useDefaultConfig(oldData, [
  74. "autoLikeChannels", "autoLikeChannelToggleBtn",
  75. "autoLikeTimeout", "autoLikeShowToast",
  76. "autoLikeOpenMgmtDialog", "showVotes",
  77. "showVotesFormat", "toastDuration",
  78. "initTimeout",
  79. // forgot to add this to the migration when adding the feature way before so now will have to do:
  80. "volumeSliderLabel",
  81. ]), [
  82. { key: "rememberSongTimeSites", oldDefault: "ytm" },
  83. { key: "volumeSliderScrollStep", oldDefault: 10 },
  84. ]
  85. );
  86. "removeUpgradeTab" in newData && delete newData.removeUpgradeTab;
  87. "advancedLyricsFilter" in newData && delete newData.advancedLyricsFilter;
  88. return newData;
  89. },
  90. // TODO(v2.2): use default for "autoLikePlayerBarToggleBtn"
  91. // TODO(v2.2): set autoLikeChannels to true on migration once feature is fully implemented
  92. // 6 -> 7 (v2.2)
  93. 7: (oldData: FeatureConfig) => {
  94. const newData = useNewDefaultIfUnchanged(
  95. useDefaultConfig(oldData, [
  96. "showToastOnGenericError", "sponsorBlockIntegration",
  97. "themeSongIntegration", "themeSongLightness",
  98. "errorOnLyricsNotFound",
  99. ]), [
  100. { key: "toastDuration", oldDefault: 3 },
  101. ]
  102. );
  103. newData.arrowKeySkipBy = clamp(newData.arrowKeySkipBy, 0.5, 30);
  104. return newData;
  105. },
  106. } as const satisfies DataMigrationsDict;
  107. /** Uses the default config as the base, then overwrites all values with the passed {@linkcode baseData}, then sets all passed {@linkcode resetKeys} to their default values */
  108. function useDefaultConfig(baseData: Partial<FeatureConfig> | undefined, resetKeys: LooseUnion<keyof typeof featInfo>[]): FeatureConfig {
  109. const newData = { ...defaultData, ...(baseData ?? {}) };
  110. for(const key of resetKeys) // @ts-ignore
  111. newData[key] = featInfo?.[key]?.default as never; // typescript funny moments
  112. return newData;
  113. }
  114. /**
  115. * Uses {@linkcode oldData} as the base, then sets all keys provided in {@linkcode defaults} to their old default values, as long as their current value is equal to the provided old default.
  116. * This essentially means if someone has changed a feature's value from its old default value, that decision will be respected. Only if it has been left on its old default value, it will be reset to the new default value.
  117. * Returns a copy of the object.
  118. */
  119. function useNewDefaultIfUnchanged<TConfig extends Partial<FeatureConfig>>(
  120. oldData: TConfig,
  121. defaults: Array<{ key: FeatureKey, oldDefault: unknown }>,
  122. ) {
  123. const newData = { ...oldData };
  124. for(const { key, oldDefault } of defaults) {
  125. // @ts-ignore
  126. const defaultVal = featInfo?.[key]?.default as TConfig[typeof key];
  127. if(newData[key] === oldDefault)
  128. newData[key] = defaultVal as never; // we love TS
  129. }
  130. return newData as TConfig;
  131. }
  132. let canCompress = true;
  133. export const configStore = new DataStore({
  134. id: "bytm-config",
  135. formatVersion,
  136. defaultData,
  137. migrations,
  138. encodeData: (data) => canCompress ? compress(data, compressionFormat, "string") : data,
  139. decodeData: (data) => canCompress ? decompress(data, compressionFormat, "string") : data,
  140. });
  141. /** Initializes the DataStore instance and loads persistent data into memory. Returns a copy of the config object. */
  142. export async function initConfig() {
  143. canCompress = await compressionSupported();
  144. const oldFmtVer = Number(await GM.getValue(`_uucfgver-${configStore.id}`, NaN));
  145. let data = await configStore.loadData();
  146. // since the config changes so much in development keys need to be fixed in this special way
  147. if(mode === "development") {
  148. await configStore.setData(fixCfgKeys(data));
  149. data = configStore.getData();
  150. }
  151. log(`Initialized feature config DataStore with version ${configStore.formatVersion}`);
  152. if(isNaN(oldFmtVer))
  153. info(" !- Config data was initialized with default values");
  154. else if(oldFmtVer !== configStore.formatVersion) {
  155. try {
  156. await configStore.setData(data = fixCfgKeys(data));
  157. info(` !- Config data was migrated from version ${oldFmtVer} to ${configStore.formatVersion}`);
  158. }
  159. catch(err) {
  160. error(" !- Config data migration failed, falling back to default data:", err);
  161. await configStore.setData(data = configStore.defaultData);
  162. }
  163. }
  164. emitInterface("bytm:configReady");
  165. return { ...data };
  166. }
  167. /**
  168. * Fixes missing keys in the passed config object with their default values or removes extraneous keys and returns a copy of the fixed object.
  169. * Returns a copy of the originally passed object if nothing needs to be fixed.
  170. */
  171. export function fixCfgKeys(cfg: Partial<FeatureConfig>): FeatureConfig {
  172. const newCfg = { ...cfg };
  173. const passedKeys = Object.keys(cfg);
  174. const defaultKeys = Object.keys(defaultData);
  175. const missingKeys = defaultKeys.filter(k => !passedKeys.includes(k));
  176. if(missingKeys.length > 0) {
  177. for(const key of missingKeys)
  178. newCfg[key as keyof FeatureConfig] = defaultData[key as keyof FeatureConfig] as never;
  179. }
  180. const extraKeys = passedKeys.filter(k => !defaultKeys.includes(k));
  181. if(extraKeys.length > 0) {
  182. for(const key of extraKeys)
  183. delete newCfg[key as keyof FeatureConfig];
  184. }
  185. return newCfg as FeatureConfig;
  186. }
  187. /** Returns the current feature config from the in-memory cache as a copy */
  188. export function getFeatures(): FeatureConfig {
  189. return configStore.getData();
  190. }
  191. /** Returns the value of the feature with the given key from the in-memory cache, as a copy */
  192. export function getFeature<TKey extends FeatureKey>(key: TKey): FeatureConfig[TKey] {
  193. return configStore.getData()[key];
  194. }
  195. /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
  196. export function setFeatures(featureConf: FeatureConfig) {
  197. const res = configStore.setData(featureConf);
  198. emitSiteEvent("configChanged", configStore.getData());
  199. info("Saved new feature config:", featureConf);
  200. return res;
  201. }
  202. /** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */
  203. export function setDefaultFeatures() {
  204. const res = configStore.saveDefaultData();
  205. emitSiteEvent("configChanged", configStore.getData());
  206. info("Reset feature config to its default values");
  207. return res;
  208. }
  209. export async function promptResetConfig() {
  210. if(await showPrompt({ type: "confirm", message: t("reset_config_confirm") })) {
  211. closeCfgMenu();
  212. disableBeforeUnload();
  213. await setDefaultFeatures();
  214. if(location.pathname.startsWith("/watch")) {
  215. const videoTime = await getVideoTime(0);
  216. const url = new URL(location.href);
  217. url.searchParams.delete("t");
  218. if(videoTime)
  219. url.searchParams.set("time_continue", String(videoTime));
  220. location.replace(url.href);
  221. }
  222. else
  223. location.reload();
  224. }
  225. }
  226. /** Clears the feature config from the persistent storage - since the cache will be out of whack, this should only be run before a site re-/unload */
  227. export async function clearConfig() {
  228. await configStore.deleteData();
  229. info("Deleted config from persistent storage");
  230. }