1
0
Sv443 1 жил өмнө
parent
commit
f343155d74

+ 33 - 6
.eslintrc.json

@@ -1,21 +1,48 @@
 {
     "env": {
         "browser": true,
-        "es6": true
+        "es6": true,
+        "node": true
     },
     "ignorePatterns": [
-        "*.min.*"
+        "*.min.*",
+        "webpack.config.js",
+        "BetterYTM.user.js"
+    ],
+    "extends": [
+        "eslint:recommended",
+        "plugin:@typescript-eslint/recommended"
     ],
-    "extends": "eslint:recommended",
     "globals": {
         "Atomics": "readonly",
         "SharedArrayBuffer": "readonly",
         "GM": "readonly"
     },
+    "parser": "@typescript-eslint/parser",
     "parserOptions": {
-        "ecmaVersion": 2020
+        "ecmaVersion": "latest"
     },
+    "plugins": [
+        "@typescript-eslint"
+    ],
     "rules": {
-        "no-unreachable": "off"
-    }
+        "no-unreachable": "off",
+        "quotes": [ "error", "double" ],
+        "semi": [ "error", "always" ],
+        "eol-last": [ "error", "always" ],
+        "no-async-promise-executor": "off",
+        "indent": ["error", 2, { "ignoredNodes": ["VariableDeclaration[declarations.length=0]"] }],
+        "@typescript-eslint/no-non-null-assertion": "off",
+        "@typescript-eslint/no-unused-vars": ["warn", { "ignoreRestSiblings": true, "argsIgnorePattern": "^_" }],
+        "@typescript-eslint/ban-ts-comment": "off",
+        "comma-dangle": ["error", "only-multiline"]
+    },
+    "overrides": [
+        {
+            "files": ["**.js"],
+            "rules": {
+                "@typescript-eslint/no-var-requires": "off"
+            }
+        }
+    ]
 }

+ 0 - 34
.github/workflows/betterytm.yml

@@ -1,34 +0,0 @@
-name: "BetterYTM"
-
-on:
-  pull_request:
-    # The branches below must be a subset of the branches above
-    branches: [ main, develop ] # runs on every push
-
-
-jobs:
-  lint:
-    name: Lint
-    runs-on: ubuntu-latest
-
-    timeout-minutes: 10
-
-    strategy:
-      fail-fast: false
-      matrix:
-        node-version: [14.x]
-
-    steps:
-    - uses: actions/checkout@v1 # checkout latest commit
-    - name: Use Node.js ${{ matrix.node-version }} # set up Node.js
-      uses: actions/setup-node@v1
-      with:
-        node-version: ${{ matrix.node-version }}
-    - name: Clean install of dependencies # runs the npm ci command to install all dependencies
-      run: npm ci
-      env:
-        CI: "true"
-    - name: Lint # runs ESLint
-      run: npm run lint
-      env:
-        CI: "true"

+ 33 - 0
.github/workflows/lint.yml

@@ -0,0 +1,33 @@
+name: "Lint BetterYTM"
+
+on:
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [main, develop] # runs on every push
+
+jobs:
+  lint:
+    name: Lint
+    runs-on: ubuntu-latest
+
+    timeout-minutes: 10
+
+    strategy:
+      fail-fast: false
+      matrix:
+        node-version: [18.x]
+
+    steps:
+      - uses: actions/checkout@v3 # checkout latest commit
+      - name: Use Node.js ${{ matrix.node-version }} # set up Node.js
+        uses: actions/setup-node@v2
+        with:
+          node-version: ${{ matrix.node-version }}
+      - name: Clean install of dependencies # runs the npm ci command to install all dependencies
+        run: npm ci
+        env:
+          CI: "true"
+      - name: Lint # runs ESLint
+        run: npm run lint
+        env:
+          CI: "true"

+ 1 - 1
.gitignore

@@ -1,2 +1,2 @@
 node_modules/
-test.js
+test.js

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 3 - 1154
BetterYTM.user.js


+ 18 - 18
dev/numKeysJumpTo.js

@@ -1,23 +1,23 @@
 (() => {
-	const timeSteps = 9; // excluding 0
-    function getX(timeKey, maxWidth) {
-        if(timeKey >= timeSteps)
-            return maxWidth;
-        return Math.floor(maxWidth / timeSteps) * timeKey;
-    }
+  const timeSteps = 9; // excluding 0
+  function getX(timeKey, maxWidth) {
+    if(timeKey >= timeSteps)
+      return maxWidth;
+    return Math.floor(maxWidth / timeSteps) * timeKey;
+  }
 
-	const elem = document.querySelector("#sliderBar");
-	const rect = elem.getBoundingClientRect();
-	const x = getX(1, rect.width);
-	const y = (rect.top + rect.bottom) / 2;
+  const elem = document.querySelector("#sliderBar");
+  const rect = elem.getBoundingClientRect();
+  const x = getX(1, rect.width);
+  const y = (rect.top + rect.bottom) / 2;
 
-	const evt = new MouseEvent("mousedown", {
-        clientX: x,
-		clientY: y,
-		target: elem,
-        bubbles: true,
-        view: window,
-	});
+  const evt = new MouseEvent("mousedown", {
+    clientX: x,
+    clientY: y,
+    target: elem,
+    bubbles: true,
+    view: window,
+  });
 
-    document.body.dispatchEvent(evt);
+  document.body.dispatchEvent(evt);
 })();

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 29 - 0
dist/BetterYTM.user.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 889 - 8
package-lock.json


+ 23 - 5
package.json

@@ -2,16 +2,22 @@
   "name": "betterytm",
   "version": "1.0.0",
   "description": "Userscript that improves YouTube Music",
-  "main": "BetterYTM.user.js",
+  "main": "./src/BetterYTM.user.ts",
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
+    "build": "webpack",
+    "start": "webpack serve --progress --mode development",
     "lint": "eslint ."
   },
   "repository": {
     "type": "git",
     "url": "git+https://github.com/Sv443/BetterYTM.git"
   },
