Переглянути джерело

feat: more plugin registration stuff

Sv443 1 рік тому
батько
коміт
4c31c2a864
4 змінених файлів з 298 додано та 211 видалено
  1. 184 174
      src/index.ts
  2. 67 18
      src/interface.ts
  3. 12 0
      src/siteEvents.ts
  4. 35 19
      src/types.ts

+ 184 - 174
src/index.ts

@@ -1,10 +1,10 @@
 import { addGlobalStyle, compress, decompress, type Stringifiable } from "@sv443-network/userutils";
-import { initOnSelector } from "./utils";
+import { initOnSelector, warn } from "./utils";
 import { clearConfig, getFeatures, initConfig } from "./config";
 import { buildNumber, compressionFormat, defaultLogLevel, mode, scriptInfo } from "./constants";
 import { error, getDomain, info, getSessionId, log, setLogLevel, initTranslations, setLocale } from "./utils";
-import { initSiteEvents, siteEvents } from "./siteEvents";
-import { emitInterface, initInterface } from "./interface";
+import { initSiteEvents } from "./siteEvents";
+import { emitInterface, initInterface, initPlugins } from "./interface";
 import { addWelcomeMenu, showWelcomeMenu } from "./menu/welcomeMenu";
 import { initObservers, observers } from "./observers";
 import {
@@ -28,8 +28,7 @@ import {
   // menu
   addConfigMenuOption,
   // other
-  featInfo, initVersionCheck,
-  initLyricsCache,
+  initVersionCheck, initLyricsCache,
 } from "./features/index";
 
 {
@@ -85,6 +84,8 @@ async function init() {
     await initTranslations(features.locale ?? "en_US");
     setLocale(features.locale ?? "en_US");
 
+    emitInterface("bytm:initPlugins");
+
     if(features.disableBeforeUnloadPopup && domain === "ytm")
       disableBeforeUnload();
 
@@ -212,204 +213,213 @@ async function onDomLoad() {
       // }));
     }
 
-    Promise.allSettled(ftInit).then(() => {
-      emitInterface("bytm:ready");
-
-      try {
-        registerMenuCommands();
-      }
-      catch(e) {
-        void e;
-      }
-    });
-  }
-  catch(err) {
-    error("Feature error:", err);
-  }
-}
-
-void ["TODO(v1.2):", initFeatures];
-async function initFeatures() {
-  const ftInit = [] as Promise<void>[];
+    await Promise.allSettled(ftInit);
 
-  log(`DOM loaded. Initializing features for domain "${domain}"...`);
+    emitInterface("bytm:ready");
 
-  for(const [ftKey, ftInfo] of Object.entries(featInfo)) {
     try {
-      // @ts-ignore
-      const res = ftInfo?.enable?.() as undefined | Promise<void>;
-      if(res instanceof Promise)
-        ftInit.push(res);
-      else
-        ftInit.push(Promise.resolve());
+      initPlugins();
     }
     catch(err) {
-      error(`Couldn't initialize feature "${ftKey}" due to error:`, err);
+      error("Plugin loading error:", err);
     }
-  }
 
-  siteEvents.on("configOptionChanged", (ftKey, oldValue, newValue) => {
     try {
-      // @ts-ignore
-      if(featInfo[ftKey].change) {
-        // @ts-ignore
-        featInfo[ftKey].change(oldValue, newValue);
-      }
-      // @ts-ignore
-      else if(featInfo[ftKey].disable) {
-        // @ts-ignore
-        const disableRes = featInfo[ftKey].disable();
-        if(disableRes instanceof Promise) // @ts-ignore
-          disableRes.then(() => featInfo[ftKey]?.enable?.());
-        else // @ts-ignore
-          featInfo[ftKey]?.enable?.();
-      }
-      else {
-        // TODO: set "page reload required" flag in new menu
-        if(confirm("[Work in progress]\nYou changed an option that requires a page reload to be applied.\nReload the page now?")) {
-          disableBeforeUnload();
-          location.reload();
-        }
-      }
+      registerDevMenuCommands();
     }
-    catch(err) {
-      error(`Couldn't change feature "${ftKey}" due to error:`, err);
+    catch(e) {
+      warn("Couldn't register dev menu commands:", e);
     }
-  });
-
-  Promise.all(ftInit).then(() => {
-    emitInterface("bytm:ready");
-  });
+  }
+  catch(err) {
+    error("Feature error:", err);
+  }
 }
 
+// TODO(v1.2):
+// async function initFeatures() {
+//   const ftInit = [] as Promise<void>[];
+
+//   log(`DOM loaded. Initializing features for domain "${domain}"...`);
+
+//   for(const [ftKey, ftInfo] of Object.entries(featInfo)) {
+//     try {
+//       // @ts-ignore
+//       const res = ftInfo?.enable?.() as undefined | Promise<void>;
+//       if(res instanceof Promise)
+//         ftInit.push(res);
+//       else
+//         ftInit.push(Promise.resolve());
+//     }
+//     catch(err) {
+//       error(`Couldn't initialize feature "${ftKey}" due to error:`, err);
+//     }
+//   }
+
+//   siteEvents.on("configOptionChanged", (ftKey, oldValue, newValue) => {
+//     try {
+//       // @ts-ignore
+//       if(featInfo[ftKey].change) {
+//         // @ts-ignore
+//         featInfo[ftKey].change(oldValue, newValue);
+//       }
+//       // @ts-ignore
+//       else if(featInfo[ftKey].disable) {
+//         // @ts-ignore
+//         const disableRes = featInfo[ftKey].disable();
+//         if(disableRes instanceof Promise) // @ts-ignore
+//           disableRes.then(() => featInfo[ftKey]?.enable?.());
+//         else // @ts-ignore
+//           featInfo[ftKey]?.enable?.();
+//       }
+//       else {
+//         // TODO: set "page reload required" flag in new menu
+//         if(confirm("[Work in progress]\nYou changed an option that requires a page reload to be applied.\nReload the page now?")) {
+//           disableBeforeUnload();
+//           location.reload();
+//         }
+//       }
+//     }
+//     catch(err) {
+//       error(`Couldn't change feature "${ftKey}" due to error:`, err);
+//     }
+//   });
+
+//   Promise.all(ftInit).then(() => {
+//     emitInterface("bytm:ready");
+//   });
+// }
+
 /** Inserts the bundled CSS files imported throughout the script into a <style> element in the <head> */
 function insertGlobalStyle() {
   // post-build these double quotes are replaced by backticks (because if backticks are used here, the bundler converts them to double quotes)
   addGlobalStyle("#{{GLOBAL_STYLE}}").id = "bytm-style-global";
 }
 
-function registerMenuCommands() {
-  if(mode === "development") {
-    GM.registerMenuCommand("Reset config", async () => {
-      if(confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) {
-        await clearConfig();
-        disableBeforeUnload();
-        location.reload();
-      }
-    }, "r");
+/** Registers dev commands using `GM.registerMenuCommand` */
+function registerDevMenuCommands() {
+  if(mode !== "development")
+    return;
 
-    GM.registerMenuCommand("List GM values in console with decompression", async () => {
-      const keys = await GM.listValues();
-      console.log(`GM values (${keys.length}):`);
-      if(keys.length === 0)
-        console.log("  No values found.");
+  GM.registerMenuCommand("Reset config", async () => {
+    if(confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) {
+      await clearConfig();
+      disableBeforeUnload();
+      location.reload();
+    }
+  }, "r");
+
+  GM.registerMenuCommand("List GM values in console with decompression", async () => {
+    const keys = await GM.listValues();
+    console.log(`GM values (${keys.length}):`);
+    if(keys.length === 0)
+      console.log("  No values found.");
+
+    const values = {} as Record<string, Stringifiable | undefined>;
+    let longestKey = 0;
+
+    for(const key of keys) {
+      const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
+      const val = await GM.getValue(key, undefined);
+      values[key] = typeof val !== "undefined" && isEncoded ? await decompress(val, compressionFormat, "string") : val;
+      longestKey = Math.max(longestKey, key.length);
+    }
+    for(const [key, finalVal] of Object.entries(values)) {
+      const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
+      const lengthStr = String(finalVal).length > 50 ? `(${String(finalVal).length} chars) ` : "";
+      console.log(`  "${key}"${" ".repeat(longestKey - key.length)} -${isEncoded ? "-[decoded]-" : ""}> ${lengthStr}${finalVal}`);
+    }
+  }, "l");
 
-      const values = {} as Record<string, Stringifiable | undefined>;
-      let longestKey = 0;
+  GM.registerMenuCommand("List GM values in console, without decompression", async () => {
+    const keys = await GM.listValues();
+    console.log(`GM values (${keys.length}):`);
+    if(keys.length === 0)
+      console.log("  No values found.");
 
-      for(const key of keys) {
-        const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
-        const val = await GM.getValue(key, undefined);
-        values[key] = typeof val !== "undefined" && isEncoded ? await decompress(val, compressionFormat, "string") : val;
-        longestKey = Math.max(longestKey, key.length);
-      }
-      for(const [key, finalVal] of Object.entries(values)) {
-        const isEncoded = key.startsWith("_uucfg-") ? await GM.getValue(`_uucfgenc-${key.substring(7)}`, false) : false;
-        const lengthStr = String(finalVal).length > 50 ? `(${String(finalVal).length} chars) ` : "";
-        console.log(`  "${key}"${" ".repeat(longestKey - key.length)} -${isEncoded ? "-[decoded]-" : ""}> ${lengthStr}${finalVal}`);
-      }
-    }, "l");
+    const values = {} as Record<string, Stringifiable | undefined>;
+    let longestKey = 0;
+
+    for(const key of keys) {
+      const val = await GM.getValue(key, undefined);
+      values[key] = val;
+      longestKey = Math.max(longestKey, key.length);
+    }
+    for(const [key, val] of Object.entries(values)) {
+      const lengthStr = String(val).length >= 16 ? `(${String(val).length} chars) ` : "";
+      console.log(`  "${key}"${" ".repeat(longestKey - key.length)} -> ${lengthStr}${val}`);
+    }
+  });
 
-    GM.registerMenuCommand("List GM values in console, without decompression", async () => {
-      const keys = await GM.listValues();
-      console.log(`GM values (${keys.length}):`);
+  GM.registerMenuCommand("Delete all GM values", async () => {
+    const keys = await GM.listValues();
+    if(confirm(`Clear all ${keys.length} GM values?\nSee console for details.`)) {
+      console.log(`Clearing ${keys.length} GM values:`);
       if(keys.length === 0)
         console.log("  No values found.");
-
-      const values = {} as Record<string, Stringifiable | undefined>;
-      let longestKey = 0;
-
       for(const key of keys) {
-        const val = await GM.getValue(key, undefined);
-        values[key] = val;
-        longestKey = Math.max(longestKey, key.length);
-      }
-      for(const [key, val] of Object.entries(values)) {
-        const lengthStr = String(val).length >= 16 ? `(${String(val).length} chars) ` : "";
-        console.log(`  "${key}"${" ".repeat(longestKey - key.length)} -> ${lengthStr}${val}`);
+        await GM.deleteValue(key);
+        console.log(`  Deleted ${key}`);
       }
-    });
-
-    GM.registerMenuCommand("Delete all GM values", async () => {
-      const keys = await GM.listValues();
-      if(confirm(`Clear all ${keys.length} GM values?\nSee console for details.`)) {
-        console.log(`Clearing ${keys.length} GM values:`);
-        if(keys.length === 0)
-          console.log("  No values found.");
-        for(const key of keys) {
-          await GM.deleteValue(key);
-          console.log(`  Deleted ${key}`);
-        }
-      }
-    }, "d");
-
-    GM.registerMenuCommand("Delete GM values by name (comma separated)", async () => {
-      const keys = prompt("Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation.");
-      if(!keys)
-        return;
-      for(const key of keys?.split(",") ?? []) {
-        if(key && key.length > 0) {
-          const truncLength = 400;
-          const oldVal = await GM.getValue(key);
-          await GM.deleteValue(key);
-          console.log(`Deleted GM value '${key}' with previous value '${oldVal && String(oldVal).length > truncLength ? String(oldVal).substring(0, truncLength) + `… (${String(oldVal).length} / ${truncLength} chars.)` : oldVal}'`);
-        }
+    }
+  }, "d");
+
+  GM.registerMenuCommand("Delete GM values by name (comma separated)", async () => {
+    const keys = prompt("Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation.");
+    if(!keys)
+      return;
+    for(const key of keys?.split(",") ?? []) {
+      if(key && key.length > 0) {
+        const truncLength = 400;
+        const oldVal = await GM.getValue(key);
+        await GM.deleteValue(key);
+        console.log(`Deleted GM value '${key}' with previous value '${oldVal && String(oldVal).length > truncLength ? String(oldVal).substring(0, truncLength) + `… (${String(oldVal).length} / ${truncLength} chars.)` : oldVal}'`);
       }
-    }, "n");
-
-    GM.registerMenuCommand("Reset install timestamp", async () => {
-      await GM.deleteValue("bytm-installed");
-      console.log("Reset install time.");
-    }, "t");
-
-    GM.registerMenuCommand("Reset version check timestamp", async () => {
-      await GM.deleteValue("bytm-version-check");
-      console.log("Reset version check time.");
-    }, "v");
-
-    GM.registerMenuCommand("List active selector listeners in console", async () => {
-      const lines = [] as string[];
-      let listenersAmt = 0;
-      for(const [obsName, obs] of Object.entries(observers)) {
-        const listeners = obs.getAllListeners();
-        lines.push(`- "${obsName}" (${listeners.size} listeners):`);
-        [...listeners].forEach(([k, v]) => {
-          listenersAmt += v.length;
-          lines.push(`    [${v.length}] ${k}`);
-          v.forEach(({ all, continuous }, i) => {
-            lines.push(`        ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}, ${all ? "select multiple" : "select single"}`);
-          });
+    }
+  }, "n");
+
+  GM.registerMenuCommand("Reset install timestamp", async () => {
+    await GM.deleteValue("bytm-installed");
+    console.log("Reset install time.");
+  }, "t");
+
+  GM.registerMenuCommand("Reset version check timestamp", async () => {
+    await GM.deleteValue("bytm-version-check");
+    console.log("Reset version check time.");
+  }, "v");
+
+  GM.registerMenuCommand("List active selector listeners in console", async () => {
+    const lines = [] as string[];
+    let listenersAmt = 0;
+    for(const [obsName, obs] of Object.entries(observers)) {
+      const listeners = obs.getAllListeners();
+      lines.push(`- "${obsName}" (${listeners.size} listeners):`);
+      [...listeners].forEach(([k, v]) => {
+        listenersAmt += v.length;
+        lines.push(`    [${v.length}] ${k}`);
+        v.forEach(({ all, continuous }, i) => {
+          lines.push(`        ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}, ${all ? "select multiple" : "select single"}`);
         });
-      }
-      console.log(`Showing currently active listeners for ${Object.keys(observers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
-    }, "s");
-
-    GM.registerMenuCommand("Compress value", async () => {
-      const input = prompt("Enter the value to compress.\nSee console for output.");
-      if(input && input.length > 0) {
-        const compressed = await compress(input, compressionFormat);
-        console.log(`Compression result (${input.length} chars -> ${compressed.length} chars)\nValue: ${compressed}`);
-      }
-    });
+      });
+    }
+    console.log(`Showing currently active listeners for ${Object.keys(observers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
+  }, "s");
+
+  GM.registerMenuCommand("Compress value", async () => {
+    const input = prompt("Enter the value to compress.\nSee console for output.");
+    if(input && input.length > 0) {
+      const compressed = await compress(input, compressionFormat);
+      console.log(`Compression result (${input.length} chars -> ${compressed.length} chars)\nValue: ${compressed}`);
+    }
+  });
 
-    GM.registerMenuCommand("Decompress value", async () => {
-      const input = prompt("Enter the value to decompress.\nSee console for output.");
-      if(input && input.length > 0) {
-        const decompressed = await decompress(input, compressionFormat);
-        console.log(`Decompresion result (${input.length} chars -> ${decompressed.length} chars)\nValue: ${decompressed}`);
-      }
-    });
-  }
+  GM.registerMenuCommand("Decompress value", async () => {
+    const input = prompt("Enter the value to decompress.\nSee console for output.");
+    if(input && input.length > 0) {
+      const decompressed = await decompress(input, compressionFormat);
+      console.log(`Decompresion result (${input.length} chars -> ${decompressed.length} chars)\nValue: ${decompressed}`);
+    }
+  });
 }
 
 preInit();

+ 67 - 18
src/interface.ts

@@ -4,9 +4,10 @@ import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale,
 import { addSelectorListener } from "./observers";
 import { getFeatures, saveFeatures } from "./config";
 import { featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong, type LyricsCache } from "./features";
-import type { SiteEventsMap } from "./siteEvents";
-import { LogLevel, type FeatureConfig, type FeatureInfo, type LyricsCacheEntry, type PluginDef, type PluginInfo, type PluginRegisterResult, type PluginDefResolvable } from "./types";
+import { allSiteEvents, siteEvents, type SiteEventsMap } from "./siteEvents";
+import { LogLevel, type FeatureConfig, type FeatureInfo, type LyricsCacheEntry, type PluginDef, type PluginInfo, type PluginRegisterResult, type PluginDefResolvable, type PluginEventMap, type PluginItem } from "./types";
 import { BytmDialog, createHotkeyInput, createToggleInput } from "./components";
+import { createNanoEvents } from "nanoevents";
 
 const { getUnsafeWindow } = UserUtils;
 
@@ -71,7 +72,11 @@ const globalFuncs = {
   sanitizeSong,
 };
 
-const plugins = new Map<string, PluginDef>();
+/** Plugins that are queued up for registration */
+const pluginQueue = new Map<string, PluginItem>();
+
+/** Registered plugins including their event listener instance */
+const pluginMap = new Map<string, PluginItem>();
 
 /** Initializes the BYTM interface */
 export function initInterface() {
@@ -126,18 +131,28 @@ export function emitInterface<
 
 //#MARKER register plugins
 
-export function loadPlugins() {
-  // TODO:
-  // load plugins between bytm:initPlugins and bytm:ready
-  // emit bytm:pluginsLoaded after all plugins loaded
+/** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */
+export function initPlugins() {
+  // TODO(v1.3): check perms and ask user for initial activation
+
+  for(const [key, { def, events }] of pluginQueue) {
+    pluginMap.set(key, { def, events });
+    pluginQueue.delete(key);
+    emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)!);
+  }
+
+  for(const evt of allSiteEvents) // @ts-ignore
+    siteEvents.on(evt, (...args) => emitOnPlugins(evt, () => true, ...args));
+
+  emitInterface("bytm:pluginsLoaded");
 }
 
 /** Returns the key for a given plugin definition */
-function getPluginKey(plugin: PluginDef | PluginDefResolvable) {
+function getPluginKey(plugin: PluginDefResolvable) {
   return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
 }
 
-/** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) */
+/** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */
 function pluginDefToInfo(plugin?: PluginDef): PluginInfo | undefined {
   return plugin && {
     name: plugin.plugin.name,
@@ -146,15 +161,43 @@ function pluginDefToInfo(plugin?: PluginDef): PluginInfo | undefined {
   };
 }
 
+/** Checks whether two plugin definitions are the same */
+function sameDef(def1: PluginDefResolvable, def2: PluginDefResolvable) {
+  return getPluginKey(def1) === getPluginKey(def2);
+}
+
+/** Emits an event on all plugins that match the predicate (all plugins by default) */
+export function emitOnPlugins<TEvtKey extends keyof PluginEventMap>(
+  event: TEvtKey,
+  predicate: (def: PluginDef) => boolean = () => true,
+  ...data: Parameters<PluginEventMap[TEvtKey]>
+) {
+  for(const { def, events } of pluginMap.values())
+    predicate(def) && events.emit(event, ...data);
+}
+
+/** Returns the internal plugin object by its name and namespace, or undefined if it doesn't exist */
+export function getPlugin(name: string, namespace: string): PluginItem | undefined
+/** Returns the internal plugin object by its definition, or undefined if it doesn't exist */
+export function getPlugin(plugin: PluginDefResolvable): PluginItem | undefined
+/** Returns the internal plugin object, or undefined if it doesn't exist */
+export function getPlugin(...args: [pluginDefOrName: PluginDefResolvable | string, namespace?: string]): PluginItem | undefined {
+  return args.length === 2
+    ? pluginMap.get(`${args[1]}/${args[0]}`)
+    : pluginMap.get(getPluginKey(args[0] as PluginDefResolvable));
+}
+
 /** Returns info about a registered plugin on the BYTM interface by its name and namespace properties, or undefined if the plugin isn't registered */
 export function getPluginInfo(name: string, namespace: string): PluginInfo | undefined
 /** Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered */
 export function getPluginInfo(plugin: PluginDefResolvable): PluginInfo | undefined
 /** Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered */
 export function getPluginInfo(...args: [pluginDefOrName: PluginDefResolvable | string, namespace?: string]): PluginInfo | undefined {
-  return args.length === 2
-    ? pluginDefToInfo(plugins.get(`${args[1]}/${args[0]}`))
-    : pluginDefToInfo(plugins.get(getPluginKey(args[0] as PluginDefResolvable)));
+  return pluginDefToInfo(
+    args.length === 2
+      ? pluginMap.get(`${args[1]}/${args[0]}`)?.def
+      : pluginMap.get(getPluginKey(args[0] as PluginDefResolvable))?.def
+  );
 }
 
 /** Validates the passed PluginDef object and returns an array of errors */
@@ -175,20 +218,26 @@ function validatePluginDef(pluginDef: Partial<PluginDef>) {
 }
 
 /** Registers a plugin on the BYTM interface */
-export function registerPlugin(pluginDef: PluginDef): PluginRegisterResult {
-  const validationErrors = validatePluginDef(pluginDef);
+export function registerPlugin(def: PluginDef): PluginRegisterResult {
+  const validationErrors = validatePluginDef(def);
   if(validationErrors) {
-    error(`Failed to register plugin${pluginDef?.plugin?.name ? ` '${pluginDef?.plugin?.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`, LogLevel.Info);
+    error(`Failed to register plugin${def?.plugin?.name ? ` '${def?.plugin?.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`, LogLevel.Info);
     throw new Error(`Invalid plugin definition:\n- ${validationErrors.join("\n- ")}`);
   }
 
-  const { plugin: { name } } = pluginDef;
-  plugins.set(getPluginKey(pluginDef), pluginDef);
+  const events = createNanoEvents<PluginEventMap>();
+
+  const { plugin: { name } } = def;
+  pluginQueue.set(getPluginKey(def), {
+    def: def,
+    events,
+  });
 
   info(`Registered plugin: ${name}`, LogLevel.Info);
 
   return {
-    info: getPluginInfo(pluginDef)!,
+    info: getPluginInfo(def)!,
+    events,
   };
 }
 

+ 12 - 0
src/siteEvents.ts

@@ -26,6 +26,18 @@ export interface SiteEventsMap {
   autoplayQueueChanged: (queueElement: HTMLElement) => void;
 }
 
+/** Array of all site events */
+export const allSiteEvents = [
+  "configChanged",
+  "configOptionChanged",
+  "rebuildCfgMenu",
+  "cfgMenuClosed",
+  "welcomeMenuClosed",
+  "hotkeyInputActive",
+  "queueChanged",
+  "autoplayQueueChanged",
+] as const;
+
 /** EventEmitter instance that is used to detect changes to the site */
 export const siteEvents = createNanoEvents<SiteEventsMap>();
 

+ 35 - 19
src/types.ts

@@ -1,3 +1,4 @@
+import type { Emitter } from "nanoevents";
 import type * as consts from "./constants";
 import type { scriptInfo } from "./constants";
 import type { addSelectorListener } from "./observers";
@@ -5,6 +6,7 @@ import type resources from "../assets/resources.json";
 import type locales from "../assets/locales.json";
 import type { getResourceUrl, getSessionId, getVideoTime, TrLocale, t, tp } from "./utils";
 import type { getFeatures, saveFeatures } from "./config";
+import type { SiteEventsMap } from "./siteEvents";
 
 /** Custom CLI args passed to rollup */
 export type RollupArgs = Partial<{
@@ -68,32 +70,30 @@ export type LyricsCacheEntry = {
 export type PluginRegisterResult = {
   /** Public info about the registered plugin */
   info: PluginInfo;
+  /** Emitter for plugin events - see {@linkcode PluginEventMap} for a list of events */
+  events: Emitter<PluginEventMap>;
 }
 
-/** Object that describes a plugin out of the restricted perspective of another plugin */
-export type PluginInfo =
-  Pick<PluginDef["plugin"],
-    | "name"
-    | "namespace"
-    | "version"
-  >;
+/** Minimal object that describes a plugin - this is all info the other installed plugins can see */
+export type PluginInfo = {
+  /** Name of the plugin */
+  name: string;
+  /**
+   * Adding the namespace and the name property makes the unique identifier for a plugin.  
+   * If one exists with the same name and namespace as this plugin, it may be overwritten at registration.  
+   * I recommend to set this value to a URL pointing to your homepage, or the author's username.
+   */
+  namespace: string;
+  /** Version of the plugin as an array containing three whole numbers: `[major_version, minor_version, patch_version]` */
+  version: [major: number, minor: number, patch: number];
+};
 
 /** Minimum part of the PluginDef object needed to make up the resolvable plugin identifier */
-export type PluginDefResolvable = { plugin: Pick<PluginDef["plugin"], "name" | "namespace"> };
+export type PluginDefResolvable = PluginDef | { plugin: Pick<PluginDef["plugin"], "name" | "namespace"> };
 
 /** An object that describes a BYTM plugin */
 export type PluginDef = {
-  plugin: {
-    /** Name of the plugin */
-    name: string;
-    /**
-     * Adding the namespace and the name property makes the unique identifier for a plugin.  
-     * If one exists with the same name and namespace as this plugin, it may be overwritten at registration.  
-     * I recommend to set this value to a URL pointing to your homepage, or the author's username.
-     */
-    namespace: string;
-    /** Version of the plugin as an array containing three whole numbers: `[major_version, minor_version, patch_version]` */
-    version: [major: number, minor: number, patch: number];
+  plugin: PluginInfo & {
     /**
      * Descriptions of at least en_US and optionally any other locale supported by BYTM.  
      * When an untranslated locale is set, the description will default to the value of en_US
@@ -126,6 +126,22 @@ export type PluginDef = {
   }>;
 };
 
+/** All events that are dispatched to plugins individually */
+export type PluginEventMap =
+  & {
+
+    /** Called when the plugin is registered on BYTM's side */
+    pluginRegistered: (info: PluginInfo) => void;
+  }
+  & SiteEventsMap;
+
+/** A plugin in either the queue or registered map */
+export type PluginItem = 
+  & {
+    def: PluginDef;
+  }
+  & Pick<PluginRegisterResult, "events">;
+
 /** All functions exposed by the interface on the global `BYTM` object */
 export type InterfaceFunctions = {
   /** Adds a listener to one of the already present SelectorObserver instances */