interface.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import * as UserUtils from "@sv443-network/userutils";
  2. import { createNanoEvents } from "nanoevents";
  3. import { mode, branch, host, buildNumber, compressionFormat, scriptInfo } from "./constants";
  4. import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, NanoEmitter, t, tp, type TrLocale, info, error, onInteraction } from "./utils";
  5. import { addSelectorListener } from "./observers";
  6. import { getFeatures, setFeatures } from "./config";
  7. import { compareVersionArrays, compareVersions, featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong, type LyricsCache } from "./features";
  8. import { allSiteEvents, siteEvents, type SiteEventsMap } from "./siteEvents";
  9. import { LogLevel, type FeatureConfig, type FeatureInfo, type LyricsCacheEntry, type PluginDef, type PluginInfo, type PluginRegisterResult, type PluginDefResolvable, type PluginEventMap, type PluginItem, type BytmObject } from "./types";
  10. import { BytmDialog, createCircularBtn, createHotkeyInput, createToggleInput } from "./components";
  11. const { getUnsafeWindow } = UserUtils;
  12. //#region interface globals
  13. /** All events that can be emitted on the BYTM interface and the data they provide */
  14. export type InterfaceEvents = {
  15. /** Emitted whenever the plugins should be registered using `unsafeWindow.BYTM.registerPlugin()` */
  16. "bytm:initPlugins": undefined;
  17. /** Emitted whenever all plugins have been loaded */
  18. "bytm:pluginsLoaded": undefined;
  19. /** Emitted when BYTM has finished initializing all features */
  20. "bytm:ready": undefined;
  21. /** Emitted when a fatal error occurs and the script can't continue to run. Returns a short error description (not really meant to be displayed to the user). */
  22. "bytm:fatalError": string;
  23. /**
  24. * Emitted whenever the SelectorObserver instances have been initialized
  25. * Use `unsafeWindow.BYTM.addObserverListener()` to add custom listener functions to the observers
  26. */
  27. "bytm:observersReady": undefined;
  28. /** Emitted as soon as the feature config has been loaded */
  29. "bytm:configReady": FeatureConfig;
  30. /** Emitted whenever the locale is changed */
  31. "bytm:setLocale": { locale: TrLocale };
  32. /** Emitted when a dialog was opened - returns the dialog's instance */
  33. "bytm:dialogOpened": BytmDialog;
  34. /** Emitted when the dialog with the specified ID was opened - returns the dialog's instance - in TS, use `"..." as "bytm:dialogOpened:id"` to make the error go away */
  35. "bytm:dialogOpened:id": BytmDialog;
  36. /** Emitted whenever the lyrics URL for a song is loaded */
  37. "bytm:lyricsLoaded": { type: "current" | "queue", artists: string, title: string, url: string };
  38. /** Emitted when the lyrics cache has been loaded */
  39. "bytm:lyricsCacheReady": LyricsCache;
  40. /** Emitted when the lyrics cache has been cleared */
  41. "bytm:lyricsCacheCleared": undefined;
  42. /** Emitted when an entry is added to the lyrics cache - "penalized" entries get removed from cache faster because they were less related in lyrics lookups, opposite to the "best" entries */
  43. "bytm:lyricsCacheEntryAdded": { type: "best" | "penalized", entry: LyricsCacheEntry };
  44. // additionally all events from SiteEventsMap in `src/siteEvents.ts`
  45. // are emitted in this format: "bytm:siteEvent:nameOfSiteEvent"
  46. };
  47. /** All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`) */
  48. const globalFuncs = {
  49. // meta
  50. registerPlugin,
  51. getPluginInfo,
  52. // utils
  53. addSelectorListener,
  54. getResourceUrl,
  55. getSessionId,
  56. getVideoTime,
  57. setLocale,
  58. getLocale,
  59. hasKey,
  60. hasKeyFor,
  61. t,
  62. tp,
  63. getFeatures: getFeaturesInterface,
  64. saveFeatures: setFeatures,
  65. fetchLyricsUrlTop,
  66. getLyricsCacheEntry,
  67. sanitizeArtists,
  68. sanitizeSong,
  69. compareVersions,
  70. compareVersionArrays,
  71. onInteraction,
  72. };
  73. /** Initializes the BYTM interface */
  74. export function initInterface() {
  75. const props = {
  76. mode,
  77. branch,
  78. host,
  79. buildNumber,
  80. compressionFormat,
  81. ...scriptInfo,
  82. ...globalFuncs,
  83. UserUtils,
  84. NanoEmitter,
  85. BytmDialog,
  86. createHotkeyInput,
  87. createToggleInput,
  88. createCircularBtn,
  89. };
  90. for(const [key, value] of Object.entries(props))
  91. setGlobalProp(key, value);
  92. log("Initialized BYTM interface");
  93. }
  94. /** Sets a global property on the unsafeWindow.BYTM object */
  95. export function setGlobalProp<
  96. TKey extends keyof Window["BYTM"],
  97. TValue = Window["BYTM"][TKey],
  98. > (
  99. key: TKey | (string & {}),
  100. value: TValue,
  101. ) {
  102. // use unsafeWindow so the properties are available to plugins outside of the userscript's scope
  103. const win = getUnsafeWindow();
  104. if(typeof win.BYTM !== "object")
  105. win.BYTM = {} as BytmObject;
  106. win.BYTM[key] = value;
  107. }
  108. /** Emits an event on the BYTM interface */
  109. export function emitInterface<
  110. TEvt extends keyof InterfaceEvents,
  111. TDetail extends InterfaceEvents[TEvt],
  112. >(
  113. type: TEvt | `bytm:siteEvent:${keyof SiteEventsMap}`,
  114. ...data: (TDetail extends undefined ? [undefined?] : [TDetail])
  115. ) {
  116. getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: data[0] }));
  117. }
  118. //#region register plugins
  119. /** Plugins that are queued up for registration */
  120. const pluginQueue = new Map<string, PluginItem>();
  121. /** Registered plugins including their event listener instance */
  122. const pluginMap = new Map<string, PluginItem>();
  123. /** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */
  124. export function initPlugins() {
  125. // TODO(v1.3): check perms and ask user for initial activation
  126. for(const [key, { def, events }] of pluginQueue) {
  127. try {
  128. pluginMap.set(key, { def, events });
  129. pluginQueue.delete(key);
  130. emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)!);
  131. }
  132. catch(err) {
  133. error(`Failed to initialize plugin '${getPluginKey(def)}':`, err);
  134. }
  135. }
  136. for(const evt of allSiteEvents) // @ts-ignore
  137. siteEvents.on(evt, (...args) => emitOnPlugins(evt, () => true, ...args));
  138. emitInterface("bytm:pluginsLoaded");
  139. }
  140. /** Returns the key for a given plugin definition */
  141. function getPluginKey(plugin: PluginDefResolvable) {
  142. return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
  143. }
  144. /** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */
  145. function pluginDefToInfo(plugin?: PluginDef): PluginInfo | undefined {
  146. return plugin && {
  147. name: plugin.plugin.name,
  148. namespace: plugin.plugin.namespace,
  149. version: plugin.plugin.version,
  150. };
  151. }
  152. /** Checks whether two plugins are the same, given their resolvable definition objects */
  153. function sameDef(def1: PluginDefResolvable, def2: PluginDefResolvable) {
  154. return getPluginKey(def1) === getPluginKey(def2);
  155. }
  156. /** Emits an event on all plugins that match the predicate (all plugins by default) */
  157. export function emitOnPlugins<TEvtKey extends keyof PluginEventMap>(
  158. event: TEvtKey,
  159. predicate: ((def: PluginDef) => boolean) | boolean = true,
  160. ...data: Parameters<PluginEventMap[TEvtKey]>
  161. ) {
  162. for(const { def, events } of pluginMap.values())
  163. if(typeof predicate === "boolean" ? predicate : predicate(def))
  164. events.emit(event, ...data);
  165. }
  166. /**
  167. * @private FOR INTERNAL USE ONLY!
  168. * Returns the internal plugin object by its name and namespace, or undefined if it doesn't exist
  169. */
  170. export function getPlugin(name: string, namespace: string): PluginItem | undefined
  171. /**
  172. * @private FOR INTERNAL USE ONLY!
  173. * Returns the internal plugin object by a resolvable definition object, or undefined if it doesn't exist
  174. */
  175. export function getPlugin(plugin: PluginDefResolvable): PluginItem | undefined
  176. /**
  177. * @private FOR INTERNAL USE ONLY!
  178. * Returns the internal plugin object, or undefined if it doesn't exist
  179. */
  180. export function getPlugin(...args: [pluginDefOrName: PluginDefResolvable | string, namespace?: string]): PluginItem | undefined {
  181. return args.length === 2
  182. ? pluginMap.get(`${args[1]}/${args[0]}`)
  183. : pluginMap.get(getPluginKey(args[0] as PluginDefResolvable));
  184. }
  185. /**
  186. * Returns info about a registered plugin on the BYTM interface by its name and namespace properties, or undefined if the plugin isn't registered.
  187. * @public Intended for general use in plugins.
  188. */
  189. export function getPluginInfo(name: string, namespace: string): PluginInfo | undefined
  190. /**
  191. * Returns info about a registered plugin on the BYTM interface by a resolvable definition object, or undefined if the plugin isn't registered.
  192. * @public Intended for general use in plugins.
  193. */
  194. export function getPluginInfo(plugin: PluginDefResolvable): PluginInfo | undefined
  195. /**
  196. * Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered.
  197. * @public Intended for general use in plugins.
  198. */
  199. export function getPluginInfo(...args: [pluginDefOrName: PluginDefResolvable | string, namespace?: string]): PluginInfo | undefined {
  200. return pluginDefToInfo(
  201. args.length === 2
  202. ? pluginMap.get(`${args[1]}/${args[0]}`)?.def
  203. : pluginMap.get(getPluginKey(args[0] as PluginDefResolvable))?.def
  204. );
  205. }
  206. /** Validates the passed PluginDef object and returns an array of errors - returns undefined if there were no errors - never returns an empty array */
  207. function validatePluginDef(pluginDef: Partial<PluginDef>) {
  208. const errors = [] as string[];
  209. const addNoPropErr = (prop: string, type: string) =>
  210. errors.push(t("plugin_validation_error_no_property", prop, type));
  211. // def.plugin and its properties:
  212. typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object");
  213. const { plugin } = pluginDef;
  214. !plugin?.name && addNoPropErr("plugin.name", "string");
  215. !plugin?.namespace && addNoPropErr("plugin.namespace", "string");
  216. !plugin?.version && addNoPropErr("plugin.version", "[major: number, minor: number, patch: number]");
  217. return errors.length > 0 ? errors : undefined;
  218. }
  219. /** Registers a plugin on the BYTM interface */
  220. export function registerPlugin(def: PluginDef): PluginRegisterResult {
  221. const validationErrors = validatePluginDef(def);
  222. if(validationErrors) {
  223. error(`Failed to register plugin${def?.plugin?.name ? ` '${def?.plugin?.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`, LogLevel.Info);
  224. throw new Error(`Invalid plugin definition:\n- ${validationErrors.join("\n- ")}`);
  225. }
  226. const events = createNanoEvents<PluginEventMap>();
  227. const { plugin: { name } } = def;
  228. pluginQueue.set(getPluginKey(def), {
  229. def: def,
  230. events,
  231. });
  232. info(`Registered plugin: ${name}`, LogLevel.Info);
  233. return {
  234. info: getPluginInfo(def)!,
  235. events,
  236. };
  237. }
  238. //#region proxy funcs
  239. /** Returns the current feature config, with sensitive values replaced by `undefined` */
  240. export function getFeaturesInterface() {
  241. const features = getFeatures();
  242. for(const ftKey of Object.keys(features)) {
  243. const info = featInfo[ftKey as keyof typeof featInfo] as FeatureInfo[keyof FeatureInfo];
  244. if(info && info.valueHidden) // @ts-ignore
  245. features[ftKey as keyof typeof features] = undefined;
  246. }
  247. return features as FeatureConfig;
  248. }