فهرست منبع

ref!: split single file into 7

Sv443 1 سال پیش
والد
کامیت
f94cbbe683
14فایلهای تغییر یافته به همراه1319 افزوده شده و 1066 حذف شده
  1. 0 1
      .github/workflows/lint.yml
  2. 3 0
      .vscode/settings.json
  3. 1 1
      BetterYTM.user.js
  4. 284 27
      package-lock.json
  5. 2 1
      package.json
  6. 35 1035
      src/BetterYTM.user.ts
  7. 63 0
      src/config.ts
  8. 52 0
      src/features/index.ts
  9. 123 0
      src/features/input.ts
  10. 465 0
      src/features/layout.ts
  11. 219 0
      src/features/lyrics.ts
  12. 68 0
      src/utils.ts
  13. 1 1
      tools/post-build.js
  14. 3 0
      webpack.config.js

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

@@ -13,7 +13,6 @@ jobs:
     timeout-minutes: 10
 
     strategy:
-      fail-fast: false
       matrix:
         node-version: [18.x]
 

+ 3 - 0
.vscode/settings.json

@@ -0,0 +1,3 @@
+{
+    "javascript.preferences.importModuleSpecifier": "relative"
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
BetterYTM.user.js


+ 284 - 27
package-lock.json

@@ -14,6 +14,7 @@
         "@typescript-eslint/eslint-plugin": "^5.59.7",
         "@typescript-eslint/parser": "^5.59.7",
         "eslint": "^7.32.0",
+        "html-loader": "^4.2.0",
         "http-server": "^14.1.1",
         "nodemon": "^2.0.22",
         "ts-loader": "^9.4.3",
@@ -238,7 +239,6 @@
       "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
       "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "@jridgewell/set-array": "^1.0.1",
         "@jridgewell/sourcemap-codec": "^1.4.10",
@@ -262,7 +262,6 @@
       "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
       "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
       "dev": true,
-      "peer": true,
       "engines": {
         "node": ">=6.0.0"
       }
@@ -272,7 +271,6 @@
       "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.3.tgz",
       "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "@jridgewell/gen-mapping": "^0.3.0",
         "@jridgewell/trace-mapping": "^0.3.9"
@@ -289,7 +287,6 @@
       "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz",
       "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "@jridgewell/resolve-uri": "3.1.0",
         "@jridgewell/sourcemap-codec": "1.4.14"
@@ -1073,8 +1070,7 @@
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "node_modules/call-bind": {
       "version": "1.0.2",
@@ -1098,6 +1094,16 @@
         "node": ">=6"
       }
     },
+    "node_modules/camel-case": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
+      "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
+      "dev": true,
+      "dependencies": {
+        "pascal-case": "^3.1.2",
+        "tslib": "^2.0.3"
+      }
+    },
     "node_modules/caniuse-lite": {
       "version": "1.0.30001489",
       "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz",
@@ -1172,6 +1178,18 @@
         "node": ">=6.0"
       }
     },
+    "node_modules/clean-css": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz",
+      "integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==",
+      "dev": true,
+      "dependencies": {
+        "source-map": "~0.6.0"
+      },
+      "engines": {
+        "node": ">= 10.0"
+      }
+    },
     "node_modules/clone-deep": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -1214,8 +1232,7 @@
       "version": "2.20.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
       "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "node_modules/concat-map": {
       "version": "0.0.1",
@@ -1308,6 +1325,16 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/dot-case": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
+      "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
+      "dev": true,
+      "dependencies": {
+        "no-case": "^3.0.4",
+        "tslib": "^2.0.3"
+      }
+    },
     "node_modules/electron-to-chromium": {
       "version": "1.4.408",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.408.tgz",
@@ -1346,6 +1373,18 @@
         "node": ">=8.6"
       }
     },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/envinfo": {
       "version": "7.8.1",
       "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz",
@@ -1938,6 +1977,56 @@
         "node": ">=12"
       }
     },
