Procházet zdrojové kódy

ref: lots of plugin stuff

Sv443 před 11 měsíci
rodič
revize
9cc245cfb8
8 změnil soubory, kde provedl 116 přidání a 52 odebrání
  1. 3 2
      changelog.md
  2. 67 4
      contributing.md
  3. 2 2
      src/features/behavior.ts
  4. 2 2
      src/features/layout.ts
  5. 22 22
      src/index.ts
  6. 9 2
      src/interface.ts
  7. 2 2
      src/siteEvents.ts
  8. 9 16
      src/utils/misc.ts

+ 3 - 2
changelog.md

@@ -25,8 +25,9 @@
     - Added a cache to save lyrics in. Up to 1000 of the most listened to songs are saved throughout sessions for 30 days to save time and reduce server load.
   - Implemented new class BytmDialog for less duplicate code, better maintainability, the ability to make more menus easier and for them to have better accessibility
   - Expanded plugin interface
-    - Added function to register plugins (see [contributing guide](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#registerplugin))
-    - Plugins are now given access to the classes [`BytmDialog`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#bytmdialog) and [`NanoEmitter`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#nanoemitter), and the functions [`onInteraction()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#oninteraction), [`createHotkeyInput()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createhotkeyinput), [`createToggleInput()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createtoggleinput) and [`createCircularBtn()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createcircularbtn)
+    - Added function to register plugins (see [contributing guide](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#registerplugin))  
+      All plugins that are not registered will have restricted access to the BetterYTM API (subject to change in the future).
+    - Plugins are now given access to the classes [`BytmDialog`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#bytmdialog) and [`NanoEmitter`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#nanoemitter), and the functions [`onInteraction()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#oninteraction), [`getThumbnailUrl()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#getthumbnailurl), [`getBestThumbnailUrl()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#getbestthumbnailurl) [`createHotkeyInput()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createhotkeyinput), [`createToggleInput()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createtoggleinput) and [`createCircularBtn()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createcircularbtn)
   - Added an experimental fuzzy filtering algorithm when fetching lyrics to eventually yield more accurate results (hidden behind advanced mode because it's far from perfect)
 
 <div class="pr-link-cont">

+ 67 - 4
contributing.md

@@ -269,6 +269,8 @@ The usage and example blocks on each are written in TypeScript but can be used i
   - [addSelectorListener()](#addselectorlistener) - Adds a listener that checks for changes in DOM elements matching a CSS selector
   - [onInteraction()](#oninteraction) - Adds accessible event listeners to the specified element for button or link-like keyboard and mouse interactions
   - [getVideoTime()](#getvideotime) - Returns the current video time (on both YT and YTM)
+  - [getThumbnailUrl()](#getthumbnailurl) - Returns the URL to the thumbnail of the currently playing video
+  - [getBestThumbnailUrl()](#getbestthumbnailurl) - Returns the URL to the best quality thumbnail of the currently playing video
   - [createHotkeyInput()](#createhotkeyinput) - Creates a hotkey input element
   - [createToggleInput()](#createtoggleinput) - Creates a toggle input element
   - [createCircularBtn()](#createcircularbtn) - Creates a generic, circular button element
@@ -311,7 +313,8 @@ The usage and example blocks on each are written in TypeScript but can be used i
 >   
 > The returned properties include:  
 > - `token` - A private token that is used for authenticated function calls and that **should not be persistently stored** beyond the current session
-> - `events` - An object containing all event listeners that the plugin can use to listen for BetterYTM events
+> - `events` - A nano-events emitter object that allows you to listen for events that are dispatched by BetterYTM  
+>   To find a list of all events, search for `PluginEventMap` in the file [`src/types.ts`](./src/types.ts)
 > - `info` - The info object that contains all data other plugins will be able to see about your plugin
 > 
 > <details><summary><b>Complete example <i>(click to expand)</i></b></summary>
@@ -354,14 +357,21 @@ The usage and example blocks on each are written in TypeScript but can be used i
 >   ],
 > };
 > 
+> // private token that should not be stored persistently (in memory like this should be enough)
+> let authToken: string | undefined;
+> 
+> // since some function calls require the token, this function can be called to get it once the plugin is fully registered
+> export function getToken() {
+>   return authToken;
+> }
+> 
 > unsafeWindow.addEventListener("bytm:initPlugins", () => {
 >   // register the plugin
->   const { events } = unsafeWindow.BYTM.registerPlugin(pluginDef);
->   let token: string | undefined;
+>   const { token, events } = unsafeWindow.BYTM.registerPlugin(pluginDef);
 >   // listen for the pluginRegistered event
 >   events.on("pluginRegistered", (info) => {
 >     // store the (private!) token for later use in authenticated function calls
->     token = info.token;
+>     authToken = token;
 >     console.log(`${info.name} (version ${info.version.join(".")}) is registered`);
 >   });
 >   // for other events search for "type PluginEventMap" in "src/types.ts"
@@ -571,6 +581,59 @@ The usage and example blocks on each are written in TypeScript but can be used i
 
 <br>
 
+> #### getThumbnailUrl()
+> Usage:
+> ```ts
+> unsafeWindow.BYTM.getThumbnailUrl(
+>   watchID: string,
+>   qualityOrIndex: "maxresdefault" | "sddefault" | "hqdefault" | "mqdefault" | "default" | 0 | 1 | 2 | 3
+> ): string
+> ```
+>   
+> Description:  
+> Returns the URL to the thumbnail of the video with the specified watch/video ID and quality.  
+> If a number is passed, 0 will return a low quality thumbnail and 1-3 will return a low quality frame from the video.  
+>   
+> Arguments:
+> - `watchID` - The watch/video ID of the video to get the thumbnail for
+> - `qualityOrIndex` - The quality or index of the thumbnail to get. Quality strings sorted highest resolution first: `maxresdefault` > `sddefault` > `hqdefault` > `mqdefault` > `default`. If no quality is specified, `maxresdefault` (highest resolution) is used.
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> const thumbnailUrl = unsafeWindow.BYTM.getThumbnailUrl("dQw4w9WgXcQ", "maxresdefault");
+> console.log(thumbnailUrl); // "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
+> ```
+> </details>
+
+<br>
+
+> #### getBestThumbnailUrl()
+> Usage:
+> ```ts
+> unsafeWindow.BYTM.getBestThumbnailUrl(watchID: string): Promise<string | undefined>
+> ```
+>   
+> Description:  
+> Returns the URL to the best quality thumbnail of the video with the specified watch/video ID.  
+> Will sequentially try to get the highest quality thumbnail available until one is found.  
+> Order of quality values tried: `maxresdefault` > `sddefault` > `hqdefault` > `0`  
+>   
+> If no thumbnail is found, the Promise will resolve with `undefined`  
+>   
+> Arguments:
+> - `watchID` - The watch/video ID of the video to get the thumbnail for
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> const thumbnailUrl = await unsafeWindow.BYTM.getBestThumbnailUrl("dQw4w9WgXcQ");
+> console.log(thumbnailUrl); // "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
+> ```
+> </details>
+
+<br>
+
 > #### setLocale()
 > Usage:  
 > ```ts

+ 2 - 2
src/features/behavior.ts

@@ -1,5 +1,5 @@
 import { clamp, interceptWindowEvent, pauseFor } from "@sv443-network/userutils";
-import { domLoaded, error, getDomain, getVideoTime, getWatchId, info, log, getVideoSelector, waitVideoElementReady, getCurrentParams } from "../utils";
+import { domLoaded, error, getDomain, getVideoTime, getWatchId, info, log, getVideoSelector, waitVideoElementReady } from "../utils";
 import { getFeatures } from "../config";
 import { addSelectorListener } from "../observers";
 import { LogLevel } from "../types";
@@ -106,7 +106,7 @@ export async function initRememberSongTime() {
 /** Tries to restore the time of the currently playing song */
 async function restoreSongTime() {
   if(location.pathname.startsWith("/watch")) {
-    const watchID = getCurrentParams().get("v");
+    const watchID = new URL(location.href).searchParams.get("v");
     if(!watchID)
       return;
 

+ 2 - 2
src/features/layout.ts

@@ -2,7 +2,7 @@ import { addParent, autoPlural, debounce, fetchAdvanced, pauseFor } from "@sv443
 import { getFeatures } from "../config";
 import { siteEvents } from "../siteEvents";
 import { addSelectorListener } from "../observers";
-import { error, getResourceUrl, log, warn, t, onInteraction, openInTab, getBestThumbnailUrl, getDomain, addStyle, currentMediaType, domLoaded, waitVideoElementReady, getVideoTime, fetchCss, addStyleFromResource, getCurrentParams } from "../utils";
+import { error, getResourceUrl, log, warn, t, onInteraction, openInTab, getBestThumbnailUrl, getDomain, addStyle, currentMediaType, domLoaded, waitVideoElementReady, getVideoTime, fetchCss, addStyleFromResource } from "../utils";
 import { scriptInfo } from "../constants";
 import { openCfgMenu } from "../menu/menu_old";
 import { createCircularBtn } from "../components";
@@ -594,7 +594,7 @@ export async function initThumbnailOverlay() {
         updateOverlayVisibility();
       });
 
-      const params = getCurrentParams();
+      const params = new URL(location.href).searchParams;
       if(params.has("v")) {
         applyThumbUrl(params.get("v")!);
         updateOverlayVisibility();

+ 22 - 22
src/index.ts

@@ -1,5 +1,5 @@
 import { compress, decompress, pauseFor, type Stringifiable } from "@sv443-network/userutils";
-import { addStyleFromResource, domLoaded, reserialize, warn } from "./utils";
+import { addStyleFromResource, domLoaded, warn } from "./utils";
 import { clearConfig, fixMissingCfgKeys, getFeatures, initConfig, setFeatures } from "./config";
 import { buildNumber, compressionFormat, defaultLogLevel, mode, scriptInfo } from "./constants";
 import { error, getDomain, info, getSessionId, log, setLogLevel, initTranslations, setLocale } from "./utils";
@@ -7,29 +7,24 @@ import { initSiteEvents } from "./siteEvents";
 import { emitInterface, initInterface, initPlugins } from "./interface";
 import { initObservers, addSelectorListener, globservers } from "./observers";
 import { getWelcomeDialog } from "./dialogs";
+import type { FeatureConfig } from "./types";
 import {
   // layout
-  addWatermark, removeUpgradeTab,
-  initRemShareTrackParam, fixSpacing,
-  initThumbnailOverlay, initHideCursorOnIdle,
-  fixHdrIssues,
+  addWatermark, removeUpgradeTab, initRemShareTrackParam, fixSpacing, initThumbnailOverlay, initHideCursorOnIdle, fixHdrIssues,
   // volume
   initVolumeFeatures,
   // song lists
   initQueueButtons, initAboveQueueBtns,
   // behavior
-  initBeforeUnloadHook, disableBeforeUnload,
-  initAutoCloseToasts, initRememberSongTime,
-  disableDarkReader,
+  initBeforeUnloadHook, disableBeforeUnload, initAutoCloseToasts, initRememberSongTime, disableDarkReader,
   // input
-  initArrowKeySkip, initSiteSwitch,
-  addAnchorImprovements, initNumKeysSkip,
+  initArrowKeySkip, initSiteSwitch, addAnchorImprovements, initNumKeysSkip,
   // lyrics
-  addMediaCtrlLyricsBtn,
+  addMediaCtrlLyricsBtn, initLyricsCache,
   // menu
   addConfigMenuOptionYT, addConfigMenuOptionYTM,
-  // other
-  initVersionCheck, initLyricsCache,
+  // general
+  initVersionCheck,
 } from "./features";
 
 {
@@ -194,15 +189,20 @@ async function onDomLoad() {
     }
 
     //#region (ytm+yt) cfg menu option
-    if(domain === "ytm") {
-      addSelectorListener("body", "tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
-        listener: addConfigMenuOptionYTM,
-      });
+    try {
+      if(domain === "ytm") {
+        addSelectorListener("body", "tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
+          listener: addConfigMenuOptionYTM,
+        });
+      }
+      else if(domain === "yt") {
+        addSelectorListener<0, "yt">("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", {
+          listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement),
+        });
+      }
     }
-    if(domain === "yt") {
-      addSelectorListener<0, "yt">("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", {
-        listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement),
-      });
+    catch(err) {
+      error("Couldn't add config menu option:", err);
     }
 
     if(["ytm", "yt"].includes(domain)) {
@@ -271,7 +271,7 @@ function registerDevMenuCommands() {
   }, "r");
 
   GM.registerMenuCommand("Fix missing config values", async () => {
-    const oldFeats = reserialize(getFeatures());
+    const oldFeats = JSON.parse(JSON.stringify(getFeatures())) as FeatureConfig;
     await setFeatures(fixMissingCfgKeys(oldFeats));
     console.log("Fixed missing config values.\nFrom:", oldFeats, "\n\nTo:", getFeatures());
     if(confirm("All missing or invalid config values were set to their default values.\nReload the page now?"))

+ 9 - 2
src/interface.ts

@@ -1,7 +1,7 @@
 import * as UserUtils from "@sv443-network/userutils";
 import { createNanoEvents } from "nanoevents";
 import { mode, branch, host, buildNumber, compressionFormat, scriptInfo } from "./constants";
-import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, NanoEmitter, t, tp, type TrLocale, info, error, onInteraction } from "./utils";
+import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, NanoEmitter, t, tp, type TrLocale, info, error, onInteraction, getThumbnailUrl, getBestThumbnailUrl } from "./utils";
 import { addSelectorListener } from "./observers";
 import { getFeatures, setFeatures } from "./config";
 import { compareVersionArrays, compareVersions, featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong, type LyricsCache } from "./features";
@@ -13,6 +13,11 @@ const { getUnsafeWindow, randomId } = UserUtils;
 
 //#region interface globals
 
+/** All events that can be emitted on the BYTM interface and the data they provide */
+export type InterfaceEventsMap = {
+  [K in keyof InterfaceEvents]: (data: InterfaceEvents[K]) => void;
+};
+
 /** All events that can be emitted on the BYTM interface and the data they provide */
 export type InterfaceEvents = {
   /** Emitted whenever the plugins should be registered using `unsafeWindow.BYTM.registerPlugin()` */
@@ -66,7 +71,7 @@ export const allInterfaceEvents = [
   "bytm:lyricsCacheReady",
   "bytm:lyricsCacheCleared",
   "bytm:lyricsCacheEntryAdded",
-  ...allSiteEvents.map(evt => `bytm:siteEvent:${evt}`),
+  ...allSiteEvents.map(e => `bytm:siteEvent:${e}`),
 ] as const;
 
 /** All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`) */
@@ -95,6 +100,8 @@ const globalFuncs = {
   compareVersions,
   compareVersionArrays,
   onInteraction,
+  getThumbnailUrl,
+  getBestThumbnailUrl,
 };
 
 /** Initializes the BYTM interface */

+ 2 - 2
src/siteEvents.ts

@@ -1,5 +1,5 @@
 import { createNanoEvents } from "nanoevents";
-import { error, info, log, getCurrentParams } from "./utils";
+import { error, info, log } from "./utils";
 import { FeatureConfig } from "./types";
 import { emitInterface } from "./interface";
 import { addSelectorListener } from "./observers";
@@ -153,7 +153,7 @@ export async function initSiteEvents() {
 
     const checkWatchId = () => {
       if(location.pathname.startsWith("/watch")) {
-        const newWatchId = getCurrentParams().get("v");
+        const newWatchId = new URL(location.href).searchParams.get("v");
         if(newWatchId && newWatchId !== lastWatchId) {
           info(`Detected watch ID change - old ID: "${lastWatchId}" - new ID: "${newWatchId}"`);
           emitSiteEvent("watchIdChanged", newWatchId, lastWatchId);

+ 9 - 16
src/utils/misc.ts

@@ -2,7 +2,7 @@ import { compress, fetchAdvanced, openInNewTab, randomId } from "@sv443-network/
 import { marked } from "marked";
 import { branch, compressionFormat, repo } from "../constants";
 import { type Domain, type ResourceKey } from "../types";
-import { error, type TrLocale, warn } from ".";
+import { error, type TrLocale, warn, sendRequest } from ".";
 import langMapping from "../../assets/locales.json" assert { type: "json" };
 
 //#region misc
@@ -24,9 +24,6 @@ export function getDomain(): Domain {
     throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
 }
 
-/** Returns the search params of the current URL */
-export const getCurrentParams = () => new URL(location.href).searchParams;
-
 /** Returns a pseudo-random ID unique to each session - returns null if sessionStorage is unavailable */
 export function getSessionId(): string | null {
   try {
@@ -78,40 +75,36 @@ export function getWatchId() {
   return pathname.includes("/watch") ? searchParams.get("v") : null;
 }
 
-type ThumbQuality = `${"" | "hq" | "mq" | "sd" | "maxres"}default`;
+/** Quality identifier for a thumbnail - from highest to lowest res: `maxresdefault` > `sddefault` > `hqdefault` > `mqdefault` > `default` */
+type ThumbQuality = `${"maxres" | "sd" | "hq" | "mq" | ""}default`;
 
 /** Returns the thumbnail URL for a video with the given watch ID and quality (defaults to "hqdefault") */
 export function getThumbnailUrl(watchId: string, quality?: ThumbQuality): string
-/** Returns the thumbnail URL for a video with the given watch ID and index */
+/** Returns the thumbnail URL for a video with the given watch ID and index (0 is low quality thumbnail, 1-3 are low quality frames from the video) */
 export function getThumbnailUrl(watchId: string, index: 0 | 1 | 2 | 3): string
 /** Returns the thumbnail URL for a video with either a given quality identifier or index */
-export function getThumbnailUrl(watchId: string, qualityOrIndex: ThumbQuality | 0 | 1 | 2 | 3 = "hqdefault") {
+export function getThumbnailUrl(watchId: string, qualityOrIndex: ThumbQuality | 0 | 1 | 2 | 3 = "maxresdefault") {
   return `https://i.ytimg.com/vi/${watchId}/${qualityOrIndex}.jpg`;
 }
 
 /** Returns the best available thumbnail URL for a video with the given watch ID */
 export async function getBestThumbnailUrl(watchId: string) {
-  const priorityList = ["maxresdefault", "sddefault", 0];
+  const priorityList = ["maxresdefault", "sddefault", "hqdefault", 0];
 
   for(const quality of priorityList) {
-    let response: Response | undefined;
+    let response: GM.Response<unknown> | undefined;
     const url = getThumbnailUrl(watchId, quality as ThumbQuality);
     try {
-      response = await fetchAdvanced(url, { method: "HEAD", timeout: 5000 });
+      response = await sendRequest({ url, method: "HEAD", timeout: 6_000 });
     }
     catch(e) {
       void e;
     }
-    if(response?.ok)
+    if(response && response.status < 300 && response.status >= 200)
       return url;
   }
 }
 
-/** Copies a JSON-serializable object */
-export function reserialize<T>(data: T): T {
-  return JSON.parse(JSON.stringify(data));
-}
-
 /** Opens the given URL in a new tab, using GM.openInTab if available */
 export function openInTab(href: string, background = false) {
   try {