Procházet zdrojové kódy

feat: plugin registration stuff

Sv443 před 1 rokem
rodič
revize
44fa2344a1

+ 3 - 1
assets/README.md

@@ -31,7 +31,9 @@ The keys of the object are the locale codes, and the values are the locale objec
 <br>
 <br>
 
 
 ### [`plugins.json`](plugins.json)
 ### [`plugins.json`](plugins.json)
-(Not implemented yet)
+(Not fully implemented yet, but should still be filled out when a plugin is added)  
+  
+For the structure of this array of objects, see `type PluginObj` in [`src/types.ts`](../src/types.ts)
 
 
 <br>
 <br>
 
 

+ 23 - 16
assets/translations/README.md

@@ -20,15 +20,15 @@ To submit or edit a translation, please follow [this guide](../../contributing.m
 ### Translation progress:
 ### Translation progress:
 | &nbsp; | Locale | Translated keys | Based on |
 | &nbsp; | Locale | Translated keys | Based on |
 | :----: | ------ | --------------- | :------: |
 | :----: | ------ | --------------- | :------: |
-| ─ | [`en_US`](./en_US.json) | 180 (default locale) |  |
-| ‼️ | [`de_DE`](./de_DE.json) | `162/180` (90%) | ─ |
-| ─ | [`en_UK`](./en_UK.json) | `180/180` (100%) | `en_US` |
-| ‼️ | [`es_ES`](./es_ES.json) | `162/180` (90%) | ─ |
-| ‼️ | [`fr_FR`](./fr_FR.json) | `162/180` (90%) | ─ |
-| ‼️ | [`hi_IN`](./hi_IN.json) | `162/180` (90%) | ─ |
-| ‼️ | [`ja_JA`](./ja_JA.json) | `162/180` (90%) | ─ |
-| ‼️ | [`pt_BR`](./pt_BR.json) | `162/180` (90%) | ─ |
-| ‼️ | [`zh_CN`](./zh_CN.json) | `162/180` (90%) | ─ |
+| ─ | [`en_US`](./en_US.json) | 181 (default locale) |  |
+| ‼️ | [`de_DE`](./de_DE.json) | `162/181` (89.5%) | ─ |
+| ─ | [`en_UK`](./en_UK.json) | `181/181` (100%) | `en_US` |
+| ‼️ | [`es_ES`](./es_ES.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`fr_FR`](./fr_FR.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`hi_IN`](./hi_IN.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`ja_JA`](./ja_JA.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`pt_BR`](./pt_BR.json) | `162/181` (89.5%) | ─ |
+| ‼️ | [`zh_CN`](./zh_CN.json) | `162/181` (89.5%) | ─ |
 
 
 <sub>
 <sub>
 ✅ - Fully translated
 ✅ - Fully translated
@@ -49,7 +49,7 @@ This means to figure out which keys are untranslated, you will need to manually
 
 
 ### Missing keys:
 ### Missing keys:
 
 
-<details><summary><code>de_DE</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>de_DE</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 
 | Key | English text |
 | Key | English text |
 | --- | ------------ |
 | --- | ------------ |
@@ -71,10 +71,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 
 <br></details>
 <br></details>
 
 
-<details><summary><code>es_ES</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>es_ES</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 
 | Key | English text |
 | Key | English text |
 | --- | ------------ |
 | --- | ------------ |
@@ -96,10 +97,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 
 <br></details>
 <br></details>
 
 
-<details><summary><code>fr_FR</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>fr_FR</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 
 | Key | English text |
 | Key | English text |
 | --- | ------------ |
 | --- | ------------ |
@@ -121,10 +123,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 
 <br></details>
 <br></details>
 
 
-<details><summary><code>hi_IN</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>hi_IN</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 
 | Key | English text |
 | Key | English text |
 | --- | ------------ |
 | --- | ------------ |
@@ -146,10 +149,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 
 <br></details>
 <br></details>
 
 
-<details><summary><code>ja_JA</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>ja_JA</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 
 | Key | English text |
 | Key | English text |
 | --- | ------------ |
 | --- | ------------ |
@@ -171,10 +175,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 
 <br></details>
 <br></details>
 
 
-<details><summary><code>pt_BR</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>pt_BR</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 
 | Key | English text |
 | Key | English text |
 | --- | ------------ |
 | --- | ------------ |
@@ -196,10 +201,11 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 
 <br></details>
 <br></details>
 
 
-<details><summary><code>zh_CN</code> - 18 missing keys <i>(click to show)</i></summary><br>
+<details><summary><code>zh_CN</code> - 19 missing keys <i>(click to show)</i></summary><br>
 
 
 | Key | English text |
 | Key | English text |
 | --- | ------------ |
 | --- | ------------ |
@@ -221,5 +227,6 @@ This means to figure out which keys are untranslated, you will need to manually
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_desc_rememberSongTimeReduction` | `How many seconds to subtract when restoring the time of a remembered song` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_helptext_rememberSongTimeReduction` | `When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you can re-listen to the part that was interrupted.` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
 | `feature_desc_rememberSongTimeMinPlayTime` | `Minimum amount of seconds a song needs to be played for its time to be remembered` |
+| `plugin_validation_error_no_property` | `No property '%1' with type '%2'` |
 
 
 <br></details>
 <br></details>

+ 3 - 1
assets/translations/en_US.json

@@ -195,6 +195,8 @@
     "feature_desc_logLevel": "How much information to log to the console",
     "feature_desc_logLevel": "How much information to log to the console",
     "feature_helptext_logLevel": "Changing this is really only needed for debugging purposes as a result of experiencing a problem.\nShould you have one, you can increase the log level here, open your browser's JavaScript console (usually with Ctrl + Shift + K) and attach screenshots of that log in a GitHub issue.",
     "feature_helptext_logLevel": "Changing this is really only needed for debugging purposes as a result of experiencing a problem.\nShould you have one, you can increase the log level here, open your browser's JavaScript console (usually with Ctrl + Shift + K) and attach screenshots of that log in a GitHub issue.",
     "feature_desc_advancedMode": "Show advanced settings (after reload)",
     "feature_desc_advancedMode": "Show advanced settings (after reload)",
-    "feature_helptext_advancedMode": "Show advanced settings in the configuration menu after reloading the page.\nThis is useful if you want to more deeply customize the script's behavior."
+    "feature_helptext_advancedMode": "Show advanced settings in the configuration menu after reloading the page.\nThis is useful if you want to more deeply customize the script's behavior.",
+
+    "plugin_validation_error_no_property": "No property '%1' with type '%2'"
   }
   }
 }
 }