+    "node_modules/html-loader": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-4.2.0.tgz",
+      "integrity": "sha512-OxCHD3yt+qwqng2vvcaPApCEvbx+nXWu+v69TYHx1FO8bffHn/JjHtE3TTQZmHjwvnJe4xxzuecetDVBrQR1Zg==",
+      "dev": true,
+      "dependencies": {
+        "html-minifier-terser": "^7.0.0",
+        "parse5": "^7.0.0"
+      },
+      "engines": {
+        "node": ">= 14.15.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "webpack": "^5.0.0"
+      }
+    },
+    "node_modules/html-minifier-terser": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
+      "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==",
+      "dev": true,
+      "dependencies": {
+        "camel-case": "^4.1.2",
+        "clean-css": "~5.3.2",
+        "commander": "^10.0.0",
+        "entities": "^4.4.0",
+        "param-case": "^3.0.4",
+        "relateurl": "^0.2.7",
+        "terser": "^5.15.1"
+      },
+      "bin": {
+        "html-minifier-terser": "cli.js"
+      },
+      "engines": {
+        "node": "^14.13.1 || >=16.0.0"
+      }
+    },
+    "node_modules/html-minifier-terser/node_modules/commander": {
+      "version": "10.0.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+      "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/http-proxy": {
       "version": "1.18.1",
       "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
@@ -2302,6 +2391,15 @@
       "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
       "dev": true
     },
+    "node_modules/lower-case": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
+      "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
+      "dev": true,
+      "dependencies": {
+        "tslib": "^2.0.3"
+      }
+    },
     "node_modules/lru-cache": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -2442,6 +2540,16 @@
       "dev": true,
       "peer": true
     },
+    "node_modules/no-case": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
+      "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
+      "dev": true,
+      "dependencies": {
+        "lower-case": "^2.0.2",
+        "tslib": "^2.0.3"
+      }
+    },
     "node_modules/node-releases": {
       "version": "2.0.12",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.12.tgz",
@@ -2620,6 +2728,16 @@
         "node": ">=6"
       }
     },
+    "node_modules/param-case": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
+      "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
+      "dev": true,
+      "dependencies": {
+        "dot-case": "^3.0.4",
+        "tslib": "^2.0.3"
+      }
+    },
     "node_modules/parent-module": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -2632,6 +2750,28 @@
         "node": ">=6"
       }
     },
+    "node_modules/parse5": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
+      "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
+      "dev": true,
+      "dependencies": {
+        "entities": "^4.4.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/pascal-case": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
+      "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
+      "dev": true,
+      "dependencies": {
+        "no-case": "^3.0.4",
+        "tslib": "^2.0.3"
+      }
+    },
     "node_modules/path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -2842,6 +2982,15 @@
         "url": "https://github.com/sponsors/mysticatea"
       }
     },
+    "node_modules/relateurl": {
+      "version": "0.2.7",
+      "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
+      "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
     "node_modules/require-from-string": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3128,7 +3277,6 @@
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
       "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
       "dev": true,
-      "peer": true,
       "engines": {
         "node": ">=0.10.0"
       }
@@ -3138,7 +3286,6 @@
       "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
       "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "buffer-from": "^1.0.0",
         "source-map": "^0.6.0"
@@ -3265,7 +3412,6 @@
       "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.6.tgz",
       "integrity": "sha512-V8QHcs8YuyLkLHsJO5ucyff1ykrLVsR4dNnS//L5Y3NiSXpbK1J+WMVUs67eI0KTxs9JtHhgEQpXQVHlHI92DQ==",
       "dev": true,
-      "peer": true,
       "dependencies": {
         "@jridgewell/source-map": "^0.3.2",
         "acorn": "^8.5.0",
@@ -3319,7 +3465,6 @@
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
       "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
       "dev": true,
-      "peer": true,
       "bin": {
         "acorn": "bin/acorn"
       },
@@ -3971,7 +4116,6 @@
       "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
       "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
       "dev": true,
-      "peer": true,
       "requires": {
         "@jridgewell/set-array": "^1.0.1",
         "@jridgewell/sourcemap-codec": "^1.4.10",
@@ -3988,15 +4132,13 @@
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
       "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "@jridgewell/source-map": {
       "version": "0.3.3",
       "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.3.tgz",
       "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==",
       "dev": true,
-      "peer": true,
       "requires": {
         "@jridgewell/gen-mapping": "^0.3.0",
         "@jridgewell/trace-mapping": "^0.3.9"
@@ -4013,7 +4155,6 @@
       "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz",
       "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==",
       "dev": true,
-      "peer": true,
       "requires": {
         "@jridgewell/resolve-uri": "3.1.0",
         "@jridgewell/sourcemap-codec": "1.4.14"
@@ -4610,8 +4751,7 @@
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
       "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "call-bind": {
       "version": "1.0.2",
@@ -4629,6 +4769,16 @@
       "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
       "dev": true
     },
+    "camel-case": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
+      "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
+      "dev": true,
+      "requires": {
+        "pascal-case": "^3.1.2",
+        "tslib": "^2.0.3"
+      }
+    },
     "caniuse-lite": {
       "version": "1.0.30001489",
       "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz",
@@ -4669,6 +4819,15 @@
       "dev": true,
       "peer": true
     },
+    "clean-css": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz",
+      "integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==",
+      "dev": true,
+      "requires": {
+        "source-map": "~0.6.0"
+      }
+    },
     "clone-deep": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -4705,8 +4864,7 @@
       "version": "2.20.3",
       "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
       "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "concat-map": {
       "version": "0.0.1",
@@ -4776,6 +4934,16 @@
         "esutils": "^2.0.2"
       }
     },
+    "dot-case": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
+      "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
+      "dev": true,
+      "requires": {
+        "no-case": "^3.0.4",
+        "tslib": "^2.0.3"
+      }
+    },
     "electron-to-chromium": {
       "version": "1.4.408",
       "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.408.tgz",
@@ -4808,6 +4976,12 @@
         "ansi-colors": "^4.1.1"
       }
     },
+    "entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "dev": true
+    },
     "envinfo": {
       "version": "7.8.1",
       "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz",
@@ -5247,6 +5421,39 @@
         "whatwg-encoding": "^2.0.0"
       }
     },
+    "html-loader": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-4.2.0.tgz",
+      "integrity": "sha512-OxCHD3yt+qwqng2vvcaPApCEvbx+nXWu+v69TYHx1FO8bffHn/JjHtE3TTQZmHjwvnJe4xxzuecetDVBrQR1Zg==",
+      "dev": true,
+      "requires": {
+        "html-minifier-terser": "^7.0.0",
+        "parse5": "^7.0.0"
+      }
+    },
+    "html-minifier-terser": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
+      "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==",
+      "dev": true,
+      "requires": {
+        "camel-case": "^4.1.2",
+        "clean-css": "~5.3.2",
+        "commander": "^10.0.0",
+        "entities": "^4.4.0",
+        "param-case": "^3.0.4",
+        "relateurl": "^0.2.7",
+        "terser": "^5.15.1"
+      },
+      "dependencies": {
+        "commander": {
+          "version": "10.0.1",
+          "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+          "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+          "dev": true
+        }
+      }
+    },
     "http-proxy": {
       "version": "1.18.1",
       "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
@@ -5529,6 +5736,15 @@
       "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
       "dev": true
     },
+    "lower-case": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
+      "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
+      "dev": true,
+      "requires": {
+        "tslib": "^2.0.3"
+      }
+    },
     "lru-cache": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -5639,6 +5855,16 @@
       "dev": true,
       "peer": true
     },
+    "no-case": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
+      "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
+      "dev": true,
+      "requires": {
+        "lower-case": "^2.0.2",
+        "tslib": "^2.0.3"
+      }
+    },
     "node-releases": {
       "version": "2.0.12",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.12.tgz",
@@ -5770,6 +5996,16 @@
       "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
       "dev": true
     },
+    "param-case": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
+      "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
+      "dev": true,
+      "requires": {
+        "dot-case": "^3.0.4",
+        "tslib": "^2.0.3"
+      }
+    },
     "parent-module": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5779,6 +6015,25 @@
         "callsites": "^3.0.0"
       }
     },
+    "parse5": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
+      "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
+      "dev": true,
+      "requires": {
+        "entities": "^4.4.0"
+      }
+    },
+    "pascal-case": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
+      "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
+      "dev": true,
+      "requires": {
+        "no-case": "^3.0.4",
+        "tslib": "^2.0.3"
+      }
+    },
     "path-exists": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5926,6 +6181,12 @@
       "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
       "dev": true
     },
+    "relateurl": {
+      "version": "0.2.7",
+      "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
+      "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
+      "dev": true
+    },
     "require-from-string": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -6119,15 +6380,13 @@
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
       "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     "source-map-support": {
       "version": "0.5.21",
       "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
       "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
       "dev": true,
-      "peer": true,
       "requires": {
         "buffer-from": "^1.0.0",
         "source-map": "^0.6.0"
@@ -6225,7 +6484,6 @@
       "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.6.tgz",
       "integrity": "sha512-V8QHcs8YuyLkLHsJO5ucyff1ykrLVsR4dNnS//L5Y3NiSXpbK1J+WMVUs67eI0KTxs9JtHhgEQpXQVHlHI92DQ==",
       "dev": true,
-      "peer": true,
       "requires": {
         "@jridgewell/source-map": "^0.3.2",
         "acorn": "^8.5.0",
@@ -6237,8 +6495,7 @@
           "version": "8.8.2",
           "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
           "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
-          "dev": true,
-          "peer": true
+          "dev": true
         }
       }
     },

+ 2 - 1
package.json

@@ -30,6 +30,7 @@
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
     "eslint": "^7.32.0",
+    "html-loader": "^4.2.0",
     "http-server": "^14.1.1",
     "nodemon": "^2.0.22",
     "ts-loader": "^9.4.3",
@@ -54,4 +55,4 @@
       "dev/*"
     ]
   }
-}
+}

+ 35 - 1035
src/BetterYTM.user.ts

@@ -1,133 +1,48 @@
-import type { Domain, FeatureConfig } from "./types";
+import { getFeatures } from "./config";
+import { addMediaCtrlGeniusBtn, addMenu, addQueueGeniusBtns, addWatermark, geniUrlBase, initArrowKeySkip, initLayout, initSiteSwitch, removeUpgradeTab, setVolSliderSize, setVolSliderStep } from "./features/index";
+import { getDomain } from "./utils";
+
+/** Set to true to enable debug mode for more output in the JS console */
+export const dbg = true;
+/** Specifies the hard limit for repetitive tasks */
+export const triesLimit = 50;
+/** Specifies the interval for repetitive tasks */
+export const triesInterval = 150;
+
+/** Info about the userscript, parsed from the userscript header (tools/post-build.js) */
+export const info = Object.freeze({
+  name: GM.info.script.name,
+  version: GM.info.script.version,
+  namespace: GM.info.script.namespace,
+});
 
 (async () => {
-  /** Set to true to enable debug mode for more output in the JS console */
-  const dbg = true;
-  const branch = dbg ? "develop" : "main";
-
-  /** 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
 
+  const features = await getFeatures();
 
-  /** 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";
+  await initLayout();
 
-  /** 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`;
+  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}`);
 
-  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);
-    }
+    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()
-  {
+  /** 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")
-      {
+    try {
+      if(domain === "ytm") {
         if(features.arrowKeySupport)
-        {
-          document.addEventListener("keydown", onKeyDown);
-          dbg && console.log("BetterYTM: Added key press listener");
-        }
+          initArrowKeySkip();
 
         if(features.removeUpgradeTab)
           removeUpgradeTab();
@@ -147,935 +62,20 @@ import type { Domain, FeatureConfig } from "./types";
         setVolSliderStep();
       }
 
-      if(["ytm", "yt"].includes(domain))
-      {
+      if(["ytm", "yt"].includes(domain)) {
         if(features.switchBetweenSites)
           initSiteSwitch(domain);
 
-        try
-        {
+        try {
           addMenu();
         }
-        catch(err)
-        {
+        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;
+      console.error("BetterYTM: General error while executing feature:", err);
     }
   }
-
-  /**
-   * 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
 })();

+ 63 - 0
src/config.ts

@@ -0,0 +1,63 @@
+import { featInfo } from "./features/index";
+import { FeatureConfig } from "./types";
+
+export 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;
+
+let featuresCache: FeatureConfig | undefined;
+
+/**
+ * Returns the current FeatureConfig in memory or reads it from GM storage  
+ * Automatically applies defaults for non-existant keys
+ * @param forceRead Set to true to force reading the config from GM storage
+ */
+export async function getFeatures(forceRead = false) {
+  let features: FeatureConfig | undefined;
+
+  if(featuresCache === undefined || forceRead) {
+    const featureConf = await loadFeatureConf();
+
+    featuresCache = features = { ...defaultFeatures, ...featureConf };
+
+    await saveFeatureConf(features);
+  }
+  return featuresCache;
+}
+
+/** Loads a feature configuration saved persistently, returns an empty object if no feature configuration was saved */
+export 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 featuresCache = defConf;
+    }
+
+    return Object.freeze(featureConf ? JSON.parse(featureConf) : {});
+  }
+  catch(err) {
+    await setDefaultFeatConf();
+    return featuresCache = defConf;
+  }
+}
+
+/**
+ * Saves a feature configuration saved persistently
+ * @param featureConf
+ */
+export 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));
+}

+ 52 - 0
src/features/index.ts

@@ -0,0 +1,52 @@
+export * from "./input";
+export * from "./layout";
+export * from "./lyrics";
+
+/** Contains all possible features with their default values and other config */
+export const featInfo = {
+  arrowKeySupport: { // category: input
+    desc: "Arrow keys skip forwards and backwards by 10 seconds",
+    type: "toggle",
+    default: true,
+  },
+  removeUpgradeTab: { // category: layout
+    desc: "Remove the \"Upgrade\" / YT Music Premium tab",
+    type: "toggle",
+    default: true,
+  },
+  switchBetweenSites: { // category: input
+    desc: "Add F9 as a hotkey to switch between the YT and YTM sites on a video / song",
+    type: "toggle",
+    default: true,
+  },
+  geniusLyrics: { // category: lyrics
+    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: { // category: lyrics
+    desc: "TODO: Add a lyrics button to each song in the queue (\"up next\" tab)",
+    type: "toggle",
+    default: true,
+  },
+  volumeSliderSize: { // category: layout
+    desc: "The width of the volume slider in pixels",
+    type: "number",
+    min: 10,
+    max: 1000,
+    step: 5,
+    default: 160,
+  },
+  volumeSliderStep: { // category: layout
+    desc: "Volume slider sensitivity - the smaller this number, the finer the volume control",
+    type: "slider",
+    min: 1,
+    max: 20,
+    default: 2,
+  },
+  watermarkEnabled: { // category: layout
+    desc: "Show a BetterYTM watermark under the YTM logo",
+    type: "toggle",
+    default: true,
+  },
+};

+ 123 - 0
src/features/input.ts

@@ -0,0 +1,123 @@
+import { getVideoTime } from "../utils";
+import { dbg } from "../BetterYTM.user";
+import type { Domain } from "../types";
+
+//#MARKER arrow key skip
+
+export function initArrowKeySkip() {
+  document.addEventListener("keydown", onKeyDown);
+  dbg && console.log("BetterYTM: Added key press listener");
+}
+
+/** 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`);
+  }
+}
+
+//#MARKER site switch
+
+/** Initializes the site switch feature */
+export 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);
+  }
+}

+ 465 - 0
src/features/layout.ts

@@ -0,0 +1,465 @@
+import { dbg, info, triesInterval, triesLimit } from "../BetterYTM.user";
+import { defaultFeatures, getFeatures, saveFeatureConf } from "../config";
+import { addGlobalStyle, insertAfter } from "../utils";
+import { featInfo } from "./index";
+import type { FeatureConfig } from "../types";
+
+let features: FeatureConfig;
+
+export async function initLayout() {
+  features = await getFeatures();
+}
+
+//#MARKER menu
+
+const branch = dbg ? "develop" : "main";
+
+/** Adds an element to open the BetterYTM menu */
+export async 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}'`);
+
+    const featConf = {...(await getFeatures())};
+
+    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 features = await getFeatures();
+
+  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");
+}
+
+export function closeMenu() {
+  const menuBg = document.querySelector("#betterytm-menu-bg") as HTMLElement;
+
+  menuBg.style.visibility = "hidden";
+  menuBg.style.display = "none";
+}
+
+export function openMenu() {
+  const menuBg = document.querySelector("#betterytm-menu-bg") as HTMLElement;
+
+  menuBg.style.visibility = "visible";
+  menuBg.style.display = "block";
+}
+
+//#MARKER watermark
+
+/**
+ * Adds a watermark beneath the logo
+ */
+export 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);
+}
+
+//#MARKER remove upgrade tab
+
+let removeUpgradeTries = 0;
+
+/** Removes the "Upgrade" / YT Music Premium tab from the title / nav bar */
+export 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 volume slider
+
+/** Sets the volume slider to a set size */
+export 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 */
+export function setVolSliderStep() {
+  const sliderElem = document.querySelector("tp-yt-paper-slider#volume-slider") as HTMLInputElement;
+
+  sliderElem.setAttribute("step", String(features.volumeSliderStep));
+}

+ 219 - 0
src/features/lyrics.ts

@@ -0,0 +1,219 @@
+import { dbg, triesInterval, triesLimit } from "../BetterYTM.user";
+import { addGlobalStyle, insertAfter } from "../utils";
+
+/** Base URL of geniURL */
+export 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`;
+
+let mcCurrentSongTitle = "";
+let mcLyricsButtonAddTries = 0;
+
+/** Adds a genius.com lyrics button to the media controls bar */
+export 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 */
+export async function addQueueGeniusBtns()
+{
+  // TODO:
+}
+
+/** Returns the genius.com lyrics site URL for the current song */
+export 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;
+  }
+}

+ 68 - 0
src/utils.ts

@@ -0,0 +1,68 @@
+import { dbg } from "./BetterYTM.user";
+
+/**
+ * Returns the current domain as a constant string representation
+ * @throws Throws if script runs on an unexpected website
+ */
+export 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 Returns null if the video time is unavailable
+ */
+export 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`
+ */
+export 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
+ */
+export 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);
+}

+ 1 - 1
tools/post-build.js

@@ -32,7 +32,7 @@ const header = `// ==UserScript==
  ▄▄▄                    ▄   ▄▄▄▄▄▄   ▄
  █  █ ▄▄▄ █   █   ▄▄▄ ▄ ▄█ █  █  █▀▄▀█
  █▀▀▄ █▄█ █▀  █▀  █▄█ █▀  █   █  █   █
- █▄▄▀ █▄▄ █▄▄ █▄▄ █▄▄ █   █   █  █   █
+ █▄▄▀ ▀▄▄ ▀▄▄ ▀▄▄ ▀▄▄ █   █   █  █   █
 
          Made with ❤️ by Sv443
  I welcome every contribution on GitHub! */

+ 3 - 0
webpack.config.js

@@ -4,6 +4,9 @@ const { exec } = require("child_process");
 module.exports = {
   entry: "./src/BetterYTM.user.ts",
   mode: "production",
+  optimization: {
+    minimize: false,
+  },
   module: {
     rules: [{
       test: /\.tsx?$/,

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است