config.ts 8.7 KB

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