+ 91 - 1
contributing.md

@@ -153,7 +153,7 @@ These are the ways to interact with BetterYTM; constants, events and global func
 - Another way of dynamically interacting is through global functions, which are also exposed by BetterYTM through the global `BYTM` object.  
 - Another way of dynamically interacting is through global functions, which are also exposed by BetterYTM through the global `BYTM` object.  
   You can find all functions that are available in the `InterfaceFunctions` type in [`src/types.ts`](src/types.ts)  
   You can find all functions that are available in the `InterfaceFunctions` type in [`src/types.ts`](src/types.ts)  
   There is also a summary with examples [below.](#global-functions)  
   There is also a summary with examples [below.](#global-functions)  
-  Additionally to those functions, the namespace `BYTM.UserUtils` is also exposed, which contains all functions from the [UserUtils](https://github.com/Sv443-Network/UserUtils) library.
+  Additionally to those functions, the namespace `BYTM.UserUtils` is also exposed, which contains all exported members from the [UserUtils library.](https://github.com/Sv443-Network/UserUtils)
 
 
 All of these interactions require the use of `unsafeWindow`, as the regular window object is pretty sandboxed in userscript managers.  
 All of these interactions require the use of `unsafeWindow`, as the regular window object is pretty sandboxed in userscript managers.  
   
   
@@ -256,6 +256,9 @@ An easy way to do this might be to include BetterYTM as a Git submodule, as long
 These are the global functions that are exposed by BetterYTM through the `unsafeWindow.BYTM` object.  
 These are the global functions that are exposed by BetterYTM through the `unsafeWindow.BYTM` object.  
 The usage and example blocks on each are written in TypeScript but can be used in JavaScript as well, after removing all type annotations.  
 The usage and example blocks on each are written in TypeScript but can be used in JavaScript as well, after removing all type annotations.  
   
   
+- Meta:
+  - [registerPlugin()](#registerplugin) - Registers a plugin with BetterYTM with the given plugin definition object
+  - [getPluginInfo()](#getplugininfo) - Returns the plugin info object for the specified plugin - also used to check if a certain plugin is registered
 - BYTM-specific:
 - BYTM-specific:
   - [getResourceUrl()](#getresourceurl) - Returns a `blob:` URL provided by the local userscript extension for the specified BYTM resource file
   - [getResourceUrl()](#getresourceurl) - Returns a `blob:` URL provided by the local userscript extension for the specified BYTM resource file
   - [getSessionId()](#getsessionid) - Returns the unique session ID that is generated on every started session
   - [getSessionId()](#getsessionid) - Returns the unique session ID that is generated on every started session
@@ -283,6 +286,93 @@ The usage and example blocks on each are written in TypeScript but can be used i
 - Other:
 - Other:
   - [NanoEmitter](#nanoemitte) - Abstract class for creating lightweight, type safe event emitting classes
   - [NanoEmitter](#nanoemitte) - Abstract class for creating lightweight, type safe event emitting classes
 
 
+<br><br>
+
+> #### registerPlugin()
+> Usage:
+> ```ts
+> unsafeWindow.BYTM.registerPlugin(pluginDef: PluginDef): PluginRegisterResult
+> ```
+>   
+> Description:  
+> Registers a plugin with BetterYTM with the given plugin definition object.  
+>   
+> Arguments:  
+> - `pluginDef` - The properties of this plugin definition object can be found by searching for `type PluginDef` in the file [`src/types.ts`](./src/types.ts)  
+>   
+> The function will either throw an error if the plugin object is invalid or return a registration result object.  
+> Its type can be found by searching for `type PluginRegisterResult` in the file [`src/types.ts`](./src/types.ts)
+> 
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> const pluginDef = {
+>   plugin: {
+>     name: "My cool plugin",
+>     namespace: "https://github.com/MyUsername",
+>     version: [1, 0, 0],
+>     description: {
+>       en_US: "This is a plugin that does cool stuff",
+>       de_DE: "Dies ist ein Plugin, das coole Sachen macht",
+>     },
+>     iconUrl: "https://picsum.photos/128/128",
+>     homepage: {
+>       github: "https://github.com/MyUsername/MyCoolBYTMPlugin",
+>       greasyfork: "...",
+>       openuserjs: "...",
+>     },
+>   },
+>   contributors: [
+>     {
+>       name: "MyUsername",
+>       homepage: "https://github.com/MyUsername",
+>       email: "[email protected]",
+>     },
+>   ],
+> };
+> 
+> unsafeWindow.addEventListener("bytm:ready", () => {
+>   unsafeWindow.BYTM.registerPlugin(pluginDef);
+> });
+> ```
+> </details>
+
+<br>
+
+> #### getPluginInfo()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.getPluginInfo(name: string, namespace: string): PluginInfo | undefined
+> unsafeWindow.BYTM.getPluginInfo(pluginDef: { plugin: { name: string, namespace: string } }): PluginInfo | undefined
+> ```
+>   
+> Description:  
+> Returns the plugin info object for the specified plugin. It's basically a more restricted version of the plugin definition object.  
+> This object contains all information that other plugins will be able to see about your plugin.  
+>   
+> Arguments:
+> - `name` - The 'name' property of the plugin
+> - `namespace` - The 'namespace' property of the plugin
+> OR:
+> - `pluginDef` - A plugin definition object containing at least the `plugin.name` and `plugin.namespace` properties
+>   
+> The function will return `undefined` if the plugin is not registered.  
+> The type of the returned object can be found by searching for `type PluginInfo` in the file [`src/types.ts`](./src/types.ts)
+> 
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> unsafeWindow.addEventListener("bytm:pluginsLoaded", () => {
+>   const pluginInfo = unsafeWindow.BYTM.getPluginInfo("My cool plugin", "https://github.com/MyUsername");
+>   if(pluginInfo) {
+>     console.log(`The plugin '${pluginInfo.name}' with version '${pluginInfo.version.join(".")}' is loaded`);
+>   }
+>   else
+>     console.error("The plugin 'My cool plugin' is not registered");
+> });
+> ```
+> </details>
+
 <br>
 <br>
 
 
 > #### getResourceUrl()
 > #### getResourceUrl()

+ 1 - 1
src/dialogs/dialogs.css

@@ -2,7 +2,7 @@
   --bytm-dialog-accent-col: #3683d4;
   --bytm-dialog-accent-col: #3683d4;
   --bytm-advanced-mode-color: #c5a73b;
   --bytm-advanced-mode-color: #c5a73b;
   --bytm-experimental-col: #d07ff0;
   --bytm-experimental-col: #d07ff0;
-  --bytm-warning-col: #f27735;
+  --bytm-warning-col: #ff5233;
 }
 }
 
 
 /* TODO(v1.2): leave only dialog */
 /* TODO(v1.2): leave only dialog */

+ 81 - 2
src/interface.ts

@@ -1,17 +1,21 @@
 import * as UserUtils from "@sv443-network/userutils";
 import * as UserUtils from "@sv443-network/userutils";
 import { mode, branch, host, buildNumber, compressionFormat, scriptInfo } from "./constants";
 import { mode, branch, host, buildNumber, compressionFormat, scriptInfo } from "./constants";
-import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, NanoEmitter, t, tp, type TrLocale } from "./utils";
+import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, NanoEmitter, t, tp, type TrLocale, info, error } from "./utils";
 import { addSelectorListener } from "./observers";
 import { addSelectorListener } from "./observers";
 import { getFeatures, saveFeatures } from "./config";
 import { getFeatures, saveFeatures } from "./config";
 import { featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong, type LyricsCache } from "./features";
 import { featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong, type LyricsCache } from "./features";
 import type { SiteEventsMap } from "./siteEvents";
 import type { SiteEventsMap } from "./siteEvents";
-import type { FeatureConfig, FeatureInfo, LyricsCacheEntry } from "./types";
+import { LogLevel, type FeatureConfig, type FeatureInfo, type LyricsCacheEntry, type PluginDef, type PluginInfo, type PluginRegisterResult, type PluginDefResolvable } from "./types";
 import { BytmDialog, createHotkeyInput, createToggleInput } from "./components";
 import { BytmDialog, createHotkeyInput, createToggleInput } from "./components";
 
 
 const { getUnsafeWindow } = UserUtils;
 const { getUnsafeWindow } = UserUtils;
 
 
 /** All events that can be emitted on the BYTM interface and the data they provide */
 /** All events that can be emitted on the BYTM interface and the data they provide */
 export type InterfaceEvents = {
 export type InterfaceEvents = {
+  /** Emitted whenever the plugins should be registered using `unsafeWindow.BYTM.registerPlugin()` */
+  "bytm:initPlugins": undefined;
+  /** Emitted whenever all plugins have been loaded */
+  "bytm:pluginsLoaded": undefined;
   /** Emitted when BYTM has finished initializing all features */
   /** Emitted when BYTM has finished initializing all features */
   "bytm:ready": undefined;
   "bytm:ready": undefined;
   /**
   /**
@@ -44,6 +48,11 @@ export type InterfaceEvents = {
 };
 };
 
 
 const globalFuncs = {
 const globalFuncs = {
+  // meta
+  registerPlugin,
+  getPluginInfo,
+
+  // utils
   addSelectorListener,
   addSelectorListener,
   getResourceUrl,
   getResourceUrl,
   getSessionId,
   getSessionId,
@@ -62,6 +71,8 @@ const globalFuncs = {
   sanitizeSong,
   sanitizeSong,
 };
 };
 
 
+const plugins = new Map<string, PluginDef>();
+
 /** Initializes the BYTM interface */
 /** Initializes the BYTM interface */
 export function initInterface() {
 export function initInterface() {
   const props = {
   const props = {
@@ -113,6 +124,74 @@ export function emitInterface<
   getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: data[0] }));
   getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: data[0] }));
 }
 }
 
 
+//#MARKER register plugins
+
+export function loadPlugins() {
+  // TODO:
+  // load plugins between bytm:initPlugins and bytm:ready
+  // emit bytm:pluginsLoaded after all plugins loaded
+}
+
+/** Returns the key for a given plugin definition */
+function getPluginKey(plugin: PluginDef | PluginDefResolvable) {
+  return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
+}
+
+/** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) */
+function pluginDefToInfo(plugin?: PluginDef): PluginInfo | undefined {
+  return plugin && {
+    name: plugin.plugin.name,
+    namespace: plugin.plugin.namespace,
+    version: plugin.plugin.version,
+  };
+}
+
+/** 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)));
+}
+
+/** Validates the passed PluginDef object and returns an array of errors */
+function validatePluginDef(pluginDef: Partial<PluginDef>) {
+  const errors = [] as string[];
+
+  const addNoPropErr = (prop: string, type: string) =>
+    errors.push(t("plugin_validation_error_no_property", prop, type));
+
+  // def.plugin and its properties:
+  typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object");
+  const { plugin } = pluginDef;
+  !plugin?.name && addNoPropErr("plugin.name", "string");
+  !plugin?.namespace && addNoPropErr("plugin.namespace", "string");
+  !plugin?.version && addNoPropErr("plugin.version", "[major: number, minor: number, patch: number]");
+
+  return errors.length > 0 ? errors : undefined;
+}
+
+/** Registers a plugin on the BYTM interface */
+export function registerPlugin(pluginDef: PluginDef): PluginRegisterResult {
+  const validationErrors = validatePluginDef(pluginDef);
+  if(validationErrors) {
+    error(`Failed to register plugin${pluginDef?.plugin?.name ? ` '${pluginDef?.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);
+
+  info(`Registered plugin: ${name}`, LogLevel.Info);
+
+  return {
+    info: getPluginInfo(pluginDef)!,
+  };
+}
+
 //#MARKER proxy functions
 //#MARKER proxy functions
 
 
 /** Returns the current feature config, with sensitive values replaced by `undefined` */
 /** Returns the current feature config, with sensitive values replaced by `undefined` */

+ 82 - 2
src/types.ts

@@ -2,7 +2,7 @@ import type * as consts from "./constants";
 import type { scriptInfo } from "./constants";
 import type { scriptInfo } from "./constants";
 import type { addSelectorListener } from "./observers";
 import type { addSelectorListener } from "./observers";
 import type resources from "../assets/resources.json";
 import type resources from "../assets/resources.json";
-import type langMapping from "../assets/locales.json";
+import type locales from "../assets/locales.json";
 import type { getResourceUrl, getSessionId, getVideoTime, TrLocale, t, tp } from "./utils";
 import type { getResourceUrl, getSessionId, getVideoTime, TrLocale, t, tp } from "./utils";
 import type { getFeatures, saveFeatures } from "./config";
 import type { getFeatures, saveFeatures } from "./config";
 
 
@@ -28,7 +28,7 @@ export type Domain = "yt" | "ytm";
 export type HttpUrlString = `http://${string}` | `https://${string}`;
 export type HttpUrlString = `http://${string}` | `https://${string}`;
 
 
 /** Key of a resource in `assets/resources.json` and extra keys defined by `tools/post-build.ts` */
 /** Key of a resource in `assets/resources.json` and extra keys defined by `tools/post-build.ts` */
-export type ResourceKey = keyof typeof resources | `trans-${keyof typeof langMapping}` | "changelog";
+export type ResourceKey = keyof typeof resources | `trans-${keyof typeof locales}` | "changelog";
 
 
 /** Describes a single hotkey */
 /** Describes a single hotkey */
 export type HotkeyObj = {
 export type HotkeyObj = {
@@ -48,6 +48,84 @@ export type LyricsCacheEntry = {
   added: number;
   added: number;
 };
 };
 
 
+//#MARKER plugins
+
+// /** Intents (permissions) BYTM has to grant to the plugin for it to work */
+// export enum PluginIntent {
+//   /** Plugin has access to hidden config values */
+//   HiddenConfigValues = 1,
+//   /** Plugin can write to the feature configuration */
+//   WriteFeatureConfig = 2,
+//   /** Plugin can write to the lyrics cache */
+//   WriteLyricsCache = 4,
+//   /** Plugin can add new translations and overwrite existing ones */
+//   WriteTranslations = 8,
+//   /** Plugin can create modal dialogs */
+//   CreateModalDialogs = 16,
+// }
+
+/** Result of a plugin registration */
+export type PluginRegisterResult = {
+  /** Public info about the registered plugin */
+  info: PluginInfo;
+}
+
+/** Object that describes a plugin out of the restricted perspective of another plugin */
+export type PluginInfo =
+  Pick<PluginDef["plugin"],
+    | "name"
+    | "namespace"
+    | "version"
+  >;
+
+/** Minimum part of the PluginDef object needed to make up the resolvable plugin identifier */
+export type PluginDefResolvable = { 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];
+    /**
+     * 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
+     */
+    description: Partial<Record<keyof typeof locales, string>> & {
+      en_US: string;
+    };
+    /** URL to the plugin's icon - recommended size: 48x48 to 128x128 */
+    iconUrl?: string;
+    /** Homepage URLs for the plugin */
+    homepage?: {
+      /** URL to the plugin's GitHub repo */
+      github?: string;
+      /** URL to the plugin's GreasyFork page */
+      greasyfork?: string;
+      /** URL to the plugin's OpenUserJS page */
+      openuserjs?: string;
+    };
+  };
+  // /** TODO(v2.3 / v3): Intents (permissions) BYTM has to grant the plugin for it to work */
+  // intents?: Array<PluginIntent>;
+  /** Info about the plugin contributors */
+  contributors?: Array<{
+    /** Name of this contributor */
+    name: string;
+    /** (optional) Email address of this contributor */
+    email?: string;
+    /** (optional) URL to this plugin contributor's homepage / GitHub profile */
+    url?: string;
+  }>;
+};
+
 /** All functions exposed by the interface on the global `BYTM` object */
 /** All functions exposed by the interface on the global `BYTM` object */
 export type InterfaceFunctions = {
 export type InterfaceFunctions = {
   /** Adds a listener to one of the already present SelectorObserver instances */
   /** Adds a listener to one of the already present SelectorObserver instances */
@@ -105,6 +183,8 @@ declare global {
   }
   }
 }
 }
 
 
+//#MARKER features
+
 export type FeatureKey = keyof FeatureConfig;
 export type FeatureKey = keyof FeatureConfig;
 
 
 export type FeatureCategory =
 export type FeatureCategory =