-  "author": "Sv443",
+  "author": {
+    "name": "Sv443",
+    "url": "https://github.com/Sv443",
+    "email": "[email protected]"
+  },
   "license": "MIT",
   "bugs": {
     "url": "https://github.com/Sv443/BetterYTM/issues"
@@ -19,6 +25,18 @@
   "homepage": "https://github.com/Sv443/BetterYTM#readme",
   "devDependencies": {
     "@types/greasemonkey": "^4.0.4",
-    "eslint": "^7.32.0"
-  }
-}
+    "@types/node": "^20.2.4",
+    "@typescript-eslint/eslint-plugin": "^5.59.7",
+    "@typescript-eslint/parser": "^5.59.7",
+    "eslint": "^7.32.0",
+    "ts-loader": "^9.4.3",
+    "tslib": "^2.5.2",
+    "typescript": "^5.0.4",
+    "webpack-cli": "^5.1.1"
+  },
+  "browserslist": [
+    "last 1 version",
+    "> 1%",
+    "not dead"
+  ]
+}

+ 1083 - 0
src/BetterYTM.user.ts

@@ -0,0 +1,1083 @@
+import type { Domain, FeatureConfig } from "./types";
+
+(async () => {
+  /** Set to true to enable debug mode for more output in the JS console */
+  const dbg = true;
+
+  // const branch = "main";
+  const branch = "develop"; // #DEBUG
+
+  /** Contains all possible features with their default values and other config */
+  const featInfo = {
+    arrowKeySupport: {
+      desc: "Arrow keys skip forwards and backwards by 10 seconds",
+      type: "toggle",
+      default: true,
+    },
+    removeUpgradeTab: {
+      desc: "Remove the \"Upgrade\" / YT Music Premium tab",
+      type: "toggle",
+      default: true,
+    },
+    switchBetweenSites: {
+      desc: "Add F9 as a hotkey to switch between the YT and YTM sites on a video / song",
+      type: "toggle",
+      default: true,
+    },
+    geniusLyrics: {
+      desc: "Add a button to the media controls to open the current song's lyrics on genius.com in a new tab",
+      type: "toggle",
+      default: true,
+    },
+    lyricsButtonsOnSongQueue: {
+      desc: "TODO: Add a lyrics button to each song in the queue (\"up next\" tab)",
+      type: "toggle",
+      default: true,
+    },
+    volumeSliderSize: {
+      desc: "The width of the volume slider in pixels",
+      type: "number",
+      min: 10,
+      max: 1000,
+      step: 5,
+      default: 160,
+    },
+    volumeSliderStep: {
+      desc: "Volume slider sensitivity - the smaller this number, the finer the volume control",
+      type: "slider",
+      min: 1,
+      max: 20,
+      default: 2,
+    },
+    watermarkEnabled: {
+      desc: "Show a BetterYTM watermark under the YTM logo",
+      type: "toggle",
+      default: true,
+    },
+  };
+
+  const defaultFeatures = (Object.keys(featInfo) as (keyof typeof featInfo)[]).reduce<Partial<FeatureConfig>>((acc, key) => {
+    acc[key] = featInfo[key].default as unknown as undefined;
+    return acc;
+  }, {}) as FeatureConfig;
+
+  const featureConf = await loadFeatureConf();
+
+  // console.log("bytm load", featureConf);
+
+  const features: FeatureConfig = { ...defaultFeatures, ...featureConf };
+  // const features = { ...defaultFeatures };
+
+  // console.log("bytm save", features);
+
+  await saveFeatureConf(features);
+
+
+  //#MARKER init
+
+
+  /** Specifies the hard limit for repetitive tasks */
+  const triesLimit = 50;
+  /** Specifies the interval for repetitive tasks */
+  const triesInterval = 150;
+
+  /** Base URL of geniURL */
+  const geniUrlBase = "https://api.sv443.net/geniurl";
+
+  /** GeniURL endpoint that gives song metadata when provided with a `?q` or `?artist` and `?song` parameter - [more info](https://api.sv443.net/geniurl) */
+  const geniURLSearchTopUrl = `${geniUrlBase}/search/top`;
+
+  const info = Object.freeze({
+    name: GM.info.script.name,
+    version: GM.info.script.version,
+    namespace: GM.info.script.namespace,
+  });
+
+  function init()
+  {
+    try
+    {
+      console.log(`${info.name} v${info.version} - ${info.namespace}`);
+      console.log(`Powered by lots of ambition and my song metadata API called geniURL: ${geniUrlBase}`);
+
+      document.addEventListener("DOMContentLoaded", onDomLoad);
+    }
+    catch(err)
+    {
+      console.error("BetterYTM - General Error:", err);
+    }
+  }
+
+
+  //#MARKER events
+
+
+  /**
+ * Called when the DOM has finished loading (after `DOMContentLoaded` is emitted)
+ */
+  async function onDomLoad()
+  {
+    const domain = getDomain();
+
+    dbg && console.log(`BetterYTM: Initializing features for domain '${domain}'`);
+
+    try
+    {
+      if(domain === "ytm")
+      {
+        if(features.arrowKeySupport)
+        {
+          document.addEventListener("keydown", onKeyDown);
+          dbg && console.log("BetterYTM: Added key press listener");
+        }
+
+        if(features.removeUpgradeTab)
+          removeUpgradeTab();
+
+        if(features.watermarkEnabled)
+          addWatermark();
+
+        if(features.geniusLyrics)
+          await addMediaCtrlGeniusBtn();
+
+        if(features.lyricsButtonsOnSongQueue)
+          await addQueueGeniusBtns();
+
+        if(typeof features.volumeSliderSize === "number")
+          setVolSliderSize();
+
+        setVolSliderStep();
+      }
+
+      if(["ytm", "yt"].includes(domain))
+      {
+        if(features.switchBetweenSites)
+          initSiteSwitch(domain);
+
+        try
+        {
+          addMenu();
+        }
+        catch(err)
+        {
+          console.error("BetterYTM: Couldn't add menu:", err);
+        }
+      }
+    }
+    catch(err)
+    {
+      console.error("BetterYTM: General error while executing feature:", err);
+    }
+
+    // if(features.themeColor != "#f00" && features.themeColor != "#ff0000")
+    //     applyTheme();
+  }
+
+
+  //#MARKER menu
+
+
+  /** Adds an element to open the BetterYTM menu */
+  function addMenu()
+  {
+    // bg & menu
+    const backgroundElem = document.createElement("div");
+    backgroundElem.id = "betterytm-menu-bg";
+    backgroundElem.title = "Click here to close the menu";
+    backgroundElem.style.visibility = "hidden";
+    backgroundElem.style.display = "none";
+    backgroundElem.addEventListener("click", (e) => {
+      if((e.target as HTMLElement).id === "betterytm-menu-bg")
+        closeMenu();
+    });
+
+    const menuContainer = document.createElement("div");
+    menuContainer.title = "";
+    menuContainer.id = "betterytm-menu";
+    menuContainer.style.borderRadius = "15px";
+    menuContainer.style.display = "flex";
+    menuContainer.style.flexDirection = "column";
+    menuContainer.style.justifyContent = "space-between";
+
+
+    // title
+    const titleCont = document.createElement("div");
+    titleCont.style.padding = "8px 20px 15px 8px";
+    titleCont.style.display = "flex";
+    titleCont.style.justifyContent = "space-between";
+    titleCont.id = "betterytm-menu-titlecont";
+
+    const titleElem = document.createElement("h2");
+    titleElem.id = "betterytm-menu-title";
+    titleElem.innerText = "BetterYTM - Configuration";
+
+    const linksCont = document.createElement("div");
+    linksCont.id = "betterytm-menu-linkscont";
+
+    const addLink = (imgSrc: string, href: string, title: string) => {
+      const anchorElem = document.createElement("a");
+      anchorElem.className = "betterytm-menu-link";
+      anchorElem.rel = "noopener noreferrer";
+      anchorElem.target = "_blank";
+      anchorElem.href = href;
+      anchorElem.title = title;
+      anchorElem.style.marginLeft = "10px";
+        
+      const imgElem = document.createElement("img");
+      imgElem.className = "betterytm-menu-img";
+      imgElem.src = imgSrc;
+      imgElem.style.width = "32px";
+      imgElem.style.height = "32px";
+
+      anchorElem.appendChild(imgElem);
+      linksCont.appendChild(anchorElem);
+    };
+
+    addLink(`https://raw.githubusercontent.com/Sv443/BetterYTM/${branch}/resources/external/github.png`, info.namespace, `${info.name} on GitHub`);
+    addLink(`https://raw.githubusercontent.com/Sv443/BetterYTM/${branch}/resources/external/greasyfork.png`, "https://greasyfork.org/xyz", `${info.name} on GreasyFork`);
+
+    const closeElem = document.createElement("img");
+    closeElem.id = "betterytm-menu-close";
+    closeElem.src = `https://raw.githubusercontent.com/Sv443/BetterYTM/${branch}/resources/icon/close.png`;
+    closeElem.title = "Click to close the menu";
+    closeElem.style.marginLeft = "50px";
+    closeElem.style.width = "32px";
+    closeElem.style.height = "32px";
+    closeElem.addEventListener("click", closeMenu);
+
+    linksCont.appendChild(closeElem);
+
+    titleCont.appendChild(titleElem);
+    titleCont.appendChild(linksCont);
+
+
+    // TODO: features
+    const featuresCont = document.createElement("div");
+    featuresCont.id = "betterytm-menu-opts";
+    featuresCont.style.display = "flex";
+    featuresCont.style.flexDirection = "column";
+
+    /** Gets called whenever the feature config is changed */
+    const confChanged = async (key: keyof typeof defaultFeatures, initialVal: number | boolean, newVal: number | boolean) => {
+      dbg && console.info(`BetterYTM: Feature config changed, key '${key}' from value '${initialVal}' to '${newVal}'`);
+
+      /** @type {FeatureConfig} */
+      const featConf = {...(await loadFeatureConf())};
+
+      featConf[key] = newVal as never;
+
+      await saveFeatureConf(featConf);
+
+      dbg && console.log("BetterYTM: Saved feature config changes");
+
+      dbg && console.log("#DEBUG", await GM.getValue("betterytm-config"));
+    };
+
+    const featKeys = Object.keys(features);
+    for(const key of featKeys) {
+      const ftInfo = featInfo[key as keyof typeof features];
+
+      if(!ftInfo)
+        continue;
+
+      const { desc, type, default: ftDef } = ftInfo;
+
+      // @ts-ignore
+      const step = ftInfo?.step ?? undefined;
+      const val = features[key as keyof typeof features];
+
+      const initialVal = val || ftDef || undefined;
+
+      const ftConfElem = document.createElement("div");
+      ftConfElem.id = `betterytm-ftconf-${key}`;
+      ftConfElem.style.display = "flex";
+      ftConfElem.style.flexDirection = "row";
+      ftConfElem.style.justifyContent = "space-between";
+      ftConfElem.style.padding = "8px 20px";
+
+      {
+        const textElem = document.createElement("span");
+        textElem.style.display = "inline-block";
+        textElem.style.fontSize = "15px";
+        textElem.innerText = desc;
+
+        ftConfElem.appendChild(textElem);
+      }
+
+      {
+        let inputType = "text";
+        switch(type)
+        {
+        case "toggle":
+          inputType = "checkbox";
+          break;
+        case "slider":
+          inputType = "range";
+          break;
+        case "number":
+          inputType = "number";
+          break;
+        }
+
+        const inputElemId = `betterytm-ftconf-${key}-input`;
+
+        const ctrlElem = document.createElement("span");
+        ctrlElem.style.display = "inline-block";
+        ctrlElem.style.whiteSpace = "nowrap";
+
+        const inputElem = document.createElement("input");
+        inputElem.id = inputElemId;
+        inputElem.style.marginRight = "37px";
+        inputElem.type = inputType;
+        if(type === "toggle")
+          inputElem.style.marginLeft = "5px";
+        if(typeof initialVal !== "undefined")
+          inputElem.value = String(initialVal);
+        if(type === "number" && step)
+          inputElem.step = step;
+
+        // @ts-ignore
+        if(ftInfo.min && ftInfo.max) {
+          // @ts-ignore
+          inputElem.min = ftInfo.min;
+          // @ts-ignore
+          inputElem.max = ftInfo.max;
+        }
+
+        if(type === "toggle" && typeof initialVal !== "undefined")
+          inputElem.checked = Boolean(initialVal);
+
+        const fmtVal = (v: unknown) => String(v);
+        const toggleLabelText = (toggled: boolean) => toggled ? "On" : "Off";
+
+        let labelElem: HTMLLabelElement | undefined;
+        if(type === "slider") {
+          labelElem = document.createElement("label");
+          labelElem.classList.add("betterytm-ftconf-label");
+          labelElem.style.marginRight = "20px";
+          labelElem.style.fontSize = "16px";
+          labelElem.htmlFor = inputElemId;
+          labelElem.innerText = fmtVal(initialVal);
+
+          inputElem.addEventListener("change", () => {
+            if(labelElem)
+              labelElem.innerText = fmtVal(parseInt(inputElem.value));
+          });
+        }
+        else if(type === "toggle" && typeof initialVal !== "undefined") {
+          labelElem = document.createElement("label");
+          labelElem.classList.add("betterytm-ftconf-label");
+          labelElem.style.paddingLeft = "10px";
+          labelElem.style.paddingRight = "5px";
+          labelElem.style.fontSize = "16px";
+          labelElem.htmlFor = inputElemId;
+          labelElem.innerText = toggleLabelText(Boolean(initialVal));
+
+          inputElem.addEventListener("change", () => {
+            if(labelElem)
+              labelElem.innerText = toggleLabelText(inputElem.checked);
+          });
+        }
+
+        inputElem.addEventListener("change", ({ currentTarget }) => {
+          const elem = currentTarget as HTMLInputElement;
+          let v = parseInt(elem.value);
+          if(isNaN(v))
+            v = Number(elem.value);
+          if(typeof initialVal !== "undefined")
+            confChanged(key as keyof FeatureConfig, initialVal, (type !== "toggle" ? v : elem.checked));
+        });
+
+        const resetElem = document.createElement("button");
+        resetElem.innerText = "Reset";
+        resetElem.addEventListener("click", () => {
+          inputElem[type !== "toggle" ? "value" : "checked"] = ftDef as never;
+
+          if(labelElem) {
+            if(type === "toggle")
+              labelElem.innerText = toggleLabelText(inputElem.checked);
+            else
+              labelElem.innerText = fmtVal(parseInt(inputElem.value));
+          }
+
+          if(typeof initialVal !== "undefined")
+            confChanged(key as keyof FeatureConfig, initialVal, ftDef);
+        });
+
+        labelElem && ctrlElem.appendChild(labelElem);
+        ctrlElem.appendChild(inputElem);
+        ctrlElem.appendChild(resetElem);
+
+        ftConfElem.appendChild(ctrlElem);
+      }
+
+      featuresCont.appendChild(ftConfElem);
+    }
+
+    const footerElem = document.createElement("div");
+    footerElem.style.marginTop = "20px";
+    footerElem.style.fontSize = "17px";
+    footerElem.style.textDecoration = "underline";
+    footerElem.style.padding = "8px 20px";
+    footerElem.innerText = "You need to reload the page to apply changes.";
+
+    const reloadElem = document.createElement("button");
+    reloadElem.style.marginLeft = "20px";
+    reloadElem.innerText = "Reload now";
+    reloadElem.title = "Click to reload the page";
+    reloadElem.addEventListener("click", () => location.reload());
+
+    footerElem.appendChild(reloadElem);
+    featuresCont.appendChild(footerElem);
+
+
+    // finalize
+    const menuBody = document.createElement("div");
+    menuBody.id = "betterytm-menu-body";
+    menuBody.appendChild(titleCont);
+    menuBody.appendChild(featuresCont);
+
+    const versionCont = document.createElement("div");
+    versionCont.style.display = "flex";
+    versionCont.style.justifyContent = "space-around";
+    versionCont.style.fontSize = "1.15em";
+    versionCont.style.marginTop = "10px";
+    versionCont.style.marginBottom = "5px";
+
+    const versionElem = document.createElement("span");
+    versionElem.id = "betterytm-menu-version";
+    versionElem.innerText = `v${info.version}`;
+
+    versionCont.appendChild(versionElem);
+    featuresCont.appendChild(versionCont);
+
+    menuContainer.appendChild(menuBody);
+    menuContainer.appendChild(versionCont);
+
+    backgroundElem.appendChild(menuContainer);
+
+    document.body.appendChild(backgroundElem);
+
+
+    // add style
+    const menuStyle = `\
+    #betterytm-menu-bg {
+      display: block;
+      position: fixed;
+      width: 100vw;
+      height: 100vh;
+      top: 0;
+      left: 0;
+      z-index: 15;
+      background-color: rgba(0, 0, 0, 0.6);
+    }
+
+    #betterytm-menu {
+      display: inline-block;
+      position: fixed;
+      width: 50vw;
+      height: auto;
+      min-height: 500px;
+      left: 25vw;
+      top: 25vh;
+      z-index: 16;
+      overflow: auto;
+      padding: 8px;
+      color: #fff;
+      background-color: #212121;
+    }
+
+    #betterytm-menu-titlecont {
+      display: flex;
+    }
+
+    #betterytm-menu-title {
+      font-size: 20px;
+      margin-top: 5px;
+      margin-bottom: 8px;
+    }
+
+    #betterytm-menu-linkscont {
+      display: flex;
+    }
+
+    .betterytm-menu-link {
+      display: inline-block;
+    }
+
+    /*.betterytm-menu-img {
+
+    }*/
+
+    #betterytm-menu-close {
+      cursor: pointer;
+    }
+
+    .betterytm-ftconf-label {
+      user-select: none;
+    }
+    `;
+
+    dbg && console.log("BetterYTM: Added menu elem:", backgroundElem);
+
+    addGlobalStyle(menuStyle, "menu");
+  }
+
+  function closeMenu() {
+    const menuBg = document.querySelector("#betterytm-menu-bg") as HTMLElement;
+
+    menuBg.style.visibility = "hidden";
+    menuBg.style.display = "none";
+  }
+
+  function openMenu() {
+    const menuBg = document.querySelector("#betterytm-menu-bg") as HTMLElement;
+
+    menuBg.style.visibility = "visible";
+    menuBg.style.display = "block";
+  }
+
+
+  //#MARKER features
+
+
+  //#SECTION arrow key skip
+
+  /** Called when the user presses any key, anywhere */
+  function onKeyDown(evt: KeyboardEvent) {
+    if(["ArrowLeft", "ArrowRight"].includes(evt.code)) {
+      // discard the event when a (text) input is currently active, like when editing a playlist
+      if(["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName ?? "_"))
+        return dbg && console.info(`BetterYTM: Captured valid key but the current active element is <${document.activeElement!.tagName.toLowerCase()}>, so the keypress is ignored`);
+
+      dbg && console.log(`BetterYTM: Captured key '${evt.code}' in proxy listener`);
+
+      // ripped this stuff from the console, most of these are probably unnecessary but this was finnicky af and I am sick and tired of trial and error
+      const defaultProps = {
+        altKey: false,
+        ctrlKey: false,
+        metaKey: false,
+        shiftKey: false,
+        target: document.body,
+        currentTarget: document.body,
+        originalTarget: document.body,
+        explicitOriginalTarget: document.body,
+        srcElement: document.body,
+        type: "keydown",
+        bubbles: true,
+        cancelBubble: false,
+        cancelable: true,
+        isTrusted: true,
+        repeat: false,
+      };
+
+      let invalidKey = false;
+      let keyProps = {};
+
+      switch(evt.code) {
+      case "ArrowLeft":
+        keyProps = {
+          code: "KeyH",
+          key: "h",
+          keyCode: 72,
+          which: 72,
+        };
+        break;
+      case "ArrowRight":
+        keyProps = {
+          code: "KeyL",
+          key: "l",
+          keyCode: 76,
+          which: 76,
+        };
+        break;
+      default:
+        invalidKey = true;
+        break;
+      }
+
+      if(!invalidKey) {
+        // TODO: check if the code prop is correct
+        const proxyProps = { code: "", ...defaultProps, ...keyProps };
+
+        document.body.dispatchEvent(new KeyboardEvent("keydown", proxyProps));
+
+        dbg && console.log(`BetterYTM: Dispatched proxy keydown event: [${evt.code}] -> [${proxyProps.code}]`);
+      }
+      else if(dbg)
+        console.warn(`BetterYTM: Captured key '${evt.code}' has no defined behavior`);
+    }
+  }
+
+  //#SECTION site switch
+
+  /** Initializes the site switch feature */
+  function initSiteSwitch(domain: Domain) {
+    // TODO:
+    // extra features:
+    // - keep video time
+
+    document.addEventListener("keydown", (e) => {
+      if(e.key == "F9")
+        switchSite(domain === "yt" ? "ytm" : "yt");
+    });
+    dbg && console.log("BetterYTM: Initialized site switch listener");
+  }
+
+  /** Switches to the other site (between YT and YTM) */
+  function switchSite(newDomain: Domain) {
+    try {
+      let subdomain;
+      if(newDomain === "ytm")
+        subdomain = "music";
+      else if(newDomain === "yt")
+        subdomain = "www";
+
+      if(!subdomain)
+        throw new TypeError(`Unrecognized domain '${newDomain}'`);
+
+
+      const { pathname, search, hash } = new URL(location.href);
+
+      const vt = getVideoTime() ?? 0;
+
+      dbg && console.log(`BetterYTM: Found video time of ${vt} seconds`);
+
+      const newSearch = search.includes("?") ? `${search}&t=${vt}` : `?t=${vt}`;
+
+      const url = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
+
+      console.info(`BetterYTM - switching to domain '${newDomain}' at ${url}`);
+
+      location.href = url;
+    }
+    catch(err) {
+      console.error("BetterYTM: Error while switching site:", err);
+    }
+  }
+
+  //#SECTION remove upgrade tab
+
+  let removeUpgradeTries = 0;
+
+  /** Removes the "Upgrade" / YT Music Premium tab from the title / nav bar */
+  function removeUpgradeTab() {
+    const tabElem = document.querySelector(".ytmusic-nav-bar ytmusic-pivot-bar-item-renderer[tab-id=\"SPunlimited\"]");
+    if(tabElem) {
+      tabElem.remove();
+      dbg && console.log(`BetterYTM: Removed upgrade tab after ${removeUpgradeTries} tries`);
+    }
+    else if(removeUpgradeTries < triesLimit) {
+      setTimeout(removeUpgradeTab, triesInterval); // TODO: improve this
+      removeUpgradeTries++;
+    }
+    else
+      console.error(`BetterYTM: Couldn't find upgrade tab to remove after ${removeUpgradeTries} tries`);
+  }
+
+  //#SECTION add watermark
+
+  /**
+ * Adds a watermark beneath the logo
+ */
+  function addWatermark()
+  {
+    const watermark = document.createElement("span");
+    watermark.id = "betterytm-watermark";
+    watermark.className = "style-scope ytmusic-nav-bar";
+    watermark.innerText = info.name;
+    watermark.title = "Open menu";
+
+    watermark.addEventListener("click", () => openMenu());
+
+
+    const style = `\
+    #betterytm-watermark {
+        font-size: 10px;
+        display: inline-block;
+        position: absolute;
+        left: 45px;
+        top: 46px;
+        z-index: 10;
+        color: white;
+        text-decoration: none;
+        cursor: pointer;
+    }
+
+    @media(max-width: 615px) {
+        #betterytm-watermark {
+            display: none;
+        }
+    }
+
+    #betterytm-watermark:hover {
+        text-decoration: underline;
+    }`;
+
+    addGlobalStyle(style, "watermark");
+
+
+    const logoElem = document.querySelector("#left-content") as HTMLElement;
+    insertAfter(logoElem, watermark);
+
+
+    dbg && console.log("BetterYTM: Added watermark element:", watermark);
+  }
+
+  //#SECTION genius.com lyrics button
+
+  let mcCurrentSongTitle = "";
+  let mcLyricsButtonAddTries = 0;
+
+  /**
+   * Adds a genius.com lyrics button to the media controls bar
+   */
+  async function addMediaCtrlGeniusBtn(): Promise<unknown> {
+    const likeContainer = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer") as HTMLElement;
+
+    if(!likeContainer) {
+      mcLyricsButtonAddTries++;
+      if(mcLyricsButtonAddTries < triesLimit)
+        return setTimeout(addMediaCtrlGeniusBtn, triesInterval); // TODO: improve this
+
+      return console.error(`BetterYTM: Couldn't find element to append lyrics buttons to after ${mcLyricsButtonAddTries} tries`);
+    }
+
+    const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string") as HTMLDivElement;
+
+
+    const gUrl = await getCurrentGeniusUrl();
+
+    const linkElem = document.createElement("a");
+    linkElem.id = "betterytm-lyrics-button";
+    linkElem.className = "ytmusic-player-bar";
+    linkElem.title = gUrl ? "Click to open this song's lyrics in a new tab" : "Loading...";
+    if(gUrl)
+      linkElem.href = gUrl;
+    linkElem.target = "_blank";
+    linkElem.rel = "noopener noreferrer";
+    linkElem.style.visibility = gUrl ? "initial" : "hidden";
+    linkElem.style.display = gUrl ? "inline-flex" : "none";
+
+    const style = `\
+    #betterytm-lyrics-button {
+      align-items: center;
+      justify-content: center;
+      position: relative;
+      vertical-align: middle;
+
+      margin-left: 8px;
+      width: 40px;
+      height: 40px;
+      border-radius: 100%;
+      background-color: transparent;
+    }
+
+    #betterytm-lyrics-button:hover {
+      background-color: #383838;
+    }
+
+    #betterytm-lyrics-img {
+      display: inline-block;
+      z-index: 10;
+      width: 24px;
+      height: 24px;
+      padding: 5px;
+    }`;
+
+    addGlobalStyle(style, "lyrics");
+
+
+    const imgElem = document.createElement("img");
+    imgElem.id = "betterytm-lyrics-img";
+    imgElem.src = "https://raw.githubusercontent.com/Sv443/BetterYTM/main/resources/external/genius.png";
+
+    linkElem.appendChild(imgElem);
+
+    dbg && console.log(`BetterYTM: Inserted genius button after ${mcLyricsButtonAddTries} tries:`, linkElem);
+
+    insertAfter(likeContainer, linkElem);
+
+
+    mcCurrentSongTitle = songTitleElem.title;
+
+    const onMutation = async (mutations: MutationRecord[]) => {
+      for await(const mut of mutations) {
+        const newTitle = (mut.target as HTMLElement).title;
+
+        if(newTitle != mcCurrentSongTitle && newTitle.length > 0) {
+          const lyricsBtn = document.querySelector("#betterytm-lyrics-button") as HTMLAnchorElement;
+
+          if(!lyricsBtn)
+            return;
+
+          dbg && console.log(`BetterYTM: Song title changed from '${mcCurrentSongTitle}' to '${newTitle}'`);
+
+          lyricsBtn.style.cursor = "wait";
+          lyricsBtn.style.pointerEvents = "none";
+
+          mcCurrentSongTitle = newTitle;
+
+          const url = await getCurrentGeniusUrl(); // can take a second or two
+          if(!url)
+            continue;
+
+          lyricsBtn.href = url;
+
+          lyricsBtn.title = "Click to open this song's lyrics in a new tab";
+          lyricsBtn.style.cursor = "pointer";
+          lyricsBtn.style.visibility = "initial";
+          lyricsBtn.style.display = "inline-flex";
+          lyricsBtn.style.pointerEvents = "initial";
+        }
+      }
+    };
+
+    // since YT and YTM don't reload the page on video change, MutationObserver needs to be used to watch for changes in the video title
+    const obs = new MutationObserver(onMutation);
+
+    obs.observe(songTitleElem, { attributes: true, attributeFilter: [ "title" ] });
+  }
+
+
+  /**
+   * Adds genius lyrics buttons to the song queue
+   */
+  async function addQueueGeniusBtns()
+  {
+    // TODO:
+  }
+
+  /**
+   * Returns the genius.com lyrics site URL for the current song
+   */
+  async function getCurrentGeniusUrl() {
+    try {
+      // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
+      const isVideo = typeof document.querySelector("ytmusic-player")?.getAttribute("video-mode_") === "string";
+
+      const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string") as HTMLElement;
+      const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string:first-child") as HTMLElement;
+
+      if(!songTitleElem || !songMetaElem || !songTitleElem.title)
+        return null;
+
+      const sanitizeSongName = (songName: string) => {
+        const parensRegex = /\(.+\)/gmi;
+        const squareParensRegex = /\[.+\]/gmi;
+
+        // trim right after the song name:
+        const sanitized = songName
+          .replace(parensRegex, "")
+          .replace(squareParensRegex, "");
+
+        return sanitized.trim();
+      };
+
+      const splitArtist = (songMeta: string) => {
+        songMeta = songMeta.split(/\s*\u2022\s*/gmiu)[0]; // split at bullet (&bull; / •) character
+
+        if(songMeta.match(/&/))
+          songMeta = songMeta.split(/\s*&\s*/gm)[0];
+
+        if(songMeta.match(/,/))
+          songMeta = songMeta.split(/,\s*/gm)[0];
+
+        return songMeta.trim();
+      };
+
+      const songNameRaw = songTitleElem.title;
+      const songName = sanitizeSongName(songNameRaw);
+
+      const artistName = splitArtist(songMetaElem.title);
+
+      /** Use when the current song is not a "real YTM song" with a static background, but rather a music video */
+      const getGeniusUrlVideo = async () => {
+        if(!songName.includes("-")) // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
+          return await getGeniusUrl(artistName, songName);
+
+        const [artist, ...rest] = songName.split("-").map(v => v.trim());
+
+        return await getGeniusUrl(artist, rest.join(" "));
+      };
+
+      // TODO: artist might need further splitting before comma or ampersand
+
+      const url = isVideo ? await getGeniusUrlVideo() : (await getGeniusUrl(artistName, songName) ?? await getGeniusUrlVideo());
+
+      return url;
+    }
+    catch(err)
+    {
+      console.error("BetterYTM: Couldn't resolve genius.com URL:", err);
+      return null;
+    }
+  }
+
+  /**
+   * @param artist
+   * @param song
+   */
+  async function getGeniusUrl(artist: string, song: string): Promise<string | undefined> {
+    try {
+      const fetchUrl = `${geniURLSearchTopUrl}?artist=${encodeURIComponent(artist)}&song=${encodeURIComponent(song)}`;
+
+      dbg && console.log(`BetterYTM: Requesting URL from geniURL at '${fetchUrl}'`);
+
+      const result = await (await fetch(fetchUrl)).json();
+
+      if(result.error)
+      {
+        console.error("BetterYTM: Couldn't fetch genius.com URL:", result.message);
+        return undefined;
+      }
+
+      const url = result.url;
+
+      dbg && console.info(`BetterYTM: Found genius URL: ${url}`);
+
+      return url;
+    }
+    catch(err) {
+      console.error("BetterYTM: Couldn't get genius URL due to error:", err);
+      return undefined;
+    }
+  }
+
+  // #SECTION volume slider
+
+  /**
+   * Sets the volume slider to a set size
+   */
+  function setVolSliderSize() {
+    const { volumeSliderSize: size } = features;
+
+    if(typeof size !== "number" || isNaN(Number(size)))
+      return;
+
+    const style = `\
+.volume-slider.ytmusic-player-bar, .expand-volume-slider.ytmusic-player-bar {
+    width: ${size}px !important;
+}`;
+
+    addGlobalStyle(style, "vol_slider_size");
+  }
+
+  /** Sets the `step` attribute of the volume slider */
+  function setVolSliderStep() {
+    const sliderElem = document.querySelector("tp-yt-paper-slider#volume-slider") as HTMLInputElement;
+
+    sliderElem.setAttribute("step", String(features.volumeSliderStep));
+  }
+
+  //#MARKER other
+
+
+  /**
+   * Returns the current domain as a constant string representation
+   * @throws {Error} If script runs on an unexpected website
+   * @returns {Domain}
+   */
+  function getDomain() {
+    const { hostname } = new URL(location.href);
+
+    if(hostname.includes("music.youtube"))
+      return "ytm";
+    else if(hostname.includes("youtube"))
+      return "yt";
+    else
+      throw new Error("BetterYTM is running on an unexpected website");
+  }
+
+  /**
+   * TODO: this is entirely broken now
+   * Returns the current video time in seconds
+   * @returns {number|null} Returns null if the video time is unavailable
+   */
+  function getVideoTime() {
+    const domain = getDomain();
+
+    try {
+      if(domain === "ytm") {
+        const pbEl = document.querySelector("#progress-bar") as HTMLProgressElement;
+        return pbEl.value ?? null;
+      }
+      else if(domain === "yt") // YT doesn't update the progress bar when it's hidden (YTM doesn't hide it) so TODO: come up with some solution here
+        return 0;
+
+      return null;
+    }
+    catch(err) {
+      console.error("BetterYTM: Couldn't get video time due to error:", err);
+      return null;
+    }
+  }
+
+  /**
+   * Inserts `afterNode` as a sibling just after the provided `beforeNode`
+   * @param beforeNode
+   * @param afterNode
+   * @returns Returns the `afterNode`
+   */
+  function insertAfter(beforeNode: HTMLElement, afterNode: HTMLElement) {
+    beforeNode.parentNode?.insertBefore(afterNode, beforeNode.nextSibling);
+    return afterNode;
+  }
+
+  /**
+   * Adds global CSS style through a `<style>` element in the document's `<head>`
+   * @param style CSS string
+   * @param ref Reference name that is included in the `<style>`'s ID - defaults to a random number if left undefined
+   */
+  function addGlobalStyle(style: string, ref: string) {
+    if(typeof ref !== "string" || ref.length === 0)
+      ref = String(Math.floor(Math.random() * 10000));
+
+    const styleElem = document.createElement("style");
+    styleElem.id = `betterytm-style-${ref}`;
+    styleElem.innerHTML = style;
+    document.querySelector("head")!.appendChild(styleElem);
+
+    dbg && console.log(`BetterYTM: Inserted global style with ref '${ref}':`, styleElem);
+  }
+
+  //#SECTION feature config
+
+  /** Loads a feature configuration saved persistently, returns an empty object if no feature configuration was saved */
+  async function loadFeatureConf(): Promise<FeatureConfig> {
+    const defConf = Object.freeze({...defaultFeatures});
+
+    try {
+      /** @type {string} */
+      const featureConf = await GM.getValue("betterytm-config");
+
+      if(typeof featureConf !== "string") {
+        await setDefaultFeatConf();
+        return defConf;
+      }
+
+      return Object.freeze(featureConf ? JSON.parse(featureConf) : {});
+    }
+    catch(err) {
+      await setDefaultFeatConf();
+      return defConf;
+    }
+  }
+
+  /**
+   * Saves a feature configuration saved persistently
+   * @param featureConf
+   */
+  function saveFeatureConf(featureConf: FeatureConfig) {
+    if(!featureConf || typeof featureConf != "object")
+      throw new TypeError("Feature config not provided or invalid");
+
+    return GM.setValue("betterytm-config", JSON.stringify(featureConf));
+  }
+
+  function setDefaultFeatConf() {
+    return GM.setValue("betterytm-config", JSON.stringify(defaultFeatures));
+  }
+
+  init(); // call init() when script is loaded
+})();

+ 31 - 0
src/types.d.ts

@@ -0,0 +1,31 @@
+/**
+ * Import HTML as modules
+ * https://stackoverflow.com/a/47705264/3323672
+ */
+declare module "*.html" {
+  const content: string;
+  export default content;
+}
+
+/** Which domain this script is currently running on */
+export type Domain = "yt" | "ytm";
+
+/** Feature configuration */
+export interface FeatureConfig {
+  /** Arrow keys skip forwards and backwards by 10 seconds */
+  arrowKeySupport: boolean;
+  /** Remove the \"Upgrade\" / YT Music Premium tab */
+  removeUpgradeTab: boolean;
+  /** Add F9 as a hotkey to switch between the YT and YTM sites on a video / song */
+  switchBetweenSites: boolean;
+  /** Add a button to the media controls to open the current song's lyrics on genius.com in a new tab */
+  geniusLyrics: boolean;
+  /** TODO: Add a lyrics button to each song in the queue (\"up next\" tab) */
+  lyricsButtonsOnSongQueue: boolean;
+  /** The width of the volume slider in pixels */
+  volumeSliderSize: number;
+  /** Volume slider sensitivity - the smaller this number, the finer the volume control */
+  volumeSliderStep: number;
+  /** Show a BetterYTM watermark under the YTM logo */
+  watermarkEnabled: boolean;
+}

+ 48 - 0
tools/post-build.js

@@ -0,0 +1,48 @@
+const { readFile, writeFile } = require("fs/promises");
+const package = require("../package.json");
+
+const userscriptName = "BetterYTM.user.js";
+const url = package.repository.url.replace("git+", "").replace(".git", "");
+const scriptUrl = package.repository.url.replace("git+", "").replace(".git", "") + "/raw/main/" + userscriptName;
+
+const header = `// ==UserScript==
+// @name            BetterYTM
+// @homepageURL     ${package.homepage}
+// @namespace       ${url}
+// @version         ${package.version}
+// @description     Improvements for YouTube Music
+// @description:de  Verbesserungen für YouTube Music
+// @license         ${package.license}
+// @author          ${package.author.name}
+// @copyright       ${package.author.name} <${package.author.email}>
+// @match           https://music.youtube.com/*
+// @match           https://www.youtube.com/*
+// @icon            https://raw.githubusercontent.com/Sv443/BetterYTM/main/resources/icon/v2.1_200.png
+// @run-at          document-start
+// @grant           GM.getValue
+// @grant           GM.setValue
+// @connect         self
+// @connect         youtube.com
+// @connect         github.com
+// @connect         githubusercontent.com
+// @downloadURL     ${scriptUrl}
+// @updateURL       ${scriptUrl}
+// ==/UserScript==
+
+/* Disclaimer: I am not affiliated with YouTube, Google, Alphabet, Genius or anyone else */
+/* C&D this 🖕 */
+`;
+
+(async () => {
+  try {
+    const path = `./${userscriptName}`;
+    const input = String(await readFile(path));
+    await writeFile(path, `${header}\n${input}${input.endsWith("\n") ? "" : "\n"}`);
+    console.info("Successfully added the userscript header!");
+  }
+  catch(err) {
+    console.error("Error while adding userscript header:");
+    console.error(err);
+    process.exit(1);
+  }
+})();

+ 23 - 9
tsconfig.json

@@ -1,11 +1,25 @@
 {
-    "compilerOptions": {
-        "module": "None",
-        "allowJs": true,
-        "noEmit": true,
-        "lib": [
-            "DOM",
-            "DOM.Iterable"
-        ]
-    }
+  "compilerOptions": {
+    "module": "ES6",
+    "target": "ES2016",
+    "outDir": ".",
+    "allowJs": true,
+    "lib": [
+      "DOM",
+      "DOM.Iterable"
+    ],
+    "allowSyntheticDefaultImports": true,
+    "baseUrl": ".",
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "isolatedModules": true,
+    "rootDir": ".",
+    "skipLibCheck": true,
+    "sourceMap": true,
+    "strict": true,
+    "useDefineForClassFields": true,
+  },
+  "exclude": [
+    "**/*.js",
+  ]
 }

+ 45 - 0
webpack.config.js

@@ -0,0 +1,45 @@
+const path = require("path");
+const { exec } = require("child_process");
+const package = require("./package.json");
+
+module.exports = {
+  entry: "./src/BetterYTM.user.ts",
+  mode: "production",
+  module: {
+    rules: [{
+      test: /\.tsx?$/,
+      use: "ts-loader",
+      exclude: /node_modules/,
+    },
+    {
+      test: /\.html$/i,
+      loader: "html-loader",
+    },
+    {
+      test: /\.s[ac]ss$/i,
+      use: [
+        "style-loader",
+        "css-loader",
+        "sass-loader",
+      ],
+    },
+    ],
+  },
+  plugins: [{
+    apply: (compiler) => {
+      compiler.hooks.afterEmit.tap("AfterEmitPlugin", () => {
+        exec("node ./tools/post-build.js", (_err, stdout, stderr) => {
+          stdout && process.stdout.write(`${stdout}\n`);
+          stderr && process.stderr.write(`${stderr}\n`);
+        });
+      });
+    }
+  }],
+  resolve: {
+    extensions: [".ts", ".js"],
+  },
+  output: {
+    filename: "BetterYTM.user.js",
+    path: path.resolve(__dirname),
+  },
+};

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно