Sven Fehler 1 рік тому
батько
коміт
520689c104
100 змінених файлів з 9105 додано та 2634 видалено
  1. 10 0
      .babelrc.cjs
  2. 1 4
      .env.template
  3. 27 2
      .eslintrc.cjs
  4. 12 3
      .github/ISSUE_TEMPLATE/1_bug_report.md
  5. 16 0
      .github/ISSUE_TEMPLATE/3_plugin_submission.md
  6. 38 0
      .github/workflows/build.yml
  7. 3 2
      .github/workflows/lint.yml
  8. 3 0
      .gitignore
  9. 7 4
      .vscode/extensions.json
  10. 10 7
      .vscode/settings.json
  11. 661 21
      LICENSE.txt
  12. 12 11
      README-summary.md
  13. 25 15
      README.md
  14. 1 0
      assets/external/README.md
  15. BIN
      assets/external/discord.png
  16. BIN
      assets/icon/pdn/v1.0.pdn
  17. BIN
      assets/icon/pdn/v1.1.pdn
  18. BIN
      assets/icon/pdn/v2.0.pdn
  19. BIN
      assets/icon/pdn/v2.1.pdn
  20. BIN
      assets/icon/pdn/v3.0.pdn
  21. 0 0
      assets/icons/arrow_down.svg
  22. 0 0
      assets/icons/close.pdn
  23. 0 0
      assets/icons/close.png
  24. 0 0
      assets/icons/delete.svg
  25. 0 0
      assets/icons/error.svg
  26. 1 0
      assets/icons/globe.svg
  27. 0 0
      assets/icons/help.svg
  28. 0 0
      assets/icons/lyrics.svg
  29. 0 0
      assets/icons/skip_to.svg
  30. 1 0
      assets/icons/spinner.svg
  31. 47 0
      assets/locales.json
  32. BIN
      assets/logo/logo.pdn
  33. 0 0
      assets/logo/logo_1000.png
  34. 0 0
      assets/logo/logo_128.png
  35. 0 0
      assets/logo/logo_48.png
  36. 8 0
      assets/require.json
  37. 16 12
      assets/resources.json
  38. 0 1
      assets/spinner.svg
  39. 7 0
      assets/style/anchorImprovements.css
  40. 7 0
      assets/style/fixSpacing.css
  41. 27 0
      assets/translations/README.md
  42. 141 0
      assets/translations/de_DE.json
  43. 6 0
      assets/translations/en_UK.json
  44. 144 0
      assets/translations/en_US.json
  45. 141 0
      assets/translations/es_ES.json
  46. 141 0
      assets/translations/fr_FR.json
  47. 141 0
      assets/translations/hi_IN.json
  48. 141 0
      assets/translations/ja_JA.json
  49. 141 0
      assets/translations/pt_BR.json
  50. 141 0
      assets/translations/zh_CN.json
  51. 41 1
      changelog.md
  52. 701 13
      contributing.md
  53. 916 637
      dist/BetterYTM.user.js
  54. 0 18
      global.d.ts
  55. 1466 653
      package-lock.json
  56. 66 27
      package.json
  57. 88 0
      rollup.config.mjs
  58. 11 0
      src/README.md
  59. 41 15
      src/config.ts
  60. 12 7
      src/constants.ts
  61. 17 0
      src/declarations.d.ts
  62. 2 0
      src/dev/README.md
  63. 1 1
      src/dev/discoveries.md
  64. 0 17
      src/dev/parseVideoTime.ts
  65. 4 0
      src/features/README.md
  66. 232 0
      src/features/behavior.ts
  67. 182 65
      src/features/index.ts
  68. 49 179
      src/features/input.ts
  69. 39 49
      src/features/layout.css
  70. 139 315
      src/features/layout.ts
  71. 47 30
      src/features/lyrics.ts
  72. 68 0
      src/features/songLists.css
  73. 289 0
      src/features/songLists.ts
  74. 95 0
      src/features/versionCheck.tsx
  75. 198 54
      src/index.ts
  76. 89 0
      src/interface.ts
  77. 4 0
      src/menu/README.md
  78. 17 0
      src/menu/hotkeyInput.css
  79. 140 0
      src/menu/hotkeyInput.ts
  80. 0 38
      src/menu/menu.css
  81. 0 20
      src/menu/menu.html
  82. 0 95
      src/menu/menu.ts
  83. 128 29
      src/menu/menu_old.css
  84. 511 200
      src/menu/menu_old.ts
  85. 242 0
      src/menu/new/BytmMenu.tsx
  86. 5 0
      src/menu/updateMenu.css
  87. 78 0
      src/menu/updateMenu.ts
  88. 49 0
      src/menu/welcomeMenu.css
  89. 282 0
      src/menu/welcomeMenu.ts
  90. 68 0
      src/observers.ts
  91. 20 6
      src/siteEvents.ts
  92. 3 0
      src/tools/README.md
  93. 139 45
      src/tools/post-build.ts
  94. 18 0
      src/tools/run-invisible.mjs
  95. 15 11
      src/tools/serve.ts
  96. 86 0
      src/tools/tr-format.ts
  97. 19 0
      src/tools/tr-progress-template.md
  98. 104 0
      src/tools/tr-progress.ts
  99. 92 0
      src/translations.ts
  100. 215 27
      src/types.ts

+ 10 - 0
.babelrc.cjs

@@ -0,0 +1,10 @@
+module.exports = {
+  presets: [
+    [
+      "@babel/preset-react",
+    ],
+  ],
+  plugins: [
+    "@babel/plugin-transform-class-properties",
+  ],
+};

+ 1 - 4
.env.template

@@ -1,8 +1,5 @@
-# HTTP port of the dev server where the userscript will be served (when using `npm run watch`) - defaults to 8710 if empty
+# HTTP port of the dev server where the userscript will be served (when using `npm run dev`) - defaults to 8710 if empty
 DEV_SERVER_PORT=
 
 # Whether to trigger the bell sound in some terminals when the code has finished (re)compiling - default is false
 RING_BELL=false
-
-# optional suffix to add just before the .user.js of the output file (for use with CI or build scripts to build the file as BetterYTM.min.user.js)
-# OUTFILE_SUFFIX=".min"

+ 27 - 2
.eslintrc.cjs

@@ -6,7 +6,6 @@ module.exports = {
   },
   ignorePatterns: [
     "*.min.*",
-    "webpack.config.js",
     "*.user.js",
     "*.map",
     "dist/**",
@@ -14,6 +13,9 @@ module.exports = {
   extends: [
     "eslint:recommended",
     "plugin:@typescript-eslint/recommended",
+    "plugin:react/recommended",
+    "plugin:react/jsx-runtime",
+    "plugin:react-hooks/recommended",
   ],
   globals: {
     Atomics: "readonly",
@@ -24,26 +26,49 @@ module.exports = {
   parser: "@typescript-eslint/parser",
   parserOptions: {
     ecmaVersion: "latest",
+    ecmaFeatures: {
+      jsx: true,
+    },
+    sourceType: "module",
   },
   plugins: [
     "@typescript-eslint",
+    "react",
+    "react-hooks",
   ],
+  settings: {
+    react: {
+      version: "detect",
+    },
+  },
   rules: {
     "no-unreachable": "off",
     "quotes": [ "error", "double" ],
     "semi": [ "error", "always" ],
     "eol-last": [ "error", "always" ],
     "no-async-promise-executor": "off",
+    "no-cond-assign": "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",
+    "@typescript-eslint/ban-types": ["error", {
+      types: {
+        "{}": false,
+      },
+      extendDefaults: true,
+    }],
+    "@typescript-eslint/no-explicit-any": "off",
     "comma-dangle": ["error", "only-multiline"],
     "no-misleading-character-class": "off",
+    "react-hooks/rules-of-hooks": "error",
+    "react-hooks/exhaustive-deps": "error",
+    "react/react-in-jsx-scope": "off",
+    "react/prop-types": "off",
   },
   overrides: [
     {
-      files: ["**.js"],
+      files: ["**.js", "**.mjs", "**.cjs"],
       rules: {
         "@typescript-eslint/no-var-requires": "off",
         "quotes": [ "error", "double" ],

+ 12 - 3
.github/ISSUE_TEMPLATE/1_bug_report.md

@@ -7,18 +7,27 @@ assignees: Sv443
 
 ---
 
+### Checklist:
+<!-- Please go through all steps and check them off by replacing the [ ] with [x] -->
+<!-- You can also click on the checkboxes after submitting this issue -->
+- [ ] I checked [here](https://github.com/Sv443/BetterYTM/issues?q=is%3Aissue+sort%3Aupdated-desc) if there is already an issue for this bug
+- [ ] I am using the latest version of BetterYTM
+- [ ] I am using the latest version of my browser
+- [ ] I was able to reproduce the bug while all my other userscripts or extensions were disabled
+
 
 ### Description of the bug and steps to reproduce:
 <!-- A clear and concise description of what the bug is -->
 <!-- Also include steps to reproduce it -->
+<!-- Don't shy away from adding too much information, it's better to have too much than too little -->
 
 
 ### Environment:
 - Browser name & version: 
-- Userscript version & build number: x.x.x (xxxxxxx)
+- Userscript version & build number: vX.X.X (xxxxxxx)
 <!--
-  To view the userscript version and build number, either open the configuration menu and copy the numbers and letters at the bottom,
-  or open the JavaScript console of your browser (usually with F12). It should be near the very top.
+  To view the userscript version and build number, either open the configuration menu and copy the numbers and letters below the menu title,
+  or open the JavaScript console of your browser (usually with Ctrl + Shift + K) and scroll to the very top.
 -->
 
 

+ 16 - 0
.github/ISSUE_TEMPLATE/3_plugin_submission.md

@@ -0,0 +1,16 @@
+---
+name: Submit a plugin
+about: Submit a plugin for BetterYTM to be included in the readme and possibly the userscript itself
+title: ''
+labels: plugin submission
+assignees: Sv443
+
+---
+
+
+### Repository link:
+<!-- Link to the repository of the plugin you want to submit -->
+
+
+### Description of the features:
+<!-- A concise description or list of your plugin's features -->

+ 38 - 0
.github/workflows/build.yml

@@ -0,0 +1,38 @@
+name: "Build BetterYTM"
+
+on:
+  workflow_dispatch:
+  pull_request:
+    branches: [main]
+  push:
+    branches: [main]
+
+jobs:
+  build:
+    name: Build
+    runs-on: ubuntu-latest
+
+    timeout-minutes: 5
+
+    strategy:
+      matrix:
+        node-version: [20.x]
+
+    env:
+      CI: "true"
+
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Node.js v${{ matrix.node-version }}
+        uses: actions/setup-node@v3
+        with:
+          node-version: ${{ matrix.node-version }}
+      - name: Install dependencies # runs the npm ci command to install from package-lock.json
+        run: npm ci
+      - name: Build all for production
+        run: npm run build-prod
+      - name: Upload artifacts
+        uses: actions/upload-artifact@v2
+        with:
+          name: dist
+          path: dist

+ 3 - 2
.github/workflows/lint.yml

@@ -1,6 +1,7 @@
 name: "Lint BetterYTM"
 
 on:
+  workflow_dispatch:
   push:
     branches: [develop]
 
@@ -9,11 +10,11 @@ jobs:
     name: Lint
     runs-on: ubuntu-latest
 
-    timeout-minutes: 10
+    timeout-minutes: 5
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     env:
       CI: "true"

+ 3 - 0
.gitignore

@@ -6,4 +6,7 @@ node_modules/
 dist/*.map
 dist/out/
 dist/*.css
+dist/*_gf.user.js
+dist/*_oujs.user.js
 .build.json
+*.ignore.*

+ 7 - 4
.vscode/extensions.json

@@ -1,7 +1,10 @@
 {
   "recommendations": [
-    "adpyke.vscode-userscript",
+    "andywang.vscode-scriptmonkey",
     "dbaeumer.vscode-eslint",
-    "fabiospampinato.vscode-highlight"
-  ]
-}
+    "fabiospampinato.vscode-highlight",
+  ],
+  "unwantedRecommendations": [
+    "adpyke.vscode-userscript",
+  ],
+}

+ 10 - 7
.vscode/settings.json

@@ -1,5 +1,7 @@
 {
   "javascript.preferences.importModuleSpecifier": "relative",
+  "typescript.tsdk": "node_modules/typescript/lib",
+
   "search.exclude": {
     "**/BetterYTM.user.js": true,
   },
@@ -7,38 +9,39 @@
     "*.env": "dotenv",
     "*.env.template": "dotenv",
   },
+  "editor.tabSize": 2,
 
   // requires extension: fabiospampinato.vscode-highlight
   "highlight.regexes": {
     "(TODO(\\((\\s|\\d|\\w|[,.-_+*&])+\\))?:?)": { // TODO: or TODO or TODO(xy): but not todo or todo:
       "backgroundColor": "#ed0",
       "color": "black",
-      "overviewRulerColor": "#ed0"
+      "overviewRulerColor": "#ed0",
     },
     "(#MARKER)": { // #MARKER test
       "backgroundColor": "#c31",
       "color": "#fff",
       "isWholeLine": true,
-      "overviewRulerColor": "#c31"
+      "overviewRulerColor": "#c31",
     },
     "(#SECTION ([^\\S\\r\\n]*[\\w,.\\-_&]+)*[:]*)": { // #SECTION test, 123 & foo
       "backgroundColor": "#44f",
       "color": "white",
-      "overviewRulerColor": "#44f"
+      "overviewRulerColor": "#44f",
     },
     "(#?(DEBUG|DBG)#?)": { // #DEBUG or DEBUG or #DBG or #DBG#
       "backgroundColor": "#ff0",
       "color": "blue",
-      "overviewRulerColor": "#ff0"
+      "overviewRulerColor": "#ff0",
     },
     "(IMPORTANT:)": { // IMPORTANT:
       "backgroundColor": "#a22",
-      "color": "#fff"
+      "color": "#fff",
     },
     "(FIXME:)": { // FIXME:
       "backgroundColor": "#a22",
       "color": "#fff",
-      "overviewRulerColor": "#752020"
-    }
+      "overviewRulerColor": "#752020",
+    },
   },
 }

+ 661 - 21
LICENSE.txt

@@ -1,21 +1,661 @@
-MIT License
-
-Copyright (c) 2022 Sven Fehler (Sv443)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+  A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate.  Many developers of free software are heartened and
+encouraged by the resulting cooperation.  However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+  The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community.  It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server.  Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+  An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals.  This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU Affero General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Remote Network Interaction; Use with the GNU General Public License.
+
+  Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software.  This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time.  Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source.  For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code.  There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.

+ 12 - 11
README-summary.md

@@ -1,33 +1,34 @@
 <h1><img src="https://raw.githubusercontent.com/Sv443/BetterYTM/main/assets/icon/icon_128.png" width="96" height="96" /><br>BetterYTM</h1>
 
-### Lots of configurable layout and UX improvements for YouTube Music
+### Lots of configurable layout and user experience improvements for YouTube Music
+Supported Languages: 🇺🇸 English, 🇩🇪 German, 🇪🇸 Spanish, 🇫🇷 French, 🇮🇳 Hindi, 🇯🇵 Japanese, 🇵🇹 Portuguese, 🇨🇳 Chinese
 
 <br>
 
 ### Features:
 All of these can be toggled and configured in the configuration menu.
-- Layout:
+- Layout & User Experience:
   - Open any song's lyrics on genius.com which generally has higher quality than YouTube's providers
   - Quick actions on songs in a queue, to quickly open their lyrics or remove them from the queue
   - Set a custom size and step resolution for the volume slider and show a percentage label next to it
   - Improvements to clickability of song titles and thumbnails when wanting to open them in a new tab
+  - Remember the time of the last played song to resume playback after reloading or reopening the tab
   - Quickly scroll to the currently active song in the queue by clicking a button
   - Remove the tracking parameter from URLs in the share menu
   - Automatically close permanent notifications
   - Remove the premium tab in the sidebar
-- Input:
-  - Use arrow keys to skip forward or backward by 10 seconds
+- Input / Interaction:
+  - Use arrow keys to skip forward or backward by a configurable amount of time
   - Press number keys to skip to a percentage of the currently playing song
-  - Switch between YouTube and YouTube Music on a video by pressing the hotkey F9
+  - Press a hotkey on a video/song to switch between YouTube and YouTube Music, while keeping the video time
   - Prevent the "unsaved data" popup that sometimes appears before leaving the site
   
-... and more!
+... and these are just the notable features, there are many more smaller improvements and bugfixes!
 
 <br>
 
 To toggle and configure features, after installing the userscript, click the "BetterYTM" text under the logo to open the configuration menu.  
-Alternatively or if you disabled the watermark, you can open it through the menu you get by clicking on your avatar in the top right corner.  
-Note that the page needs to be reloaded for changes to take effect.  
+Alternatively or if you disabled the watermark, you can open it through the popover menu opened by clicking your avatar in the top right corner.  
   
 My work relies on donations, so if you like this userscript please consider [supporting development ❤️](https://github.com/sponsors/Sv443)
 
@@ -41,7 +42,7 @@ I really recommend ViolentMonkey: [Firefox](https://addons.mozilla.org/en-US/fir
 
 </b>
 
-Once you have the extension, click the install button above!
+Once you have the extension, click the install button at the top of this page!
 
 <br>
 
@@ -53,7 +54,7 @@ Note: the `unsafeWindow` grant is required due to limitations in some browsers,
 <br>
 <sup>
 
-To install the latest development build [click here](https://github.com/Sv443/BetterYTM/raw/main/BetterYTM.user.js) (note: the script will not auto-update to the next release version)
+To install the latest development build [click here](https://github.com/Sv443/BetterYTM/raw/develop/dist/BetterYTM.user.js) (note: the script will not auto-update to the next release version)
 
 </sup>
 
@@ -91,4 +92,4 @@ Icons:
 Made with ❤️ by [Sv443](https://github.com/Sv443)  
 If you like this userscript, please consider [supporting me](https://github.com/sponsors/Sv443)  
   
-© 2022 Sv443 - [MIT license](https://github.com/Sv443/BetterYTM/tree/main/LICENSE.txt)
+© 2022 Sv443 - [AGPL-3.0](https://github.com/Sv443/BetterYTM/tree/main/LICENSE.txt)

+ 25 - 15
README.md

@@ -1,42 +1,44 @@
 <div style="text-align: center;" align="center">
 
-<h1><img src="./assets/icon/icon_128.png" width="96" height="96" /><br>BetterYTM</h1>
+<h1><img src="./assets/logo/logo_128.png" width="96" height="96" /><br>BetterYTM</h1>
 
-### Lots of configurable layout and UX improvements for YouTube Music
+### Lots of configurable layout and user experience improvements for YouTube Music
+Supported Languages: 🇺🇸 English, 🇩🇪 German, 🇪🇸 Spanish, 🇫🇷 French, 🇮🇳 Hindi, 🇯🇵 Japanese, 🇵🇹 Portuguese, 🇨🇳 Chinese
 
-[**Features**](#features) • [**Installation**](#installation) • [**Development**](#development) • [**Attributions**](#attributions) • [**Disclaimers**](#disclaimers)
+[**Features**](#features) • [**Installation**](#installation) • [**Plugins**](#plugins) • [**Development**](#development) • [**Attributions**](#attributions) • [**Disclaimers**](#disclaimers)
 
 </div>
 <br>
 
 ### Features:
 All of these can be toggled and configured in the configuration menu.
-- Layout:
+- Layout & User Experience:
   - Open any song's lyrics on genius.com which generally has higher quality than YouTube's providers
   - Quick actions on songs in a queue, to quickly open their lyrics or remove them from the queue
   - Set a custom size and step resolution for the volume slider and show a percentage label next to it
   - Improvements to clickability of song titles and thumbnails when wanting to open them in a new tab
+  - Remember the time of the last played song to resume playback after reloading or reopening the tab
   - Quickly scroll to the currently active song in the queue by clicking a button
   - Remove the tracking parameter from URLs in the share menu
   - Automatically close permanent notifications
   - Remove the premium tab in the sidebar
-- Input:
-  - Use arrow keys to skip forward or backward by 10 seconds
+- Input / Interaction:
+  - Use arrow keys to skip forward or backward by a configurable amount of time
   - Press number keys to skip to a percentage of the currently playing song
-  - Switch between YouTube and YouTube Music on a video by pressing the hotkey F9 <!-- TODO(v1.1): make configurable -->
+  - Press a hotkey on a video/song to switch between YouTube and YouTube Music, while keeping the video time
   - Prevent the "unsaved data" popup that sometimes appears before leaving the site
   
-... and more!
+... and these are just the notable features, there are many more smaller improvements and bugfixes!
 
-<br>
+<br><br>
 
 To toggle and configure features, after installing the userscript, click the "BetterYTM" text under the logo to open the configuration menu.  
-Alternatively or if you disabled the watermark, you can open it through the menu you get by clicking on your avatar in the top right corner.  
-Note that the page needs to be reloaded for changes to take effect.  
+Alternatively or if you disabled the watermark, you can open it through the popover menu opened by clicking your avatar in the top right corner.  
   
-My work relies on donations, so if you like this userscript please consider [supporting development ❤️](https://github.com/sponsors/Sv443)
+> [!NOTE]  
+> My work relies on donations, so if you like this userscript please consider [supporting development ❤️](https://github.com/sponsors/Sv443)
 
-<br><br>
+<br><br><br>
 
 ## Installation:
 <b>
@@ -59,12 +61,20 @@ Note: the `unsafeWindow` grant is required due to limitations in some browsers,
 </sup>
 <sup>
 
-To install the latest development build [click here](https://github.com/Sv443/BetterYTM/raw/main/BetterYTM.user.js) (note: the script will not auto-update to the next release version)
+To install the latest development build [click here](https://github.com/Sv443/BetterYTM/raw/develop/dist/BetterYTM.user.js) (note: the script will not auto-update to the next release version)
 
 </sup>
 
 <br><br><br>
 
+## Plugins:
+BetterYTM supports plugin userscripts that can be installed in parallel and can make use of BetterYTM's pre-existing API.  
+  
+Currently there are no available plugins, but you can [submit an issue here, using the plugin submission template](https://github.com/Sv443/BetterYTM/issues/new/choose).  
+Also refer to the [plugin creation guide](./contributing.md#developing-a-plugin-that-interfaces-with-betterytm) for more information on how to use the API.
+
+<br><br>
+
 ### Development:
 This project is based on my extensive template for making a userscript with TypeScript and many modern language and convenience features.  
 [Check it out here](https://github.com/Sv443/Userscript.ts) if you want to make your own userscripts!  
@@ -99,6 +109,6 @@ Icons:
 Made with ❤️ by [Sv443](https://github.com/Sv443)  
 If you like this userscript, please consider [supporting me](https://github.com/sponsors/Sv443)  
   
-© 2022 Sv443 - [MIT license](./LICENSE.txt)
+© 2022 Sv443 - [AGPL-3.0](./LICENSE.txt)
 
 </div>

+ 1 - 0
assets/external/README.md

@@ -4,3 +4,4 @@ They belong to their respective owners:
 - The GitHub logo is owned by GitHub Inc.
 - The GreasyFork logo is owned by Jason Barnabe.
 - The OpenUserJS logo is owned by OpenUserJS.
+- The Discord logo is owned by Discord Inc.

BIN
assets/external/discord.png


BIN
assets/icon/pdn/v1.0.pdn


BIN
assets/icon/pdn/v1.1.pdn


BIN
assets/icon/pdn/v2.0.pdn


BIN
assets/icon/pdn/v2.1.pdn


BIN
assets/icon/pdn/v3.0.pdn


+ 0 - 0
assets/arrow_down.svg → assets/icons/arrow_down.svg


+ 0 - 0
assets/close.pdn → assets/icons/close.pdn


+ 0 - 0
assets/close.png → assets/icons/close.png


+ 0 - 0
assets/delete.svg → assets/icons/delete.svg


+ 0 - 0
assets/error.svg → assets/icons/error.svg


+ 1 - 0
assets/icons/globe.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path fill="#ffffff" d="M480-100.001q-78.154 0-147.499-29.962-69.346-29.961-120.962-81.576-51.615-51.616-81.576-120.962Q100.001-401.846 100.001-480q0-78.769 29.962-147.807 29.961-69.038 81.576-120.654 51.616-51.615 120.962-81.576Q401.846-859.999 480-859.999q78.769 0 147.807 29.962 69.038 29.961 120.654 81.576 51.615 51.616 81.576 120.654Q859.999-558.769 859.999-480q0 78.154-29.962 147.499-29.961 69.346-81.576 120.962-51.616 51.615-120.654 81.576Q558.769-100.001 480-100.001Zm0-60.845q30.616-40.616 51.539-81.924 20.923-41.308 34.077-90.308H394.384q13.923 50.539 34.462 91.847 20.538 41.308 51.154 80.385Zm-77.46-11q-23-33-41.308-75.039t-28.462-86.193H197.076q31.693 62.309 85.001 104.694 53.309 42.385 120.463 56.538Zm154.92 0q67.154-14.153 120.463-56.538 53.308-42.385 85.001-104.694H627.23q-12.077 44.539-30.385 86.578t-39.385 74.654Zm-385.537-221.23h148.693q-3.769-22.308-5.461-43.731-1.692-21.424-1.692-43.193t1.692-43.193q1.692-21.423 5.461-43.731H171.923q-5.769 20.385-8.846 42.385Q160-502.539 160-480t3.077 44.539q3.077 22 8.846 42.385Zm208.692 0h198.77q3.769-22.308 5.462-43.347 1.692-21.038 1.692-43.577 0-22.539-1.692-43.577-1.693-21.039-5.462-43.347h-198.77q-3.769 22.308-5.462 43.347-1.692 21.038-1.692 43.577 0 22.539 1.692 43.577 1.693 21.039 5.462 43.347Zm258.769 0h148.693q5.769-20.385 8.846-42.385Q800-457.461 800-480t-3.077-44.539q-3.077-22-8.846-42.385H639.384q3.769 22.308 5.461 43.731 1.692 21.424 1.692 43.193t-1.692 43.193q-1.692 21.423-5.461 43.731ZM627.23-626.922h135.694Q730.846-690 678.5-731.616q-52.347-41.615-121.04-56.923 23 34.923 40.923 76.385 17.924 41.462 28.847 85.232Zm-232.846 0h171.232q-13.923-50.154-35.039-92.424-21.115-42.269-50.577-79.808-29.462 37.539-50.577 79.808-21.116 42.27-35.039 92.424Zm-197.308 0H332.77q10.923-43.77 28.847-85.232 17.923-41.462 40.923-76.385-69.077 15.308-121.232 57.116-52.154 41.808-84.232 104.501Z"/></svg>

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
assets/icons/help.svg


+ 0 - 0
assets/lyrics.svg → assets/icons/lyrics.svg


+ 0 - 0
assets/skip_to.svg → assets/icons/skip_to.svg


+ 1 - 0
assets/icons/spinner.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200" preserveAspectRatio="xMidYMid" viewBox="0 0 100 100"><circle cx="50" cy="50" r="35" fill="none" stroke="#fff" stroke-dasharray="164.934 56.978" stroke-width="6" transform="matrix(1,0,0,1,0,0)"/></svg>

+ 47 - 0
assets/locales.json

@@ -0,0 +1,47 @@
+{
+  "de_DE": {
+    "name": "Deutsch (Deutschland)",
+    "userscriptDesc": "Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music",
+    "authors": ["Sv443"]
+  },
+  "en_US": {
+    "name": "English (United States)",
+    "userscriptDesc": "Configurable layout and user experience improvements for YouTube Music",
+    "authors": ["Sv443"]
+  },
+  "en_UK": {
+    "name": "English (United Kingdom)",
+    "userscriptDesc": "Configurable layout and user experience improvements for YouTube Music",
+    "authors": ["Sv443"]
+  },
+  "es_ES": {
+    "name": "Español (España)",
+    "userscriptDesc": "Mejoras de diseño y experiencia de usuario configurables para YouTube Music",
+    "authors": ["Sv443"]
+  },
+  "fr_FR": {
+    "name": "Français (France)",
+    "userscriptDesc": "Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music",
+    "authors": ["Sv443"]
+  },
+  "hi_IN": {
+    "name": "हिंदी (भारत)",
+    "userscriptDesc": "YouTube Music के लिए विन्यास और यूजर अनुभव में सुधार करने योग्य लेआउट और यूजर अनुभव सुधार",
+    "authors": ["Sv443"]
+  },
+  "ja_JA": {
+    "name": "日本語 (日本)",
+    "userscriptDesc": "YouTube Musicのレイアウトとユーザーエクスペリエンスの改善を設定可能にする",
+    "authors": ["Sv443"]
+  },
+  "pt_BR": {
+    "name": "Português (Brasil)",
+    "userscriptDesc": "Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music",
+    "authors": ["Sv443"]
+  },
+  "zh_CN": {
+    "name": "中文(简化,中国)",
+    "userscriptDesc": "可配置的布局和YouTube Music的用户体验改进",
+    "authors": ["Sv443"]
+  }
+}

BIN
assets/icon/pdn/v3.1.pdn → assets/logo/logo.pdn


+ 0 - 0
assets/icon/icon_1000.png → assets/logo/logo_1000.png


+ 0 - 0
assets/icon/icon_128.png → assets/logo/logo_128.png


+ 0 - 0
assets/icon/icon_48.png → assets/logo/logo_48.png


+ 8 - 0
assets/require.json

@@ -0,0 +1,8 @@
+[
+  {
+    "url": "https://unpkg.com/react@18/umd/react.development.js"
+  },
+  {
+    "url": "https://unpkg.com/react-dom@18/umd/react-dom.development.js"
+  }
+]

+ 16 - 12
assets/resources.json

@@ -1,15 +1,19 @@
 {
-  "icon": "icon/icon_48.png",
-  "close": "close.png",
+  "img-arrow_down": "icons/arrow_down.svg",
+  "img-delete": "icons/delete.svg",
+  "img-error": "icons/error.svg",
+  "img-globe": "icons/globe.svg",
+  "img-help": "icons/help.svg",
+  "img-lyrics": "icons/lyrics.svg",
+  "img-skip_to": "icons/skip_to.svg",
+  "img-spinner": "icons/spinner.svg",
+  "img-logo": "logo/logo_48.png",
+  "img-close": "icons/close.png",
+  "img-discord": "external/discord.png",
+  "img-github": "external/github.png",
+  "img-greasyfork": "external/greasyfork.png",
+  "img-openuserjs": "external/openuserjs.png",
 
-  "delete": "delete.svg",
-  "error": "error.svg",
-  "lyrics": "lyrics.svg",
-  "spinner": "spinner.svg",
-  "arrow_down": "arrow_down.svg",
-  "skip_to": "skip_to.svg",
-
-  "github": "external/github.png",
-  "greasyfork": "external/greasyfork.png",
-  "openuserjs": "external/openuserjs.png"
+  "css-fix_spacing": "style/fixSpacing.css",
+  "css-anchor_improvements": "style/anchorImprovements.css"
 }

+ 0 - 1
assets/spinner.svg

@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200" preserveAspectRatio="xMidYMid" viewBox="0 0 100 100" style="display:block"><circle cx="50" cy="50" r="35" fill="none" stroke="#fff" stroke-dasharray="164.934 56.978" stroke-width="6" transform="matrix(1,0,0,1,0,0)"/></svg>

+ 7 - 0
assets/style/anchorImprovements.css

@@ -0,0 +1,7 @@
+ytmusic-responsive-list-item-renderer:not([unplayable_]) .left-items {
+  margin-right: 0 !important;
+}
+
+.bytm-carousel-shelf-anchor {
+  margin-right: var(--ytmusic-responsive-list-item-thumbnail-margin-right, 24px);
+}

+ 7 - 0
assets/style/fixSpacing.css

@@ -0,0 +1,7 @@
+ytmusic-carousel-shelf-renderer ytmusic-carousel ytmusic-responsive-list-item-renderer {
+  margin-bottom: var(--ytmusic-carousel-item-margin-bottom, 16px) !important;
+}
+
+ytmusic-carousel-shelf-renderer ytmusic-carousel {
+  --ytmusic-carousel-item-height: 60px !important;
+}

+ 27 - 0
assets/translations/README.md

@@ -0,0 +1,27 @@
+## BetterYTM - Translations
+To submit or edit a translation, please follow [this guide](../../contributing.md#submitting-translations)
+
+<br>
+
+### Translation progress:
+| Locale | Translated keys | Based on |
+| ------ | --------------- | :------: |
+| [`en_US`](./en_US.json) | 126 (default locale) |  |
+| [`de_DE`](./de_DE.json) | ✅ `126/126` (100.0%) | ─ |
+| [`en_UK`](./en_UK.json) | ✅ `126/126` (100.0%) | `en_US` |
+| [`es_ES`](./es_ES.json) | ✅ `126/126` (100.0%) | ─ |
+| [`fr_FR`](./fr_FR.json) | ✅ `126/126` (100.0%) | ─ |
+| [`hi_IN`](./hi_IN.json) | ✅ `126/126` (100.0%) | ─ |
+| [`ja_JA`](./ja_JA.json) | ✅ `126/126` (100.0%) | ─ |
+| [`pt_BR`](./pt_BR.json) | ✅ `126/126` (100.0%) | ─ |
+| [`zh_CN`](./zh_CN.json) | ✅ `126/126` (100.0%) | ─ |
+
+<br>
+
+If a translation is based on another translation, that means the keys from the base translation file are automatically applied if they are missing. This is used for locales that are very similar to each other, such as `en_UK` and `en_US`  
+This means you need to manually check against the base translations for missing keys if you want to improve translations.
+
+<br>
+
+### Missing keys:
+No missing keys

+ 141 - 0
assets/translations/de_DE.json

@@ -0,0 +1,141 @@
+{
+  "translations": {
+    "config_menu_option": "%1 Einstellungen",
+    "config_menu_title": "%1 - Einstellungen",
+    "changelog_menu_title": "%1 - Änderungsprotokoll",
+    "export_menu_title": "%1 - Einstellungen exportieren",
+    "import_menu_title": "%1 - Einstellungen importieren",
+    "open_menu_tooltip": "%1's Einstellungen öffnen",
+    "close_menu_tooltip": "Klicke um das Menü zu schließen",
+    "reload_hint": "Du musst die Seite neu laden, um Änderungen zu speichern",
+    "reload_now": "Jetzt neu laden",
+    "reload_tooltip": "Seite neu laden",
+    "version_tooltip": "Version %1 (build %2) - klicken um das Änderungsprotokoll zu öffnen",
+    "export": "Exportieren",
+    "export_hint": "Kopiere den folgenden Text um deine Einstellungen zu exportieren:",
+    "export_tooltip": "Exportiere deine aktuellen Einstellungen",
+    "import": "Importieren",
+    "import_hint": "Füge die Einstellungen, die du importieren möchtest, in das Feld unten ein und klicke dann auf Importieren:",
+    "import_tooltip": "Importiere Einstellungen, die du zuvor exportiert hast",
+    "start_import_tooltip": "Klicke um die Einstellungen, die du oben eingefügt hast, zu importieren",
+    "import_error_invalid": "Die importierten Daten sind ungültig",
+    "import_error_no_format_version": "Die importierten Daten enthalten keine Format-Version",
+    "import_error_no_data": "Das importierte Objekt enthält keine Daten",
+    "import_error_wrong_format_version": "Die importierten Daten haben eine nicht unterstützte Format-Version (%1 oder niedriger erwartet, aber %2 erhalten)",
+    "import_success_confirm_reload": "Die Einstellungen wurde erfolgreich importiert.\nMöchtest du die Seite jetzt neu laden, um die Änderungen zu übernehmen?",
+    "reset_tooltip": "Alle Einstellungen auf ihre Standardwerte zurücksetzen",
+    "reset_confirm": "Möchtest du wirklich alle Einstellungen auf ihre Standardwerte zurücksetzen?\nDie Seite wird automatisch neu geladen.",
+    "copy_to_clipboard": "In die Zwischenablage kopieren",
+    "copy_config_tooltip": "Kopiere die Einstellungen in die Zwischenablage",
+    "copied_notice": "Kopiert!",
+    "open_github": "Öffne %1 auf GitHub",
+    "open_discord": "Tritt meinem Discord-Server bei",
+    "open_greasyfork": "Öffne %1 auf GreasyFork",
+    "open_openuserjs": "Öffne %1 auf OpenUserJS",
+    "lang_changed_prompt_reload": "Die Sprache wurde geändert.\nMöchtest du die Seite jetzt neu laden, um die Änderungen zu übernehmen?",
+
+    "reset": "Zurücksetzen",
+    "close": "Schließen",
+    "log_level_debug": "Debug (sehr viele)",
+    "log_level_info": "Info (nur wichtige)",
+    "toggled_on": "An",
+    "toggled_off": "Aus",
+    "remove_from_queue": "Aus der Wiedergabeliste entfernen",
+    "delete_from_list": "Aus der Liste löschen",
+    "couldnt_remove_from_queue": "Song konnte nicht aus der Wiedergabeliste entfernt werden",
+    "couldnt_delete_from_list": "Song konnte nicht aus der Liste gelöscht werden",
+    "scroll_to_playing": "Zum aktiven Song scrollen",
+    "scroll_to_bottom": "Zum Ende der Wiedergabeliste scrollen",
+    "volume_tooltip": "Lautstärke: %1% (Sensitivität: %2%)",
+    "middle_click_open_tab": "Mittelklick um in einem neuen Tab zu öffnen",
+    "boost_gain_enable_tooltip": "Booste die Lautstärke auf %1%",
+    "boost_gain_disable_tooltip": "Deaktiviere den Lautstärke-Boost",
+
+    "open_current_lyrics": "Öffne den Songtext vom aktuellen Song in einem neuen Tab",
+    "open_lyrics": "Öffne den Songtext in einem neuen Tab",
+    "lyrics_loading": "Songtext-URL wird geladen...",
+    "lyrics_rate_limited-1": "Du hast zu viele Anfragen gesendet.\nBitte warte ein paar Sekunden, bevor du weitere Songtexte anforderst.",
+    "lyrics_rate_limited-n": "Du hast zu viele Anfragen gesendet.\nBitte warte %1 Sekunden, bevor du weitere Songtexte anforderst.",
+    "lyrics_not_found_confirm_open_search": "Für diesen Song konnte kein Songtext gefunden werden.\nMöchtest du genius.com öffnen, um manuell danach zu suchen?",
+    "lyrics_not_found_click_open_search": "Es konnte kein Songtext gefunden werden - klicke um die manuelle Suche zu öffnen",
+
+    "hotkey_input_click_to_change": "Zum Ändern klicken",
+    "hotkey_input_click_to_change_tooltip": "Klicke, dann drücke die gewünschte Tastenkombination",
+    "hotkey_input_click_to_cancel_tooltip": "Klicke, um abzubrechen",
+    "hotkey_key_ctrl": "Strg",
+    "hotkey_key_shift": "Shift",
+    "hotkey_key_mac_option": "Option",
+    "hotkey_key_alt": "Alt",
+
+    "welcome_menu_title": "Willkommen bei %1!",
+    "config_menu": "Einstellungen",
+    "open_config_menu_tooltip": "Klicke, um das Einstellungsmenü zu öffnen",
+    "open_changelog": "Änderungsprotokoll",
+    "open_changelog_tooltip": "Klicke, um das Änderungsprotokoll zu öffnen",
+    "feature_help_button_tooltip": "Klicke, um mehr Informationen über diese Funktion zu erhalten",
+    "welcome_text_line_1": "Vielen Dank für die Installation!",
+    "welcome_text_line_2": "Ich hoffe, du hast genauso viel Spaß mit %1 wie ich beim Erstellen hatte 😃",
+    "welcome_text_line_3": "Wenn dir %1 gefällt, hinterlasse bitte eine Bewertung auf %2GreasyFork%3 oder %4OpenUserJS%5",
+    "welcome_text_line_4": "Meine Arbeit hängt von Spenden ab, also bitte überlege zu %1spenden ❤️%2",
+    "welcome_text_line_5": "Hast du einen Fehler gefunden oder möchtest ein Feature vorschlagen? Bitte %1öffne ein Issue auf GitHub%2",
+    "list_button_placement_queue_only": "Nur Wiedergabeliste",
+    "list_button_placement_everywhere": "In jeder Songliste",
+    "remember_song_time_sites_all": "Beide Seiten",
+    "remember_song_time_sites_yt": "Nur YouTube",
+    "remember_song_time_sites_ytm": "Nur YouTube Music",
+    "new_version_available": "Eine neue Version von %1 ist verfügbar!\nAktuell installiert: %2 - neue Version: %3\n(Du kannst diese Benachrichtigung im Einstellungsmenü deaktivieren)\n\nMöchtest du %4 öffnen, um es manuell zu installieren?",
+
+    "feature_category_layout": "Layout",
+    "feature_category_songLists": "Songlisten",
+    "feature_category_behavior": "Verhalten",
+    "feature_category_input": "Eingabe",
+    "feature_category_lyrics": "Songtexte",
+    "feature_category_general": "Allgemein",
+
+    "feature_desc_removeUpgradeTab": "Entferne den Upgrade / Premium Tab",
+    "feature_desc_volumeSliderLabel": "Füge eine Prozent-Beschriftung neben dem Lautstärkeregler hinzu",
+    "feature_desc_volumeSliderSize": "Die Breite des Lautstärkereglers in Pixeln",
+    "feature_desc_volumeSliderStep": "Lautstärkeregler-Sensitivität (um wie wenig Prozent die Lautstärke auf einmal geändert werden kann)",
+    "feature_desc_volumeSliderScrollStep": "Scrollrad-Sensitivität vom Lautstärkeregler in Prozent - springt zum nächsten Sensitivitätswert von oben",
+    "feature_helptext_volumeSliderScrollStep": "Um wie viel Prozent die Lautstärke geändert werden soll, wenn der Lautstärkeregler mit dem Mausrad gescrollt wird.\nDies sollte ein Vielfaches der Lautstärkeregler-Sensitivität sein, ansonsten gibt es kleine unregelmäßige Sprünge in der Lautstärke beim Scrollen.",
+    "feature_desc_watermarkEnabled": "Zeige ein Wasserzeichen unter dem Seitenlogo, das dieses Einstellungsmenü öffnet",
+    "feature_helptext_watermarkEnabled": "Wenn dies deaktiviert ist, kannst du das Einstellungsmenü immer noch öffnen, indem du die Option im Menü anklickst, das sich öffnet, wenn du auf dein Profilbild in der oberen rechten Ecke klickst.\nEs wird jedoch schwieriger sein, das Easter Egg zu finden ;)",
+    "feature_desc_removeShareTrackingParam": "Entferne den Tracking-Parameter \"&si\" von URLs im \"Teilen\" Popup",
+    "feature_helptext_removeShareTrackingParam": "Zu Analysezwecken fügt YouTube einen Tracking-Parameter am Ende der URL hinzu, die im Teilen-Menü angezeigt wird. Obwohl es nicht direkt schädlich ist, macht es die URL länger und gibt YouTube mehr Informationen über dich und die Personen, denen du den Link sendest.",
+    "feature_desc_numKeysSkipToTime": "Das Drücken einer Zahlentaste (0-9) springt zu einer bestimmten Zeit im Video",
+    "feature_desc_fixSpacing": "Behebe diverse Abstandprobleme im Layout",
+    "feature_helptext_fixSpacing": "Es gibt verschiedene Stellen im User Interface, an denen der Abstand zwischen Elementen inkonsistent ist. Diese Funktion behebt diese Probleme.",
+
+    "feature_desc_lyricsQueueButton": "Füge jedem Song in der Wiedergabeliste einen Knopf hinzu, um den Songtext schnell zu öffnen",
+    "feature_desc_deleteFromQueueButton": "Füge jedem Song in der Wiedergabeliste einen Knopf hinzu, um ihn schnell zu entfernen",
+    "feature_desc_listButtonsPlacement": "Wo sollen die Wiedergabelisten-Knöpfe angezeigt werden?",
+    "feature_helptext_listButtonsPlacement": "Es gibt verschiedene Songlisten auf der Seite, wie z.B. Albumseiten, Playlists und die aktuelle Wiedergabeliste. Mit dieser Option kannst du auswählen, wo die Wiedergabelisten-Knöpfe angezeigt werden sollen.",
+    "feature_desc_scrollToActiveSongBtn": "Füge einen Knopf zur Wiedergabeliste hinzu, um zum aktiven Song zu scrollen",
+
+    "feature_desc_disableBeforeUnloadPopup": "Verhindere das Erscheinen des Bestätigungs-Popup beim Verlassen der Seite, während ein Song läuft",
+    "feature_helptext_disableBeforeUnloadPopup": "Wenn du versuchst, die Seite zu verlassen, während ein Song läuft, erscheint ein Popup, das dich fragt, ob du die Seite wirklich verlassen möchtest. Es könnte etwas in der Art von \"Du hast ungespeicherte Daten\" oder \"Diese Seite fragt, ob du sie schließen möchtest\" sein.\nDiese Funktion deaktiviert dieses Popup vollständig.",
+    "feature_desc_closeToastsTimeout": "Nach wie vielen Sekunden permanente Benachrichtigungen geschlossen werden sollen - 0 für manuelles Schließen",
+    "feature_helptext_closeToastsTimeout": "Die meisten Popups, die in der unteren linken Ecke erscheinen, schließen sich automatisch nach 3 Sekunden, mit Ausnahme von bestimmten wie z.B. beim Liken eines Songs.\nDiese Funktion ermöglicht es dir, eine Zeit festzulegen, nach der permanente Popups geschlossen werden.\nDie anderen Popups bleiben unberührt.\nSetze dies auf 0 für das Standardverhalten, permanente Benachrichtigungen nicht zu schließen.",
+    "feature_desc_rememberSongTime": "Stelle die Zeit des letzten Songs wieder her, wenn die Seite neu geladen oder wiederhergestellt wird",
+    "feature_helptext_rememberSongTime-1": "Manchmal möchtest du nach dem Neuladen der Seite oder nach dem versehentlichen Schließen an derselben Stelle weiterhören. Diese Funktion ermöglicht es dir, das zu tun.\nUm die Zeit des Songs zu speichern, musst du ihn %1 Sekunde lang abspielen, dann wird die Zeit gespeichert und für kurze Zeit wiederherstellbar sein.",
+    "feature_helptext_rememberSongTime-n": "Manchmal möchtest du nach dem Neuladen der Seite oder nach dem versehentlichen Schließen an derselben Stelle weiterhören. Diese Funktion ermöglicht es dir, das zu tun.\nUm die Zeit des Songs zu speichern, musst du ihn %1 Sekunden lang abspielen, dann wird die Zeit gespeichert und für kurze Zeit wiederherstellbar sein.",
+    "feature_desc_rememberSongTimeSites": "Auf welchen Seiten soll die Songzeit gespeichert und wiederhergestellt werden?",
+
+    "feature_desc_arrowKeySupport": "Benutze die Pfeiltasten um vor- und zurückzuspulen",
+    "feature_helptext_arrowKeySupport": "Normalerweise kannst du nur in 10 Sekunden Schritten vor- und zurückspulen, indem du die Tasten \"H\" und \"L\" benutzt. Diese Funktion ermöglicht es dir, auch die Pfeiltasten zu benutzen.\nUm die Anzahl der Sekunden zu ändern, um die gespult werden soll, benutze die Option unten.",
+    "feature_desc_arrowKeySkipBy": "Um wie viele Sekunden vor- und zurückspulen, wenn die Pfeiltasten benutzt werden",
+    "feature_desc_switchBetweenSites": "Füge einen Hotkey hinzu, um zwischen den YT und YTM Seiten zu wechseln",
+    "feature_helptext_switchBetweenSites": "Wenn du auf YouTube oder YouTube Music bist, kannst du mit diesem Hotkey zur anderen Seite wechseln, während du auf demselben Video / Song bleibst.",
+    "feature_desc_switchSitesHotkey": "Welcher Hotkey muss gedrückt werden, um zwischen den Seiten zu wechseln?",
+    "feature_desc_anchorImprovements": "Links auf der Seite erstellen und verbessern, damit Dinge einfacher in einem neuen Tab geöffnet werden können",
+    "feature_helptext_anchorImprovements": "Einige Elemente auf der Seite sind nur mit der linken Maustaste klickbar, was bedeutet, dass du sie nicht in einem neuen Tab öffnen kannst, indem du darauf mit der mittleren Maustaste klickst oder durch das Kontextmenü mit Shift + Rechtsklick. Diese Funktion fügt Links zu vielen von ihnen hinzu oder vergrößert vorhandene, um das Klicken zu erleichtern.",
+
+    "feature_desc_geniusLyrics": "Füge einen Knopf zu dem aktuell spielenden Song hinzu, um den Songtext auf genius.com zu öffnen",
+
+    "feature_desc_locale": "Sprache",
+    "feature_desc_versionCheck": "Prüfe alle 24 Stunden auf Updates",
+    "feature_helptext_versionCheck": "Diese Funktion prüft alle 24 Stunden auf Updates, benachrichtigt dich, wenn eine neue Version verfügbar ist und ermöglicht es dir, das Skript manuell zu aktualisieren.\nWenn dein Userscript-Manager Skripte automatisch aktualisiert, kannst du diese Funktion deaktivieren.",
+    "feature_desc_logLevel": "Wie viele Informationen sollen in der Konsole geloggt werden?",
+    "feature_helptext_logLevel": "Das Ändern dieses Wertes ist wirklich nur für Debugging-Zwecke notwendig, wenn du ein Problem hast.\nSolltest du eines haben, kannst du den Log-Level hier erhöhen, die JavaScript-Konsole deines Browsers (normalerweise mit Strg + Shift + K) öffnen und Screenshots dieses Logs in einem GitHub-Issue hinzufügen."
+  }
+}

+ 6 - 0
assets/translations/en_UK.json

@@ -0,0 +1,6 @@
+{
+  "base": "en_US",
+  "translations": {
+    "feature_category_behavior": "Behaviour"
+  }
+}

+ 144 - 0
assets/translations/en_US.json

@@ -0,0 +1,144 @@
+{
+  "translations": {
+    "config_menu_option": "%1 Configuration",
+    "config_menu_title": "%1 - Configuration",
+    "changelog_menu_title": "%1 - Changelog",
+    "export_menu_title": "%1 - Export Configuration",
+    "import_menu_title": "%1 - Import Configuration",
+    "open_menu_tooltip": "Open %1's configuration menu",
+    "close_menu_tooltip": "Close the menu",
+    "reload_hint": "You need to reload the page to apply any changes.",
+    "reload_now": "Reload now",
+    "reload_tooltip": "Reload the page",
+    "version_tooltip": "Version %1 (build %2) - click to open the changelog",
+    "export": "Export",
+    "export_hint": "Copy the following text to export your configuration:",
+    "export_tooltip": "Export your current configuration",
+    "import": "Import",
+    "import_hint": "Paste the configuration you want to import into the field below, then click the import button:",
+    "import_tooltip": "Import a configuration you have previously exported",
+    "start_import_tooltip": "Click to import the configuration you pasted above",
+    "import_error_invalid": "The imported data is invalid",
+    "import_error_no_format_version": "The imported data does not contain a format version",
+    "import_error_no_data": "The imported object does not contain any data",
+    "import_error_wrong_format_version": "The imported data is in an unsupported format version (expected %1 or lower but got %2)",
+    "import_success_confirm_reload": "Successfully imported the configuration.\nDo you want to reload the page now to apply changes?",
+    "reset_tooltip": "Reset all settings to their default values",
+    "reset_confirm": "Do you really want to reset all settings to their default values?\nThe page will be automatically reloaded.",
+    "copy_to_clipboard": "Copy to clipboard",
+    "copy_config_tooltip": "Copy the configuration to your clipboard",
+    "copied_notice": "Copied!",
+    "open_github": "Open %1 on GitHub",
+    "open_discord": "Join my Discord server",
+    "open_greasyfork": "Open %1 on GreasyFork",
+    "open_openuserjs": "Open %1 on OpenUserJS",
+    "lang_changed_prompt_reload": "The language was changed.\nDo you want to reload the page now to apply changes?",
+
+    "reset": "Reset",
+    "close": "Close",
+    "log_level_debug": "Debug (most)",
+    "log_level_info": "Info (only important)",
+    "toggled_on": "On",
+    "toggled_off": "Off",
+    "remove_from_queue": "Remove this song from the queue",
+    "delete_from_list": "Delete this song from the list",
+    "couldnt_remove_from_queue": "Couldn't remove this song from the queue",
+    "couldnt_delete_from_list": "Couldn't delete this song from the list",
+    "scroll_to_playing": "Scroll to the currently playing song",
+    "scroll_to_bottom": "Click to scroll to the bottom",
+    "volume_tooltip": "Volume: %1% (Sensitivity: %2%)",
+    "middle_click_open_tab": "Middle-click to open in a new tab",
+    "boost_gain_enable_tooltip": "Boost the volume to %1%",
+    "boost_gain_disable_tooltip": "Disable the volume boost",
+
+    "open_current_lyrics": "Open the current song's lyrics in a new tab",
+    "open_lyrics": "Open this song's lyrics in a new tab",
+    "lyrics_loading": "Loading lyrics URL...",
+    "lyrics_rate_limited-1": "You are being rate limited.\nPlease wait a few seconds before requesting more lyrics.",
+    "lyrics_rate_limited-n": "You are being rate limited.\nPlease wait %1 seconds before requesting more lyrics.",
+    "lyrics_not_found_confirm_open_search": "Couldn't find a lyrics page for this song.\nDo you want to open genius.com to manually search for it?",
+    "lyrics_not_found_click_open_search": "Couldn't find lyrics URL - click to open the manual lyrics search",
+
+    "hotkey_input_click_to_change": "Click to change",
+    "hotkey_input_click_to_change_tooltip": "Click, then press the desired key combination",
+    "hotkey_input_click_to_cancel_tooltip": "Click to cancel",
+    "hotkey_key_ctrl": "Ctrl",
+    "hotkey_key_shift": "Shift",
+    "hotkey_key_mac_option": "Option",
+    "hotkey_key_alt": "Alt",
+
+    "welcome_menu_title": "Welcome to %1!",
+    "config_menu": "Config Menu",
+    "open_config_menu_tooltip": "Click to open the configuration menu",
+    "open_changelog": "Changelog",
+    "open_changelog_tooltip": "Click to open the changelog",
+    "feature_help_button_tooltip": "Click to get more information about this feature",
+    "welcome_text_line_1": "Thank you for installing!",
+    "welcome_text_line_2": "I hope you enjoy using %1 as much as I enjoyed making it 😃",
+    "welcome_text_line_3": "If you like %1, please leave a rating on %2GreasyFork%3 or %4OpenUserJS%5",
+    "welcome_text_line_4": "My work relies on donations so please consider %1donating ❤️%2",
+    "welcome_text_line_5": "Found a bug or want to suggest a feature? Please %1open an issue on GitHub%2",
+
+    "list_button_placement_queue_only": "Currently playing queue only",
+    "list_button_placement_everywhere": "In every song list",
+
+    "remember_song_time_sites_all": "Both sites",
+    "remember_song_time_sites_yt": "Only YouTube",
+    "remember_song_time_sites_ytm": "Only YouTube Music",
+
+    "new_version_available": "A new version of %1 is available!\nCurrently installed: %2 - new version: %3\n(You can disable this notification in the config menu)\n\nDo you want to open %4 to install it manually?",
+
+    "feature_category_layout": "Layout",
+    "feature_category_songLists": "Song Lists",
+    "feature_category_behavior": "Behavior",
+    "feature_category_input": "Input",
+    "feature_category_lyrics": "Lyrics",
+    "feature_category_general": "General",
+
+    "feature_desc_removeUpgradeTab": "Remove the Upgrade / Premium tab",
+    "feature_desc_volumeSliderLabel": "Add a percentage label next to the volume slider",
+    "feature_desc_volumeSliderSize": "The width of the volume slider in pixels",
+    "feature_desc_volumeSliderStep": "Volume slider sensitivity (by how little percent the volume can be changed at a time)",
+    "feature_desc_volumeSliderScrollStep": "Volume slider scroll wheel sensitivity in percent - snaps to the nearest sensitivity value from above",
+    "feature_helptext_volumeSliderScrollStep": "By how much percent the volume should be changed when scrolling the volume slider with the mouse wheel.\nThis should be a multiple of the volume slider sensitivity, otherwise there will be small irregular jumps in the volume when scrolling.",
+    "feature_desc_watermarkEnabled": "Show a watermark under the site logo that opens this config menu",
+    "feature_helptext_watermarkEnabled": "If this is disabled, you can still open the config menu by clicking the option in the menu that opens when you click your profile picture in the top right corner.\nHowever it will be harder to find the easter egg ;)",
+    "feature_desc_removeShareTrackingParam": "Remove the tracking parameter \"&si\" from links in the share popup",
+    "feature_helptext_removeShareTrackingParam": "For analytics purposes YouTube adds a tracking parameter to the end of the URL you can copy in the share menu. While not directly harmful, it makes the URL longer and gives YouTube more information about you and the people you send the link to.",
+    "feature_desc_numKeysSkipToTime": "Enable skipping to a specific time in the video by pressing a number key (0-9)",
+    "feature_desc_fixSpacing": "Fix spacing issues in the layout",
+    "feature_helptext_fixSpacing": "There are various locations in the user interface where the spacing between elements is inconsistent. This feature fixes those issues.",
+
+    "feature_desc_lyricsQueueButton": "Add a button to each song in a queue to quickly open its lyrics page",
+    "feature_desc_deleteFromQueueButton": "Add a button to each song in a queue to quickly remove it",
+    "feature_desc_listButtonsPlacement": "Where should the queue buttons show up?",
+    "feature_helptext_listButtonsPlacement": "There are various song lists on the site like album pages, playlists and the currently playing queue. With this option you can choose where the queue buttons should show up.",
+    "feature_desc_scrollToActiveSongBtn": "Add a button to the queue to scroll to the currently playing song",
+
+    "feature_desc_disableBeforeUnloadPopup": "Prevent the confirmation popup that appears when trying to leave the site while a song is playing",
+    "feature_helptext_disableBeforeUnloadPopup": "When trying to leave the site while a few seconds into a song that is actively playing, a popup will appear asking you to confirm that you want to leave the site. It might say something along the lines of \"you have unsaved data\" or \"this site is asking if you want to close it\".\nThis feature disables that popup entirely.",
+    "feature_desc_closeToastsTimeout": "After how many seconds to close permanent notifications - 0 to only close them manually (default behavior)",
+    "feature_helptext_closeToastsTimeout": "Most popups that appear in the bottom left corner will close automatically after 3 seconds with the exception of certain ones like when liking a song.\nThis feature allows you to set a time for those permanent popups to be closed.\nThe other kind of popups will stay unaffected.\nSet this to 0 for the default behavior of not closing permanent notifications.",
+    "feature_desc_rememberSongTime": "Remember the last song's time when reloading or restoring the tab",
+    "feature_helptext_rememberSongTime-1": "Sometimes when reloading the page or restoring it after accidentally closing it, you want to resume listening at the same point. This feature allows you to do that.\nIn order to record the song's time, you need to play it for %1 second, then its time will be remembered and be restorable for a short while.",
+    "feature_helptext_rememberSongTime-n": "Sometimes when reloading the page or restoring it after accidentally closing it, you want to resume listening at the same point. This feature allows you to do that.\nIn order to record the song's time, you need to play it for %1 seconds, then its time will be remembered and be restorable for a short while.",
+    "feature_desc_rememberSongTimeSites": "On which sites should the song time be remembered and restored?",
+
+    "feature_desc_arrowKeySupport": "Use arrow keys to skip forwards and backwards in the currently playing song",
+    "feature_helptext_arrowKeySupport": "Normally you can only skip forwards and backwards by a fixed 10 second interval with the keys \"H\" and \"L\". This feature allows you to use the arrow keys too.\nTo change the amount of seconds to skip, use the option below.",
+    "feature_desc_arrowKeySkipBy": "By how many seconds to skip when using the arrow keys",
+    "feature_desc_switchBetweenSites": "Add a hotkey to switch between the YT and YTM sites on a video / song",
+    "feature_helptext_switchBetweenSites": "Pressing this hotkey will switch to the other site if you are on YouTube or YouTube Music while staying on the same video / song.",
+    "feature_desc_switchSitesHotkey": "Which hotkey needs to be pressed to switch sites?",
+    "feature_desc_anchorImprovements": "Add and improve links all over the page so things can be opened in a new tab easier",
+    "feature_helptext_anchorImprovements": "Some elements on the page are only clickable with the left mouse button, which means you can't open them in a new tab by middle-clicking or through the context menu using shift + right-click. This feature adds links to a lot of them or enlarges existing ones to make clicking easier.",
+
+    "feature_desc_geniusLyrics": "Add a button to the media controls of the currently playing song to open its lyrics on genius.com",
+
+    "feature_desc_locale": "Language",
+    "feature_desc_versionCheck": "Check for updates every 24 hours",
+    "feature_helptext_versionCheck": "This feature checks for updates every 24 hours, notifies you if a new version is available and allows you to update the script manually.\nIf your userscript manager extension updates scripts automatically, you can disable this feature.",
+    "feature_desc_logLevel": "How much information to log to the console",
+    "feature_helptext_logLevel": "Changing this is really only needed for debugging purposes as a result of experiencing a problem.\nShould you have one, you can increase the log level here, open your browser's JavaScript console (usually with Ctrl + Shift + K) and attach screenshots of that log in a GitHub issue."
+  }
+}

+ 141 - 0
assets/translations/es_ES.json

@@ -0,0 +1,141 @@
+{
+  "translations": {
+    "config_menu_option": "Configuración de %1",
+    "config_menu_title": "%1 - Configuración",
+    "changelog_menu_title": "%1 - Registro de cambios",
+    "export_menu_title": "%1 - Exportar configuración",
+    "import_menu_title": "%1 - Importar configuración",
+    "open_menu_tooltip": "Abra el menú de configuración de %1",
+    "close_menu_tooltip": "Cerrar el menú",
+    "reload_hint": "Necesitas recargar la página para aplicar cualquier cambio.",
+    "reload_now": "Recargar ahora",
+    "reload_tooltip": "Recargar la página",
+    "version_tooltip": "Versión %1 (compilación %2) - haga clic para abrir el registro de cambios",
+    "export": "Exportar",
+    "export_hint": "Copie el siguiente texto para exportar su configuración:",
+    "export_tooltip": "Exporte su configuración actual",
+    "import": "Importar",
+    "import_hint": "Pegue la configuración que desea importar en el campo a continuación, luego haga clic en el botón de importación:",
+    "import_tooltip": "Importe una configuración que haya exportado anteriormente",
+    "start_import_tooltip": "Haga clic para importar la configuración que pegó arriba",
+    "import_error_invalid": "Los datos importados no son válidos",
+    "import_error_no_format_version": "Los datos importados no contienen una versión de formato",
+    "import_error_no_data": "El objeto importado no contiene ningún dato",
+    "import_error_wrong_format_version": "Los datos importados están en una versión de formato no compatible (se esperaba %1 o inferior pero se obtuvo %2)",
+    "import_success_confirm_reload": "Configuración importada con éxito.\n¿Quieres recargar la página ahora para aplicar los cambios?",
+    "reset_tooltip": "Restablecer todos los ajustes a sus valores predeterminados",
+    "reset_confirm": "¿Realmente quieres restablecer todos los ajustes a sus valores predeterminados?\nLa página se volverá a cargar automáticamente.",
+    "copy_to_clipboard": "Copiar al portapapeles",
+    "copy_config_tooltip": "Copie la configuración en su portapapeles",
+    "copied_notice": "¡Copiado!",
+    "open_github": "Abrir %1 en GitHub",
+    "open_discord": "Únete a mi servidor de Discord",
+    "open_greasyfork": "Abrir %1 en GreasyFork",
+    "open_openuserjs": "Abrir %1 en OpenUserJS",
+    "lang_changed_prompt_reload": "El idioma se cambió.\n¿Quieres recargar la página ahora para aplicar los cambios?",
+
+    "reset": "Reiniciar",
+    "close": "Cerrar",
+    "log_level_debug": "Depurar (más)",
+    "log_level_info": "Información (solo importante)",
+    "toggled_on": "Encendido",
+    "toggled_off": "Apagado",
+    "remove_from_queue": "Eliminar esta canción de la cola",
+    "delete_from_list": "Eliminar esta canción de la lista",
+    "couldnt_remove_from_queue": "No se pudo eliminar esta canción de la cola",
+    "couldnt_delete_from_list": "No se pudo eliminar esta canción de la lista",
+    "scroll_to_playing": "Desplácese hasta la canción que se está reproduciendo actualmente",
+    "scroll_to_bottom": "Haga clic para desplazarse hasta el final",
+    "volume_tooltip": "Volumen: %1% (Sensibilidad: %2%)",
+    "middle_click_open_tab": "Haga clic con el botón central para abrir en una nueva pestaña",
+    "boost_gain_enable_tooltip": "Aumente el volumen al %1%",
+    "boost_gain_disable_tooltip": "Deshabilitar el aumento de volumen",
+
+    "open_current_lyrics": "Abrir la letra de la canción actual en una nueva pestaña",
+    "open_lyrics": "Abrir la letra de esta canción en una nueva pestaña",
+    "lyrics_loading": "Cargando URL de letras...",
+    "lyrics_rate_limited-1": "Se está limitando la velocidad.\nEspere unos segundos antes de solicitar más letras.",
+    "lyrics_rate_limited-n": "Se está limitando la velocidad.\nEspere %1 segundos antes de solicitar más letras.",
+    "lyrics_not_found_confirm_open_search": "No se pudo encontrar una página de letras para esta canción.\n¿Quieres abrir genius.com para buscarla manualmente?",
+    "lyrics_not_found_click_open_search": "No se pudo encontrar la URL de las letras: haga clic para abrir la búsqueda manual de letras",
+
+    "hotkey_input_click_to_change": "Haga clic para cambiar",
+    "hotkey_input_click_to_change_tooltip": "Haga clic, luego presione la tecla de acceso rápido deseada",
+    "hotkey_input_click_to_cancel_tooltip": "Haga clic para cancelar",
+    "hotkey_key_ctrl": "Ctrl",
+    "hotkey_key_shift": "Mayús",
+    "hotkey_key_mac_option": "Option",
+    "hotkey_key_alt": "Alt",
+
+    "welcome_menu_title": "¡Bienvenido a %1!",
+    "config_menu": "Configuración",
+    "open_config_menu_tooltip": "Haga clic para abrir el menú de configuración",
+    "open_changelog": "Registro de cambios",
+    "open_changelog_tooltip": "Haga clic para abrir el registro de cambios",
+    "feature_help_button_tooltip": "Haga clic para obtener más información sobre esta función",
+    "welcome_text_line_1": "Gracias por instalar!",
+    "welcome_text_line_2": "Espero que disfrutes usando %1 tanto como yo disfruté haciéndolo 😃",
+    "welcome_text_line_3": "Si te gusta %1, por favor deja una calificación en %2GreasyFork%3 o %4OpenUserJS%5",
+    "welcome_text_line_4": "Mi trabajo depende de las donaciones, así que por favor considera %1donar ❤️%2",
+    "welcome_text_line_5": "¿Encontraste un error o quieres sugerir una función? Por favor %1abre un problema en GitHub%2",
+    "list_button_placement_queue_only": "Solo en la cola",
+    "list_button_placement_everywhere": "En todas las listas de canciones",
+    "remember_song_time_sites_all": "Ambos sitios",
+    "remember_song_time_sites_yt": "Solo YouTube",
+    "remember_song_time_sites_ytm": "Solo YouTube Music",
+    "new_version_available": "¡Una nueva versión de %1 está disponible!\nActualmente instalado: %2 - nueva versión: %3\n(Puede desactivar esta notificación en el menú de configuración)\n\n¿Quieres abrir %4 para instalarlo manualmente?",
+
+    "feature_category_layout": "Diseño",
+    "feature_category_songLists": "Listas de canciones",
+    "feature_category_behavior": "Comportamiento",
+    "feature_category_input": "Entrada",
+    "feature_category_lyrics": "Letras",
+    "feature_category_general": "General",
+
+    "feature_desc_removeUpgradeTab": "Eliminar la pestaña Actualizar / Premium",
+    "feature_desc_volumeSliderLabel": "Agregue una etiqueta de porcentaje junto al control deslizante de volumen",
+    "feature_desc_volumeSliderSize": "El ancho del control deslizante de volumen en píxeles",
+    "feature_desc_volumeSliderStep": "Sensibilidad del control deslizante de volumen (en qué porcentaje se puede cambiar el volumen a la vez)",
+    "feature_desc_volumeSliderScrollStep": "Sensibilidad del control deslizante de volumen al desplazarse con la rueda del mouse en porcentaje - se ajusta al valor de sensibilidad más cercano desde arriba",
+    "feature_helptext_volumeSliderScrollStep": "Por cuánto porcentaje debe cambiarse el volumen al desplazar el control deslizante de volumen con la rueda del mouse.\nEsto debe ser un múltiplo de la sensibilidad del control deslizante de volumen, de lo contrario habrá pequeños saltos irregulares en el volumen al desplazarse.",
+    "feature_desc_watermarkEnabled": "Mostrar una marca de agua debajo del logotipo del sitio que abre este menú de configuración",
+    "feature_helptext_watermarkEnabled": "Si esto está deshabilitado, aún puede abrir el menú de configuración haciendo clic en la opción en el menú que se abre cuando hace clic en su imagen de perfil en la esquina superior derecha.\nSin embargo, será más difícil encontrar el huevo de pascua ;)",
+    "feature_desc_removeShareTrackingParam": "Eliminar el parámetro de seguimiento \"&si\" de los enlaces en el cuadro de diálogo Compartir",
+    "feature_helptext_removeShareTrackingParam": "Por motivos de análisis, YouTube agrega un parámetro de seguimiento al final de la URL que puede copiar en el menú Compartir. Si bien no es directamente perjudicial, hace que la URL sea más larga y le da a YouTube más información sobre usted y las personas a las que envía el enlace.",
+    "feature_desc_numKeysSkipToTime": "Habilitar el salto a un momento específico en el video presionando una tecla numérica (0-9)",
+    "feature_desc_fixSpacing": "Solucionar problemas de espaciado en el diseño",
+    "feature_helptext_fixSpacing": "Hay varios lugares en la interfaz de usuario donde el espaciado entre elementos es inconsistente. Esta función soluciona esos problemas.",
+
+    "feature_desc_lyricsQueueButton": "Agregue un botón a cada canción en la cola para abrir rápidamente su página de letras",
+    "feature_desc_deleteFromQueueButton": "Agregue un botón a cada canción en la cola para eliminarla rápidamente",
+    "feature_desc_listButtonsPlacement": "¿Dónde deberían aparecer los botones de la cola?",
+    "feature_helptext_listButtonsPlacement": "Hay varias listas de canciones en el sitio, como páginas de álbumes, listas de reproducción y la cola de reproducción actual. Con esta opción, puede elegir dónde deben aparecer los botones de la cola.",
+    "feature_desc_scrollToActiveSongBtn": "Agregue un botón a la cola para desplazarse hasta la canción que se está reproduciendo actualmente",
+
+    "feature_desc_disableBeforeUnloadPopup": "Evite la ventana emergente de confirmación que aparece al intentar salir del sitio mientras se reproduce una canción",
+    "feature_helptext_disableBeforeUnloadPopup": "Cuando intenta salir del sitio mientras está reproduciendo una canción que lleva unos segundos, aparecerá una ventana emergente que le pedirá que confirme que desea salir del sitio. Podría decir algo así como \"tiene datos no guardados\" o \"este sitio está preguntando si desea cerrarlo\".\nEsta función deshabilita completamente esa ventana emergente.",
+    "feature_desc_closeToastsTimeout": "Después de cuántos segundos cerrar las notificaciones permanentes - 0 para cerrarlas solo manualmente (comportamiento predeterminado)",
+    "feature_helptext_closeToastsTimeout": "La mayoría de las notificaciones que aparecen en la esquina inferior izquierda se cerrarán automáticamente después de 3 segundos, con la excepción de ciertas como cuando le gusta una canción.\nEsta función le permite establecer un tiempo para que se cierren las notificaciones permanentes.\nEl otro tipo de notificaciones no se verá afectado.\nEstablezca esto en 0 para el comportamiento predeterminado de no cerrar las notificaciones permanentes.",
+    "feature_desc_rememberSongTime": "Recuerde el tiempo de la última canción al volver a cargar o restaurar la pestaña",
+    "feature_helptext_rememberSongTime-1": "A veces, al volver a cargar la página o restaurarla después de cerrarla accidentalmente, desea reanudar la escucha en el mismo punto. Esta función le permite hacer eso.\nPara registrar el tiempo de la canción, debe reproducirla durante %1 segundo, luego su tiempo se recordará y se podrá restaurar por un corto tiempo.",
+    "feature_helptext_rememberSongTime-n": "A veces, al volver a cargar la página o restaurarla después de cerrarla accidentalmente, desea reanudar la escucha en el mismo punto. Esta función le permite hacer eso.\nPara registrar el tiempo de la canción, debe reproducirla durante %1 segundos, luego su tiempo se recordará y se podrá restaurar por un corto tiempo.",
+    "feature_desc_rememberSongTimeSites": "¿En qué sitios se debe recordar y restaurar el tiempo de la canción?",
+
+    "feature_desc_arrowKeySupport": "Use las teclas de flecha para saltar hacia adelante y hacia atrás en la canción que se está reproduciendo actualmente",
+    "feature_helptext_arrowKeySupport": "Normalmente solo puede saltar hacia adelante y hacia atrás en un intervalo fijo de 10 segundos con las teclas \"H\" y \"L\". Esta función le permite usar las teclas de flecha también.\nPara cambiar la cantidad de segundos para saltar, use la opción a continuación.",
+    "feature_desc_arrowKeySkipBy": "Por cuántos segundos saltar al usar las teclas de flecha",
+    "feature_desc_switchBetweenSites": "Agregue una tecla de acceso rápido para cambiar entre los sitios YT y YTM en una canción",
+    "feature_helptext_switchBetweenSites": "Al presionar esta tecla de acceso rápido, cambiará al otro sitio si está en YouTube o YouTube Music mientras se mantiene en el mismo video / canción.",
+    "feature_desc_switchSitesHotkey": "¿Qué tecla de acceso rápido debe presionarse para cambiar de sitio?",
+    "feature_desc_anchorImprovements": "Agregue e improvise enlaces en toda la página para que las cosas se puedan abrir en una nueva pestaña más fácilmente",
+    "feature_helptext_anchorImprovements": "Algunos elementos en la página solo se pueden hacer clic con el botón izquierdo del mouse, lo que significa que no se pueden abrir en una nueva pestaña haciendo clic con el botón central o mediante el menú contextual con shift + clic derecho. Esta función agrega enlaces a muchos de ellos o los agranda para facilitar el clic.",
+
+    "feature_desc_geniusLyrics": "Agregue un botón a los controles multimedia de la canción que se está reproduciendo actualmente para abrir sus letras en genius.com",
+
+    "feature_desc_locale": "Idioma",
+    "feature_desc_versionCheck": "Compruebe si hay actualizaciones",
+    "feature_helptext_versionCheck": "Esta función comprueba si hay actualizaciones cada 24 horas, le notifica si hay una nueva versión disponible y le permite actualizar el script manualmente.\nSi su extensión de administrador de usuarios de scripts actualiza los scripts automáticamente, puede desactivar esta función.",
+    "feature_desc_logLevel": "Cuánta información registrar en la consola",
+    "feature_helptext_logLevel": "Cambiar esto solo es necesario para fines de depuración como resultado de experimentar un problema.\nSi tiene uno, puede aumentar el nivel de registro aquí, abrir la consola de JavaScript de su navegador (generalmente con Ctrl + Shift + K) y adjuntar capturas de pantalla de ese registro en un problema de GitHub."
+  }
+}

+ 141 - 0
assets/translations/fr_FR.json

@@ -0,0 +1,141 @@
+{
+  "translations": {
+    "config_menu_option": "Configuration de %1",
+    "config_menu_title": "%1 - Configuration",
+    "changelog_menu_title": "%1 - Historique des modifications",
+    "export_menu_title": "%1 - Exporter la configuration",
+    "import_menu_title": "%1 - Importer la configuration",
+    "open_menu_tooltip": "Ouvrir le menu de configuration de %1",
+    "close_menu_tooltip": "Fermer le menu",
+    "reload_hint": "Vous devez recharger la page pour appliquer les modifications.",
+    "reload_now": "Recharger maintenant",
+    "reload_tooltip": "Recharger la page",
+    "version_tooltip": "Version %1 (build %2) - cliquez pour ouvrir l'historique des modifications",
+    "export": "Exporter",
+    "export_hint": "Copiez le texte suivant pour exporter votre configuration:",
+    "export_tooltip": "Exporter votre configuration actuelle",
+    "import": "Importer",
+    "import_hint": "Collez la configuration que vous souhaitez importer dans le champ ci-dessous, puis cliquez sur le bouton d'importation:",
+    "import_tooltip": "Importer une configuration que vous avez précédemment exportée",
+    "start_import_tooltip": "Cliquez pour importer la configuration que vous avez collée ci-dessus",
+    "import_error_invalid": "Les données importées ne sont pas valides",
+    "import_error_no_format_version": "Les données importées ne contiennent pas de version de format",
+    "import_error_no_data": "L'objet importé ne contient aucune donnée",
+    "import_error_wrong_format_version": "Les données importées sont dans une version de format non prise en charge (attendue %1 ou inférieure mais obtenue %2)",
+    "import_success_confirm_reload": "La configuration a été importée avec succès.\nVoulez-vous recharger la page maintenant pour appliquer les modifications?",
+    "reset_tooltip": "Réinitialiser tous les paramètres à leurs valeurs par défaut",
+    "reset_confirm": "Voulez-vous vraiment réinitialiser tous les paramètres à leurs valeurs par défaut?\nLa page sera automatiquement rechargée.",
+    "copy_to_clipboard": "Copier dans le presse-papiers",
+    "copy_config_tooltip": "Copiez la configuration dans votre presse-papiers",
+    "copied_notice": "Copié!",
+    "open_github": "Ouvrir %1 sur GitHub",
+    "open_discord": "Rejoignez mon serveur Discord",
+    "open_greasyfork": "Ouvrir %1 sur GreasyFork",
+    "open_openuserjs": "Ouvrir %1 sur OpenUserJS",
+    "lang_changed_prompt_reload": "La langue a été modifiée.\nVoulez-vous recharger la page maintenant pour appliquer les modifications?",
+
+    "reset": "Réinitialiser",
+    "close": "Fermer",
+    "log_level_debug": "Déboguer (le plus)",
+    "log_level_info": "Info (seulement important)",
+    "toggled_on": "Activé",
+    "toggled_off": "Désactivé",
+    "remove_from_queue": "Supprimer cette chanson de la file d'attente",
+    "delete_from_list": "Supprimer cette chanson de la liste",
+    "couldnt_remove_from_queue": "Impossible de supprimer cette chanson de la file d'attente",
+    "couldnt_delete_from_list": "Impossible de supprimer cette chanson de la liste",
+    "scroll_to_playing": "Faites défiler jusqu'à la chanson en cours de lecture",
+    "scroll_to_bottom": "Cliquez pour faire défiler vers le bas",
+    "volume_tooltip": "Volume: %1% (Sensibilité: %2%)",
+    "middle_click_open_tab": "Cliquez avec le bouton du milieu pour ouvrir dans un nouvel onglet",
+    "boost_gain_enable_tooltip": "Augmenter le volume à %1%",
+    "boost_gain_disable_tooltip": "Désactiver l'augmentation du volume",
+
+    "open_current_lyrics": "Ouvrir les paroles de la chanson en cours dans un nouvel onglet",
+    "open_lyrics": "Ouvrir les paroles de cette chanson dans un nouvel onglet",
+    "lyrics_loading": "Chargement de l'URL des paroles...",
+    "lyrics_rate_limited-1": "Vous êtes limité par le taux.\nVeuillez patienter quelques secondes avant de demander plus de paroles.",
+    "lyrics_rate_limited-n": "Vous êtes limité par le taux.\nVeuillez patienter %1 secondes avant de demander plus de paroles.",
+    "lyrics_not_found_confirm_open_search": "Il n'y a pas de page de paroles pour cette chanson.\nVoulez-vous ouvrir genius.com pour la rechercher manuellement?",
+    "lyrics_not_found_click_open_search": "Impossible de trouver l'URL des paroles - cliquez pour ouvrir la recherche manuelle des paroles",
+
+    "hotkey_input_click_to_change": "Cliquez pour changer",
+    "hotkey_input_click_to_change_tooltip": "Cliquez, puis appuyez sur la combinaison de touches souhaitée",
+    "hotkey_input_click_to_cancel_tooltip": "Cliquez pour annuler",
+    "hotkey_key_ctrl": "Ctrl",
+    "hotkey_key_shift": "Maj",
+    "hotkey_key_mac_option": "Option",
+    "hotkey_key_alt": "Alt",
+
+    "welcome_menu_title": "Bienvenue sur %1!",
+    "config_menu": "Menu de configuration",
+    "open_config_menu_tooltip": "Cliquez pour ouvrir le menu de configuration",
+    "open_changelog": "Historique des modifications",
+    "open_changelog_tooltip": "Cliquez pour ouvrir l'historique des modifications",
+    "feature_help_button_tooltip": "Cliquez pour obtenir plus d'informations sur cette fonctionnalité",
+    "welcome_text_line_1": "Merci d'avoir installé!",
+    "welcome_text_line_2": "J'espère que vous apprécierez d'utiliser %1 autant que j'ai apprécié de le faire 😃",
+    "welcome_text_line_3": "Si vous aimez %1, laissez une note sur %2GreasyFork%3 ou %4OpenUserJS%5",
+    "welcome_text_line_4": "Mon travail repose sur des dons, alors veuillez envisager de %1faire un don ❤️%2",
+    "welcome_text_line_5": "Vous avez trouvé un bug ou souhaitez suggérer une fonctionnalité? Veuillez %1ouvrir un problème sur GitHub%2",
+    "list_button_placement_queue_only": "Seulement dans la file d'attente",
+    "list_button_placement_everywhere": "Dans chaque liste de chansons",
+    "remember_song_time_sites_all": "Les deux sites",
+    "remember_song_time_sites_yt": "Seulement YouTube",
+    "remember_song_time_sites_ytm": "Seulement YouTube Music",
+    "new_version_available": "Une nouvelle version de %1 est disponible!\nActuellement installée: %2 - nouvelle version: %3\n(Vous pouvez désactiver cette notification dans le menu de configuration)\n\nVoulez-vous ouvrir",
+
+    "feature_category_layout": "Disposition",
+    "feature_category_songLists": "Listes de chansons",
+    "feature_category_behavior": "Comportement",
+    "feature_category_input": "Entrée",
+    "feature_category_lyrics": "Paroles",
+    "feature_category_general": "Général",
+
+    "feature_desc_removeUpgradeTab": "Supprimer l'onglet Mise à niveau / Premium",
+    "feature_desc_volumeSliderLabel": "Ajouter une étiquette de pourcentage à côté du curseur de volume",
+    "feature_desc_volumeSliderSize": "La largeur du curseur de volume en pixels",
+    "feature_desc_volumeSliderStep": "Sensibilité du curseur de volume (de combien de pour cent le volume peut être modifié à la fois)",
+    "feature_desc_volumeSliderScrollStep": "Sensibilité de la molette de la souris pour le curseur de volume en pour cent - se fixe à la valeur de sensibilité la plus proche par le haut",
+    "feature_helptext_volumeSliderScrollStep": "De combien de pour cent le volume doit être modifié lors du défilement du curseur de volume avec la molette de la souris.\nCela doit être un multiple de la sensibilité du curseur de volume, sinon il y aura de petits sauts irréguliers dans le volume lors du défilement.",
+    "feature_desc_watermarkEnabled": "Afficher un filigrane sous le logo du site qui ouvre ce menu de configuration",
+    "feature_helptext_watermarkEnabled": "Si cela est désactivé, vous pouvez toujours ouvrir le menu de configuration en cliquant sur l'option dans le menu qui s'ouvre lorsque vous cliquez sur votre photo de profil dans le coin supérieur droit.\nCependant, il sera plus difficile de trouver l'easter egg ;)",
+    "feature_desc_removeShareTrackingParam": "Supprimer le paramètre de suivi \"&si\" des liens dans la fenêtre contextuelle de partage",
+    "feature_helptext_removeShareTrackingParam": "À des fins d'analyse, YouTube ajoute un paramètre de suivi à la fin de l'URL que vous pouvez copier dans le menu de partage. Bien qu'il ne soit pas directement nocif, il rend l'URL plus longue et donne à YouTube plus d'informations sur vous et les personnes à qui vous envoyez le lien.",
+    "feature_desc_numKeysSkipToTime": "Activer le saut à un moment spécifique de la vidéo en appuyant sur une touche numérique (0-9)",
+    "feature_desc_fixSpacing": "Corriger les problèmes d'espacement dans la mise en page",
+    "feature_helptext_fixSpacing": "Il existe divers endroits dans l'interface utilisateur où l'espacement entre les éléments est incohérent. Cette fonctionnalité corrige ces problèmes.",
+
+    "feature_desc_lyricsQueueButton": "Ajouter un bouton à chaque chanson de la file d'attente pour ouvrir rapidement sa page de paroles",
+    "feature_desc_deleteFromQueueButton": "Ajouter un bouton à chaque chanson de la file d'attente pour la supprimer rapidement",
+    "feature_desc_listButtonsPlacement": "Où les boutons de file d'attente doivent-ils apparaître?",
+    "feature_helptext_listButtonsPlacement": "Il existe diverses listes de chansons sur le site comme les pages d'album, les listes de lecture et la file d'attente de lecture actuelle. Avec cette option, vous pouvez choisir où les boutons de file d'attente doivent apparaître.",
+    "feature_desc_scrollToActiveSongBtn": "Ajouter un bouton à la file d'attente pour faire défiler jusqu'à la chanson en cours de lecture",
+
+    "feature_desc_disableBeforeUnloadPopup": "Empêcher la fenêtre contextuelle de confirmation qui apparaît lors de la tentative de quitter le site pendant qu'une chanson est en cours de lecture",
+    "feature_helptext_disableBeforeUnloadPopup": "Lorsque vous essayez de quitter le site alors que vous êtes quelques secondes dans une chanson qui est en cours de lecture, une fenêtre contextuelle apparaîtra pour vous demander de confirmer que vous voulez quitter le site. Elle pourrait dire quelque chose comme \"vous avez des données non enregistrées\" ou \"ce site demande si vous voulez le fermer\".\nCette fonctionnalité désactive complètement cette fenêtre contextuelle.",
+    "feature_desc_closeToastsTimeout": "Au bout de combien de secondes fermer les notifications permanentes - 0 pour ne les fermer qu'à la main (comportement par défaut)",
+    "feature_helptext_closeToastsTimeout": "La plupart des notifications qui apparaissent dans le coin inférieur gauche se fermeront automatiquement après 3 secondes, à l'exception de certaines comme lorsque vous aimez une chanson.\nCette fonctionnalité vous permet de définir un délai pour la fermeture de ces notifications permanentes.\nLes autres types de notifications resteront inchangés.\nRéglez cette valeur à 0 pour le comportement par défaut de ne pas fermer les notifications permanentes.",
+    "feature_desc_rememberSongTime": "Se souvenir du temps de la dernière chanson lors du rechargement ou de la restauration de l'onglet",
+    "feature_helptext_rememberSongTime-1": "Parfois, lorsque vous rechargez la page ou la restaurez après l'avoir fermée accidentellement, vous voulez reprendre l'écoute au même point. Cette fonctionnalité vous permet de le faire.\nPour enregistrer le temps de la chanson, vous devez la lire pendant %1 seconde, puis son temps sera mémorisé et pourra être restauré pendant un court instant.",
+    "feature_helptext_rememberSongTime-n": "Parfois, lorsque vous rechargez la page ou la restaurez après l'avoir fermée accidentellement, vous voulez reprendre l'écoute au même point. Cette fonctionnalité vous permet de le faire.\nPour enregistrer le temps de la chanson, vous devez la lire pendant %1 secondes, puis son temps sera mémorisé et pourra être restauré pendant un court instant.",
+    "feature_desc_rememberSongTimeSites": "Sur quels sites le temps de la chanson doit-il être mémorisé et restauré?",
+
+    "feature_desc_arrowKeySupport": "Utilisez les touches fléchées pour avancer et reculer dans la chanson en cours de lecture",
+    "feature_helptext_arrowKeySupport": "Normalement, vous ne pouvez avancer et reculer que par intervalles fixes de 10 secondes avec les touches \"H\" et \"L\". Cette fonctionnalité vous permet d'utiliser aussi les touches fléchées.\nPour changer le nombre de secondes à sauter, utilisez l'option ci-dessous.",
+    "feature_desc_arrowKeySkipBy": "De combien de secondes sauter en utilisant les touches fléchées",
+    "feature_desc_switchBetweenSites": "Ajouter un raccourci pour passer d'un site à l'autre sur une vidéo / chanson",
+    "feature_helptext_switchBetweenSites": "En appuyant sur ce raccourci, vous passerez à l'autre site si vous êtes sur YouTube ou YouTube Music tout en restant sur la même vidéo / chanson.",
+    "feature_desc_switchSitesHotkey": "Quelle touche de raccourci doit être enfoncée pour passer d'un site à l'autre?",
+    "feature_desc_anchorImprovements": "Ajouter et améliorer les liens sur toute la page pour que les choses puissent être ouvertes dans un nouvel onglet plus facilement",
+    "feature_helptext_anchorImprovements": "Certains éléments de la page ne sont cliquables qu'avec le bouton gauche de la souris, ce qui signifie que vous ne pouvez pas les ouvrir dans un nouvel onglet en cliquant au milieu ou via le menu contextuel en utilisant Maj + clic droit. Cette fonctionnalité ajoute des liens à beaucoup d'entre eux ou les agrandit pour faciliter le clic.",
+
+    "feature_desc_geniusLyrics": "Ajouter un bouton aux contrôles multimédias de la chanson en cours de lecture pour ouvrir ses paroles sur genius.com",
+
+    "feature_desc_locale": "Langue",
+    "feature_desc_versionCheck": "Vérifier les mises à jour",
+    "feature_helptext_versionCheck": "Cette fonctionnalité vérifie les mises à jour toutes les 24 heures, vous avertit si une nouvelle version est disponible et vous permet de mettre à jour le script manuellement.\nSi votre gestionnaire de scripts utilisateur met à jour les scripts automatiquement, vous pouvez désactiver cette fonctionnalité.",
+    "feature_desc_logLevel": "Combien d'informations à enregistrer dans la console",
+    "feature_helptext_logLevel": "Changer cela n'est vraiment nécessaire que pour le débogage à la suite d'un problème rencontré.\nSi vous en avez un, vous pouvez augmenter le niveau de journalisation ici, ouvrir la console JavaScript de votre navigateur (généralement avec Ctrl + Maj + K) et joindre des captures d'écran de ce journal dans un problème GitHub."
+  }
+}

+ 141 - 0
assets/translations/hi_IN.json

@@ -0,0 +1,141 @@
+{
+  "translations": {
+    "config_menu_option": "%1 कॉन्फ़िगरेशन",
+    "config_menu_title": "%1 - कॉन्फ़िगरेशन",
+    "changelog_menu_title": "%1 - चेंजलॉग",
+    "export_menu_title": "%1 - निर्यात कॉन्फ़िगरेशन",
+    "import_menu_title": "%1 - आयात कॉन्फ़िगरेशन",
+    "open_menu_tooltip": "%1 का कॉन्फ़िगरेशन मेनू खोलें",
+    "close_menu_tooltip": "मेनू बंद करें",
+    "reload_hint": "आपको किसी भी परिवर्तन को लागू करने के लिए पृष्ठ को फिर से लोड करना होगा।",
+    "reload_now": "अब पुनः लोड करें",
+    "reload_tooltip": "पृष्ठ को पुनः लोड करें",
+    "version_tooltip": "संस्करण %1 (बिल्ड %2) - चेंजलॉग खोलने के लिए क्लिक करें",
+    "export": "निर्यात",
+    "export_hint": "निम्नलिखित पाठ को अपनी कॉन्फ़िगरेशन निर्यात करने के लिए कॉपी करें:",
+    "export_tooltip": "अपनी वर्तमान कॉन्फ़िगरेशन निर्यात करें",
+    "import": "आयात",
+    "import_hint": "आप जो कॉन्फ़िगरेशन आयात करना चाहते हैं, उसे नीचे दिए गए फ़ील्ड में पेस्ट करें, फिर आयात बटन पर क्लिक करें:",
+    "import_tooltip": "आपने पहले से निर्यात की गई कॉन्फ़िगरेशन आयात करें",
+    "start_import_tooltip": "आपने ऊपर पेस्ट की गई कॉन्फ़िगरेशन को आयात करने के लिए क्लिक करें",
+    "import_error_invalid": "आयात की गई डेटा अमान्य है",
+    "import_error_no_format_version": "आयात की गई डेटा में कोई फ़ॉर्मेट संस्करण नहीं है",
+    "import_error_no_data": "आयात किया गया ऑब्जेक्ट किसी भी डेटा को नहीं शामिल करता है",
+    "import_error_wrong_format_version": "आयात की गई डेटा एक असमर्थित फ़ॉर्मेट संस्करण में है (अपेक्षित %1 या नीचे लेकिन %2 मिला)",
+    "import_success_confirm_reload": "कॉन्फ़िगरेशन सफलतापूर्वक आयात किया गया।\nक्या आप परिवर्तन लागू करने के लिए पृष्ठ को अब पुनः लोड करना चाहते हैं?",
+    "reset_tooltip": "सभी सेटिंग्स को उनके डिफ़ॉल्ट मानों पर रीसेट करें",
+    "reset_confirm": "क्या आप वास्तव में सभी सेटिंग्स को उनके डिफ़ॉल्ट मानों पर रीसेट करना चाहते हैं?\nपृष्ठ स्वचालित रूप से पुनः लोड हो जाएगा।",
+    "copy_to_clipboard": "क्लिपबोर्ड पर कॉपी करें",
+    "copy_config_tooltip": "कॉन्फ़िगरेशन को अपने क्लिपबोर्ड पर कॉपी करें",
+    "copied_notice": "कॉपी किया गया!",
+    "open_github": "GitHub पर खोलें",
+    "open_discord": "मेरे Discord सर्वर में शामिल हों",
+    "open_greasyfork": "GreasyFork पर खोलें",
+    "open_openuserjs": "OpenUserJS पर खोलें",
+    "lang_changed_prompt_reload": "भाषा बदल दी गई थी।\nक्या आप परिवर्तन लागू करने के लिए पृष्ठ को अब पुनः लोड करना चाहते हैं?",
+
+    "reset": "रीसेट",
+    "close": "बंद करें",
+    "log_level_debug": "डीबग (सबसे अधिक)",
+    "log_level_info": "जानकारी (केवल महत्वपूर्ण)",
+    "toggled_on": "सक्रिय",
+    "toggled_off": "अक्षम",
+    "remove_from_queue": "इस गीत को कतार से हटाएं",
+    "delete_from_list": "इस गीत को सूची से हटाएं",
+    "couldnt_remove_from_queue": "कतार से इस गीत को हटाने में असमर्थ",
+    "couldnt_delete_from_list": "सूची से इस गीत को हटाने में असमर्थ",
+    "scroll_to_playing": "वर्तमान में चल रहे गीत पर स्क्रॉल करें",
+    "scroll_to_bottom": "नीचे स्क्रॉल करें",
+    "volume_tooltip": "वॉल्यूम: %1% (संवेदनशीलता: %2%)",
+    "middle_click_open_tab": "मध्य बटन क्लिक करें ताकि एक नई टैब में खुल जाए",
+    "boost_gain_enable_tooltip": "वॉल्यूम को %1% तक बढ़ाएँ",
+    "boost_gain_disable_tooltip": "वॉल्यूम बूस्ट अक्षम करें",
+
+    "open_current_lyrics": "एक नए टैब में वर्तमान गीत के बोल खोलें",
+    "open_lyrics": "एक नए टैब में इस गीत के बोल खोलें",
+    "lyrics_loading": "बोल लोड हो रहे हैं...",
+    "lyrics_rate_limited-1": "आपको रेट सीमित किया जा रहा है।\nकृपया अधिक बोल का अनुरोध करने से पहले कुछ सेकंड प्रतीक्षा करें।",
+    "lyrics_rate_limited-n": "आपको रेट सीमित किया जा रहा है।\nकृपया अधिक बोल का अनुरोध करने से पहले %1 सेकंड प्रतीक्षा करें।",
+    "lyrics_not_found_confirm_open_search": "इस गीत के लिए बोल पृष्ठ नहीं मिला।\nक्या आप इसे मैन्युअल रूप से खोजने के लिए genius.com को खोलना चाहते हैं?",
+    "lyrics_not_found_click_open_search": "बोल URL नहीं मिला - मैन्युअल बोल खोजने के लिए क्लिक करें",
+
+    "hotkey_input_click_to_change": "बदलने के लिए क्लिक करें",
+    "hotkey_input_click_to_change_tooltip": "बदलने के लिए क्लिक करें, फिर दबाएं",
+    "hotkey_input_click_to_cancel_tooltip": "बदलने के लिए क्लिक करें, फिर रिकवर करें",
+    "hotkey_key_ctrl": "Ctrl",
+    "hotkey_key_shift": "Shift",
+    "hotkey_key_mac_option": "Option",
+    "hotkey_key_alt": "Alt",
+
+    "welcome_menu_title": "%1 में आपका स्वागत है!",
+    "config_menu": "कॉन्फ़िगरेशन मेनू",
+    "open_config_menu_tooltip": "कॉन्फ़िगरेशन मेनू खोलने के लिए क्लिक करें",
+    "open_changelog": "चेंजलॉग खोलें",
+    "open_changelog_tooltip": "चेंजलॉग खोलने के लिए क्लिक करें",
+    "feature_help_button_tooltip": "इस सुविधा के बारे में अधिक जानकारी प्राप्त करने के लिए क्लिक करें",
+    "welcome_text_line_1": "स्थापित करने के लिए धन्यवाद!",
+    "welcome_text_line_2": "मैं आशा करता हूं कि आप %1 का उपयोग करने में इतना मज़ा लेंगे जितना मैंने इसे बनाने में लिया है 😃",
+    "welcome_text_line_3": "यदि आप %1 पसंद करते हैं, तो कृपया %2GreasyFork%3 या %4OpenUserJS%5 पर एक रेटिंग दें",
+    "welcome_text_line_4": "मेरा काम दान पर निर्भर करता है, इसलिए कृपया %1दान करने का विचार करें ❤️%2",
+    "welcome_text_line_5": "कोई बग मिला या कोई सुविधा सुझाना चाहते हैं? कृपया %1GitHub%2 पर एक समस्या खोलें",
+    "list_button_placement_queue_only": "केवल कतार में",
+    "list_button_placement_everywhere": "हर गीत सूची में",
+    "remember_song_time_sites_all": "दोनों साइटें",
+    "remember_song_time_sites_yt": "केवल YouTube",
+    "remember_song_time_sites_ytm": "केवल YouTube Music",
+    "new_version_available": "एक नया संस्करण %1 उपलब्ध है!\nवर्तमान में स्थापित: %2 - नया संस्करण: %3\n(आप इस सूचना को कॉन्फ़िग मेनू में अक्षम कर सकते हैं)\n\nक्या आप %4 को खोलकर इसे मैन्युअल रूप से स्थापित करना चाहते हैं?",
+
+    "feature_category_layout": "लेआउट",
+    "feature_category_songLists": "गीत सूचियाँ",
+    "feature_category_behavior": "व्यवहार",
+    "feature_category_input": "इनपुट",
+    "feature_category_lyrics": "बोल",
+    "feature_category_general": "सामान्य",
+
+    "feature_desc_removeUpgradeTab": "अपग्रेड/प्रीमियम टैब हटाएँ",
+    "feature_desc_volumeSliderLabel": "वॉल्यूम स्लाइडर के पास एक प्रतिशत लेबल जोड़ें",
+    "feature_desc_volumeSliderSize": "वॉल्यूम स्लाइडर की चौड़ाई पिक्सेल में",
+    "feature_desc_volumeSliderStep": "वॉल्यूम स्लाइडर संवेदनशीलता (वॉल्यूम कितने प्रतिशत कम किया जा सकता है)",
+    "feature_desc_volumeSliderScrollStep": "वॉल्यूम स्लाइडर स्क्रॉल स्टेप",
+    "feature_helptext_volumeSliderScrollStep": "जब आप माउस व्हील के साथ वॉल्यूम स्लाइडर को स्क्रॉल करते हैं, तो वॉल्यूम कितने प्रतिशत बदला जाना चाहिए।\nयह वॉल्यूम स्लाइडर संवेदनशीलता का एक गुणक होना चाहिए, अन्यथा जब आप स्क्रॉल करते समय वॉल्यूम में छोटे अनियमित छलांग होगी।",
+    "feature_desc_watermarkEnabled": "एक वॉटरमार्क दिखाएं जो इस कॉन्फ़िग मेनू को खोलता है",
+    "feature_helptext_watermarkEnabled": "यदि यह अक्षम है, तो आप फिर से कॉन्फ़िग मेनू खोल सकते हैं जब आप अपनी प्रोफ़ाइल चित्र पर क्लिक करते हैं जो कि आपके ब्राउज़र के दाएं कोने में होता है।\nहालांकि इसे ईस्टर एग को ढूंढना मुश्किल हो जाएगा ;)",
+    "feature_desc_removeShareTrackingParam": "शेयर पॉपअप में लिंक से ट्रैकिंग पैरामीटर \"&si\" हटाएं",
+    "feature_helptext_removeShareTrackingParam": "विशेष रूप से यूट्यूब शेयर पॉपअप में जो लिंक दिया जाता है, उसमें एक ट्रैकिंग पैरामीटर जोड़ा जाता है। यह न केवल लिंक को लंबा बनाता है, बल्कि यूट्यूब को आपके बारे में और उन लोगों के बारे में अधिक जानकारी देता है जिन्हें आप लिंक भेजते हैं।",
+    "feature_desc_numKeysSkipToTime": "एक नंबर कुंजी (0-9) दबाकर वीडियो में एक विशिष्ट समय पर छोड़ने को सक्षम करें",
+    "feature_desc_fixSpacing": "लेआउट में स्पेसिंग समस्याओं को ठीक करें",
+    "feature_helptext_fixSpacing": "यहां विभिन्न स्थान हैं जहां तत्वों के बीच स्पेसिंग असंगत है। यह सुविधा उन समस्याओं को ठीक करती है।",
+
+    "feature_desc_lyricsQueueButton": "कतार में प्रत्येक गीत में एक बटन जो त्वरित रूप से इसके बोल खोलता है",
+    "feature_desc_deleteFromQueueButton": "कतार में प्रत्येक गीत में एक बटन जो इसे त्वरित रूप से हटा देता है",
+    "feature_desc_listButtonsPlacement": "कतार बटन कहाँ दिखाएं?",
+    "feature_helptext_listButtonsPlacement": "साइट पर विभिन्न गीत सूचियाँ हैं जैसे एल्बम पेज, प्लेलिस्ट और वर्तमान में चल रहे कतार। इस विकल्प के साथ आप चुन सकते हैं कि कतार बटन कहाँ दिखाएं।",
+    "feature_desc_scrollToActiveSongBtn": "कतार में एक बटन जो वर्तमान में चल रहे गीत पर स्क्रॉल करता है",
+
+    "feature_desc_disableBeforeUnloadPopup": "एक गीत चल रहे होने पर साइट छोड़ने का प्रयास करने पर आने वाली पुष्टि पॉपअप को रोकें",
+    "feature_helptext_disableBeforeUnloadPopup": "जब आप वेबसाइट छोड़ने की कोशिश करते हैं जब आप एक गीत को थोड़े समय के लिए सुन रहे होते हैं, तो एक पॉपअप आता है जो आपसे पुष्टि करता है कि क्या आप वाकई साइट छोड़ना चाहते हैं। यह कुछ इस प्रकार का हो सकता है \"आपके पास असहेज डेटा है\" या \"यह साइट आपसे पूछ रही है कि क्या आप इसे बंद करना चाहते हैं\"।\nयह सुविधा इस पॉपअप को पूरी तरह से अक्षम करती है।",
+    "feature_desc_closeToastsTimeout": "कितने सेकंड के बाद स्थायी सूचनाओं को बंद करें - केवल उन्हें मैन्युअल रूप से बंद करने के लिए 0 (डिफ़ॉल्ट व्यवहार)",
+    "feature_helptext_closeToastsTimeout": "बहुत सारी स्थायी सूचनाएँ जो नीचे बाएं कोने में दिखाई देती हैं, वे 3 सेकंड के बाद स्वचालित रूप से बंद हो जाएंगी उनमें से कुछ ऐसी होती हैं जैसे गाना पसंद करने पर।\nयह सुविधा आपको उन स्थायी सूचनाओं को बंद करने के लिए समय निर्धारित करने की अनुमति देती है।\nअन्य प्रकार की स्थायी सूचनाएँ अस्पष्ट रहेंगी।\nइसे 0 के लिए सेट करें ताकि स्थायी सूचनाएँ बंद न हों।",
+    "feature_desc_rememberSongTime": "टैब को फिर से लोड करने या बहाल करने पर अंतिम गीत का समय याद रखें",
+    "feature_helptext_rememberSongTime-1": "कभी-कभी पृष्ठ को फिर से लोड करने या उसे अनजाने में बंद करने के बाद, आप चाहते हैं कि आप वही समय पर सुनना जारी रखें। यह सुविधा आपको इसे करने की अनुमति देती है।\nगीत का समय याद करने और बहाल करने के लिए, आपको इसे %1 सेकंड तक चलाना होगा, फिर इसका समय याद किया जाएगा और थोड़ी देर के लिए बहाल किया जा सकता है।",
+    "feature_helptext_rememberSongTime-n": "कभी-कभी पृष्ठ को फिर से लोड करने या उसे अनजाने में बंद करने के बाद, आप चाहते हैं कि आप वही समय पर सुनना जारी रखें। यह सुविधा आपको इसे करने की अनुमति देती है।\nगीत का समय याद करने और बहाल करने के लिए, आपको इसे %1 सेकंड तक चलाना होगा, फिर इसका समय याद किया जाएगा और थोड़ी देर के लिए बहाल किया जा सकता है।",
+    "feature_desc_rememberSongTimeSites": "गीत का समय किन साइटों पर याद रखें और बहाल करें?",
+
+    "feature_desc_arrowKeySupport": "वर्तमान में चल रहे गीत के मीडिया नियंत्रणों में एक बटन जो एरो कुंजियों का समर्थन करता है",
+    "feature_helptext_arrowKeySupport": "सामान्य रूप से आप केवल बाएं और दाएं तीर कुंजियों का उपयोग करके एक निश्चित 10 सेकंड के अंतराल में छोड़ सकते हैं। इस सुविधा की मदद से आप तीर कुंजियों का उपयोग कर सकते हैं।\nछोड़ने के लिए सेकंडों की मात्रा बदलने के लिए, नीचे दिए गए विकल्प का उपयोग करें।",
+    "feature_desc_arrowKeySkipBy": "एरो कुंजियों का उपयोग करते समय कितने सेकंड छोड़ें",
+    "feature_desc_switchBetweenSites": "वीडियो / गीत पर YT और YTM साइटों के बीच स्विच करने के लिए एक हॉटकी जोड़ें",
+    "feature_helptext_switchBetweenSites": "इस हॉटकी को दबाने से आप वर्तमान में चल रहे वीडियो / गीत के बीच स्विच कर सकते हैं जब आप YT या YTM पर होते हैं।",
+    "feature_desc_switchSitesHotkey": "साइटों को स्विच करने के लिए कौन सी हॉटकी दबानी चाहिए?",
+    "feature_desc_anchorImprovements": "एक और बेहतर लिंक जो चीजों को एक नए टैब में खोलने के लिए आसान बनाता है",
+    "feature_helptext_anchorImprovements": "पृष्ठ पर कुछ तत्व केवल बाएं माउस बटन के साथ क्लिक करके ही खोले जा सकते हैं, जिसका मतलब है कि आप उन्हें मध्य बटन क्लिक करके नए टैब में नहीं खोल सकते या तो तीन बटन क्लिक करके या तो शिफ्ट + दाएं क्लिक के माध्यम से संदर्भ मेनू के माध्यम से। यह सुविधा उनमें से बहुत से को लिंक जोड़ती है या मौजूदे को बड़ा करती है ताकि क्लिक करना आसान हो।",
+
+    "feature_desc_geniusLyrics": "वर्तमान में चल रहे गीत के मीडिया नियंत्रणों में एक बटन जो genius.com पर इसके बोल खोलता है",
+
+    "feature_desc_locale": "भाषा",
+    "feature_desc_versionCheck": "अपडेट की जांच करें",
+    "feature_helptext_versionCheck": "यह सुविधा हर 24 घंटे में अपडेट की जांच करती है, आपको अगर एक नया संस्करण उपलब्ध है तो सूचित करती है और आपको स्क्रिप्ट को मैन्युअल रूप से अपडेट करने की अनुमति देती है।\nयदि आपके यूज़रस्क्रिप्ट प्रबंधक एक्सटेंशन स्क्रिप्ट को स्वचालित रूप से अपडेट करता है, तो आप इस सुविधा को अक्षम कर सकते हैं।",
+    "feature_desc_logLevel": "कंसोल पर कितनी जानकारी लॉग इन करनी है",
+    "feature_helptext_logLevel": "इसे बदलने की वास्तव में केवल डिबगिंग के उद्देश्य से आवश्यक है क्योंकि किसी समस्या का सामना करने के परिणामस्वरूप।\nयदि आपके पास एक है, तो आप यहां लॉग स्तर बढ़ा सकते हैं, अपने ब्राउज़र के जावास्क्रिप्ट कंसोल (सामान्यतः Ctrl + Shift + K के साथ) खोल सकते हैं और उस लॉग की स्क्रीनशॉट एक गिटहब समस्या में अटैच कर सकते हैं।"
+  }
+}

+ 141 - 0
assets/translations/ja_JA.json

@@ -0,0 +1,141 @@
+{
+  "translations": {
+    "config_menu_option": "%1 構成",
+    "config_menu_title": "%1 - 構成",
+    "changelog_menu_title": "%1 - 更新履歴",
+    "export_menu_title": "%1 - 構成をエクスポート",
+    "import_menu_title": "%1 - 構成をインポート",
+    "open_menu_tooltip": "%1 の構成メニューを開く",
+    "close_menu_tooltip": "メニューを閉じる",
+    "reload_hint": "変更を適用するにはページを再読み込みする必要があります。",
+    "reload_now": "今すぐ再読み込み",
+    "reload_tooltip": "ページを再読み込みする",
+    "version_tooltip": "バージョン %1 (ビルド %2) - クリックして更新履歴を開く",
+    "export": "エクスポート",
+    "export_hint": "次のテキストをコピーして構成をエクスポートします。",
+    "export_tooltip": "現在の構成をエクスポートする",
+    "import": "インポート",
+    "import_hint": "インポートしたい構成を以下のフィールドに貼り付け、インポートボタンをクリックしてください。",
+    "import_tooltip": "以前にエクスポートした構成をインポートする",
+    "start_import_tooltip": "上記に貼り付けた構成をインポートするにはクリックしてください",
+    "import_error_invalid": "インポートされたデータが無効です",
+    "import_error_no_format_version": "インポートされたデータにフォーマットバージョンが含まれていません",
+    "import_error_no_data": "インポートされたオブジェクトにデータが含まれていません",
+    "import_error_wrong_format_version": "インポートされたデータはサポートされていないフォーマットバージョンです(%1 以下が必要ですが、%2 が指定されています)",
+    "import_success_confirm_reload": "構成をインポートしました。\n変更を適用するには今すぐページを再読み込みしますか?",
+    "reset_tooltip": "すべての設定をデフォルト値にリセットする",
+    "reset_confirm": "すべての設定をデフォルト値にリセットしてもよろしいですか?\nページは自動的に再読み込みされます。",
+    "copy_to_clipboard": "クリップボードにコピー",
+    "copy_config_tooltip": "構成をクリップボードにコピーする",
+    "copied_notice": "コピーしました!",
+    "open_github": "GitHub で %1 を開く",
+    "open_discord": "Discord サーバーに参加する",
+    "open_greasyfork": "GreasyFork で %1 を開く",
+    "open_openuserjs": "OpenUserJS で %1 を開く",
+    "lang_changed_prompt_reload": "言語が変更されました。\n変更を適用するには今すぐページを再読み込みしますか?",
+
+    "reset": "リセット",
+    "close": "閉じる",
+    "log_level_debug": "デバッグ (最大)",
+    "log_level_info": "情報 (重要なもののみ)",
+    "toggled_on": "オン",
+    "toggled_off": "オフ",
+    "remove_from_queue": "この曲をキューから削除",
+    "delete_from_list": "この曲をリストから削除",
+    "couldnt_remove_from_queue": "この曲をキューから削除できませんでした",
+    "couldnt_delete_from_list": "この曲をリストから削除できませんでした",
+    "scroll_to_playing": "現在再生中の曲までスクロール",
+    "scroll_to_bottom": "クリックして一番下までスクロール",
+    "volume_tooltip": "音量: %1% (感度: %2%)",
+    "middle_click_open_tab": "中クリックで新しいタブで開く",
+    "boost_gain_enable_tooltip": "音量を %1% にブーストする",
+    "boost_gain_disable_tooltip": "音量ブーストを無効にする",
+
+    "open_current_lyrics": "現在の曲の歌詞を新しいタブで開く",
+    "open_lyrics": "この曲の歌詞を新しいタブで開く",
+    "lyrics_loading": "歌詞 URL を読み込んでいます...",
+    "lyrics_rate_limited-1": "レート制限されています。\nもう少し待ってから歌詞をリクエストしてください。",
+    "lyrics_rate_limited-n": "レート制限されています。\nもう %1 秒待ってから歌詞をリクエストしてください。",
+    "lyrics_not_found_confirm_open_search": "この曲の歌詞ページが見つかりませんでした。\ngenius.com を開いて手動で検索しますか?",
+    "lyrics_not_found_click_open_search": "歌詞 URL が見つかりませんでした - 手動で歌詞検索を開くにはクリックしてください",
+
+    "hotkey_input_click_to_change": "クリックして変更",
+    "hotkey_input_click_to_change_tooltip": "クリックしてホットキーを変更する",
+    "hotkey_input_click_to_cancel_tooltip": "クリックしてキャンセル",
+    "hotkey_key_ctrl": "Ctrl",
+    "hotkey_key_shift": "Shift",
+    "hotkey_key_mac_option": "Option",
+    "hotkey_key_alt": "Alt",
+
+    "welcome_menu_title": "%1 へようこそ!",
+    "config_menu": "構成メニュー",
+    "open_config_menu_tooltip": "クリックして構成メニューを開く",
+    "open_changelog": "更新履歴",
+    "open_changelog_tooltip": "クリックして更新履歴を開く",
+    "feature_help_button_tooltip": "クリックしてこの機能についての詳細情報を取得する",
+    "welcome_text_line_1": "ようこそ!",
+    "welcome_text_line_2": "%1 を使っていただきありがとうございます 😃",
+    "welcome_text_line_3": "もし %1 を気に入っていただけたら、%2GreasyFork%3 か %4OpenUserJS%5 で評価をお願いします",
+    "welcome_text_line_4": "私の作業は寄付に依存しているので、%1寄付%2 を検討してください ❤️",
+    "welcome_text_line_5": "バグを見つけた、または機能を提案したいですか?%1GitHubで課題を開いてください。%2",
+    "list_button_placement_queue_only": "キュー内のみ",
+    "list_button_placement_everywhere": "すべての曲リスト",
+    "remember_song_time_sites_all": "すべてのサイト",
+    "remember_song_time_sites_yt": "YouTube のみ",
+    "remember_song_time_sites_ytm": "YouTube Music のみ",
+    "new_version_available": "新しいバージョンの %1 が利用可能です!\n現在のバージョン: %2 - 新しいバージョン: %3\n(この通知を構成メニューで無効にすることができます)\n\n%4 を開いて手動でインストールしますか?",
+
+    "feature_category_layout": "レイアウト",
+    "feature_category_songLists": "曲リスト",
+    "feature_category_behavior": "動作",
+    "feature_category_input": "入力",
+    "feature_category_lyrics": "歌詞",
+    "feature_category_general": "一般的な",
+
+    "feature_desc_removeUpgradeTab": "アップグレード / プレミアムタブを削除する",
+    "feature_desc_volumeSliderLabel": "音量スライダーの横にパーセンテージラベルを追加する",
+    "feature_desc_volumeSliderSize": "音量スライダーの幅(ピクセル単位)",
+    "feature_desc_volumeSliderStep": "音量スライダーの感度(音量を一度にどれだけのパーセントで変更できるか)",
+    "feature_desc_volumeSliderScrollStep": "音量スライダーをスクロールする感度(パーセント単位) - 上記の感度値から最も近い感度値にスナップします",
+    "feature_helptext_volumeSliderScrollStep": "マウスホイールで音量スライダーをスクロールするときに音量を変更するパーセント",
+    "feature_desc_watermarkEnabled": "この構成メニューを開くサイトロゴの下に透かしを表示する",
+    "feature_helptext_watermarkEnabled": "これが無効になっている場合、プロフィール画像をクリックして右上隅にあるメニューを開くと、構成メニューを開くことができます。\nただし、イースターエッグを見つけるのは難しくなります ;)",
+    "feature_desc_removeShareTrackingParam": "共有ポップアップ内のリンクから追跡パラメータ \"&si\" を削除する",
+    "feature_helptext_removeShareTrackingParam": "アナリティクスの目的で、YouTube は共有メニューでコピーできる URL の末尾に追跡パラメータを追加します。直接的な害はありませんが、URL を長くし、YouTube にリンクを送信する人々についての情報をより多く提供します。",
+    "feature_desc_numKeysSkipToTime": "数字キー (0-9) を押して特定の時間にスキップする",
+    "feature_desc_fixSpacing": "レイアウトのスペーシング問題を修正する",
+    "feature_helptext_fixSpacing": "ユーザーインターフェイスには、要素間のスペーシングが一貫していない場所がいくつかあります。この機能はそれらの問題を修正します。",
+
+    "feature_desc_lyricsQueueButton": "キュー内の各曲にボタンを追加して、すばやく歌詞ページを開く",
+    "feature_desc_deleteFromQueueButton": "キュー内の各曲にボタンを追加して、すばやく削除できるようにする",
+    "feature_desc_listButtonsPlacement": "キューボタンの表示場所を選択する",
+    "feature_helptext_listButtonsPlacement": "アルバムページ、プレイリスト、現在再生中のキューなど、サイトにはさまざまな曲リストがあります。このオプションを使用して、キューボタンを表示する場所を選択できます。",
+    "feature_desc_scrollToActiveSongBtn": "キューに現在再生中の曲までスクロールするボタンを追加する",
+
+    "feature_desc_disableBeforeUnloadPopup": "曲が再生されている間にサイトを離れようとすると表示される確認ポップアップを防止する",
+    "feature_helptext_disableBeforeUnloadPopup": "曲が再生されている間にサイトを離れようとすると、数秒後にポップアップが表示され、サイトを離れるかどうかを確認するように求められます。それは「保存されていないデータがあります」とか「このサイトを閉じるかどうかを尋ねています」とかのようなことが書かれているかもしれません。\nこの機能はそのポップアップを完全に無効にします。",
+    "feature_desc_closeToastsTimeout": "永続的な通知を閉じるまでの秒数 - 手動で閉じるには 0 (デフォルト動作)",
+    "feature_helptext_closeToastsTimeout": "左下隅に表示されるほとんどのポップアップは、曲を好きにするといった特定のものを除いて、3 秒後に自動的に閉じます。\nこの機能を使用すると、永続的なポップアップを閉じる時間を設定できます。\n他の種類のポップアップは影響を受けません。\n永続的な通知を閉じないデフォルト動作にするには 0 を設定してください。",
+    "feature_desc_rememberSongTime": "リロードまたはタブの復元時に最後の曲の時間を記憶する",
+    "feature_helptext_rememberSongTime-1": "ページを再読み込みしたり、誤って閉じたりして復元したときに、同じ場所で聞き直したいことがあります。この機能を使用すると、それが可能になります。\n曲の時間を記録するには、%1 秒再生する必要があります。その後、その時間が記憶され、しばらくの間復元可能になります。",
+    "feature_helptext_rememberSongTime-n": "ページを再読み込みしたり、誤って閉じたりして復元したときに、同じ場所で聞き直したいことがあります。この機能を使用すると、それが可能になります。\n曲の時間を記録するには、%1 秒再生する必要があります。その後、その時間が記憶され、しばらくの間復元可能になります。",
+    "feature_desc_rememberSongTimeSites": "曲の時間を記憶して復元するサイトはどこですか?",
+
+    "feature_desc_arrowKeySupport": "現在再生中の曲で前後にスキップするには矢印キーを使用する",
+    "feature_helptext_arrowKeySupport": "通常、キー \"H\" と \"L\" を使用して 10 秒間隔で前後にスキップすることができます。この機能を使用すると、矢印キーも使用できます。\nスキップする秒数を変更するには、以下のオプションを使用してください。",
+    "feature_desc_arrowKeySkipBy": "矢印キーを使用してスキップする秒数",
+    "feature_desc_switchBetweenSites": "ビデオ / 曲の YT と YTM サイトを切り替えるホットキーを追加する",
+    "feature_helptext_switchBetweenSites": "このホットキーを押すと、同じビデオ / 曲のままで YouTube または YouTube Music のどちらかに切り替わります。",
+    "feature_desc_switchSitesHotkey": "サイトを切り替えるために押す必要があるホットキーはどれですか?",
+    "feature_desc_anchorImprovements": "リンクを追加してページ全体でリンクを開くことができるようにする",
+    "feature_helptext_anchorImprovements": "ページ上のいくつかの要素は左クリックのみでクリックできるため、中クリックやシフト + 右クリックを使用して新しいタブで開くことができません。この機能はそれらの要素にリンクを追加するか、既存のリンクを拡大してクリックしやすくします。",
+
+    "feature_desc_geniusLyrics": "現在再生中の曲のメディアコントロールにボタンを追加して、genius.com で歌詞を開く",
+
+    "feature_desc_locale": "言語",
+    "feature_desc_versionCheck": "バージョンチェック",
+    "feature_helptext_versionCheck": "この機能は 24 時間ごとに更新をチェックし、新しいバージョンが利用可能な場合に通知し、スクリプトを手動で更新することができます。\nユーザースクリプトマネージャー拡張機能がスクリプトを自動的に更新する場合、この機能を無効にすることができます。",
+    "feature_desc_logLevel": "ログレベル",
+    "feature_helptext_logLevel": "これを変更するのは、問題が発生した結果としてデバッグ目的でのみ必要です。\n問題が発生した場合にのみ、ここでログレベルを増やし、ブラウザの JavaScript コンソールを開いて(通常は Ctrl + Shift + K)そのログのスクリーンショットを GitHub の課題に添付してください。"
+  }
+}

+ 141 - 0
assets/translations/pt_BR.json

@@ -0,0 +1,141 @@
+{
+  "translations": {
+    "config_menu_option": "Configuração do %1",
+    "config_menu_title": "%1 - Configuração",
+    "changelog_menu_title": "%1 - Registro de alterações",
+    "export_menu_title": "%1 - Exportar configuração",
+    "import_menu_title": "%1 - Importar configuração",
+    "open_menu_tooltip": "Abra o menu de configuração do %1",
+    "close_menu_tooltip": "Feche o menu",
+    "reload_hint": "Você precisa recarregar a página para aplicar quaisquer alterações.",
+    "reload_now": "Recarregar agora",
+    "reload_tooltip": "Recarregue a página",
+    "version_tooltip": "Versão %1 (compilação %2) - clique para abrir o registro de alterações",
+    "export": "Exportar",
+    "export_hint": "Copie o texto a seguir para exportar sua configuração:",
+    "export_tooltip": "Exporte sua configuração atual",
+    "import": "Importar",
+    "import_hint": "Cole a configuração que você deseja importar no campo abaixo e clique no botão de importação:",
+    "import_tooltip": "Importe uma configuração que você exportou anteriormente",
+    "start_import_tooltip": "Clique para importar a configuração que você colou acima",
+    "import_error_invalid": "Os dados importados são inválidos",
+    "import_error_no_format_version": "Os dados importados não contêm uma versão de formato",
+    "import_error_no_data": "O objeto importado não contém nenhum dado",
+    "import_error_wrong_format_version": "Os dados importados estão em uma versão de formato não suportada (esperada %1 ou inferior, mas %2)",
+    "import_success_confirm_reload": "Configuração importada com sucesso.\nVocê deseja recarregar a página agora para aplicar as alterações?",
+    "reset_tooltip": "Redefina todas as configurações para seus valores padrão",
+    "reset_confirm": "Você realmente deseja redefinir todas as configurações para seus valores padrão?\nA página será recarregada automaticamente.",
+    "copy_to_clipboard": "Copiar para a área de transferência",
+    "copy_config_tooltip": "Copie a configuração para a área de transferência",
+    "copied_notice": "Copiado!",
+    "open_github": "Abrir %1 no GitHub",
+    "open_discord": "Junte-se ao meu servidor Discord",
+    "open_greasyfork": "Abrir %1 no GreasyFork",
+    "open_openuserjs": "Abrir %1 no OpenUserJS",
+    "lang_changed_prompt_reload": "O idioma foi alterado.\nVocê deseja recarregar a página agora para aplicar as alterações?",
+
+    "reset": "Redefinir",
+    "close": "Fechar",
+    "log_level_debug": "Depurar (mais)",
+    "log_level_info": "Informações (apenas importantes)",
+    "toggled_on": "Habilitado",
+    "toggled_off": "Desabilitado",
+    "remove_from_queue": "Remover esta música da fila",
+    "delete_from_list": "Excluir esta música da lista",
+    "couldnt_remove_from_queue": "Não foi possível remover esta música da fila",
+    "couldnt_delete_from_list": "Não foi possível excluir esta música da lista",
+    "scroll_to_playing": "Rolar para a música que está tocando atualmente",
+    "scroll_to_bottom": "Clique para rolar para o final",
+    "volume_tooltip": "Volume: %1% (Sensibilidade: %2%)",
+    "middle_click_open_tab": "Clique com o botão do meio para abrir em uma nova guia",
+    "boost_gain_enable_tooltip": "Aumente o volume para %1%",
+    "boost_gain_disable_tooltip": "Desativar o aumento de volume",
+
+    "open_current_lyrics": "Abrir as letras da música atual em uma nova guia",
+    "open_lyrics": "Abrir as letras desta música em uma nova guia",
+    "lyrics_loading": "Carregando URL das letras...",
+    "lyrics_rate_limited-1": "Você está sendo limitado.\nAguarde alguns segundos antes de solicitar mais letras.",
+    "lyrics_rate_limited-n": "Você está sendo limitado.\nAguarde %1 segundos antes de solicitar mais letras.",
+    "lyrics_not_found_confirm_open_search": "Não foi possível encontrar uma página de letras para esta música.\nVocê deseja abrir genius.com para pesquisar manualmente?",
+    "lyrics_not_found_click_open_search": "Não foi possível encontrar a URL das letras - clique para abrir a pesquisa manual de letras",
+
+    "hotkey_input_click_to_change": "Clique para alterar",
+    "hotkey_input_click_to_change_tooltip": "Clique, depois pressione a tecla desejada",
+    "hotkey_input_click_to_cancel_tooltip": "Clique para cancelar",
+    "hotkey_key_ctrl": "Ctrl",
+    "hotkey_key_shift": "Shift",
+    "hotkey_key_mac_option": "Option",
+    "hotkey_key_alt": "Alt",
+
+    "welcome_menu_title": "Bem-vindo ao %1!",
+    "config_menu": "Configuração",
+    "open_config_menu_tooltip": "Clique para abrir o menu de configuração",
+    "open_changelog": "Registro de alterações",
+    "open_changelog_tooltip": "Clique para abrir o registro de alterações",
+    "feature_help_button_tooltip": "Clique para obter mais informações sobre este recurso",
+    "welcome_text_line_1": "Obrigado por instalar!",
+    "welcome_text_line_2": "Espero que você goste de usar o %1 tanto quanto eu gostei de fazê-lo 😃",
+    "welcome_text_line_3": "Se você gosta do %1, por favor, deixe uma avaliação no %2GreasyFork%3 ou %4OpenUserJS%5",
+    "welcome_text_line_4": "Meu trabalho depende de doações, então considere %1doar ❤️%2",
+    "welcome_text_line_5": "Encontrou um bug ou quer sugerir um recurso? Por favor, %1abra um problema no GitHub%2",
+    "list_button_placement_queue_only": "Apenas na fila de reprodução",
+    "list_button_placement_everywhere": "Em toda lista de músicas",
+    "remember_song_time_sites_all": "Ambos os sites",
+    "remember_song_time_sites_yt": "Apenas YouTube",
+    "remember_song_time_sites_ytm": "Apenas YouTube Music",
+    "new_version_available": "Uma nova versão do %1 está disponível!\nAtualmente instalado: %2 - nova versão: %3\n(Você pode desativar esta notificação no menu de configuração)\n\nVocê deseja abrir %4 para instalá-lo manualmente?",
+
+    "feature_category_layout": "Layout",
+    "feature_category_songLists": "Listas de músicas",
+    "feature_category_behavior": "Comportamento",
+    "feature_category_input": "Entrada",
+    "feature_category_lyrics": "Letras",
+    "feature_category_general": "Geral",
+
+    "feature_desc_removeUpgradeTab": "Remover a guia Upgrade / Premium",
+    "feature_desc_volumeSliderLabel": "Adicionar um rótulo de porcentagem ao lado do controle de volume",
+    "feature_desc_volumeSliderSize": "A largura do controle deslizante de volume em pixels",
+    "feature_desc_volumeSliderStep": "Sensibilidade do controle deslizante de volume (por quantos porcento o volume pode ser alterado de cada vez)",
+    "feature_desc_volumeSliderScrollStep": "Sensibilidade da roda do mouse para o controle deslizante de volume em porcentagem - ajusta para o valor de sensibilidade mais próximo de cima",
+    "feature_helptext_volumeSliderScrollStep": "Por quantos porcento o volume deve ser alterado ao rolar o controle deslizante de volume com a roda do mouse.\nIsso deve ser um múltiplo da sensibilidade do controle deslizante de volume, caso contrário, haverá pequenos saltos irregulares no volume ao rolar.",
+    "feature_desc_watermarkEnabled": "Mostrar uma marca d'água sob o logotipo do site que abre este menu de configuração",
+    "feature_helptext_watermarkEnabled": "Se isso estiver desativado, você ainda pode abrir o menu de configuração clicando na opção no menu que abre quando você clica em sua foto de perfil no canto superior direito.\nNo entanto, será mais difícil encontrar o easter egg ;)",
+    "feature_desc_removeShareTrackingParam": "Remova o parâmetro de rastreamento \"&si\" dos links na janela de compartilhamento",
+    "feature_helptext_removeShareTrackingParam": "Para fins de análise, o YouTube adiciona um parâmetro de rastreamento ao final do URL que você pode copiar no menu de compartilhamento. Embora não seja diretamente prejudicial, ele torna o URL mais longo e dá ao YouTube mais informações sobre você e as pessoas para quem você envia o link.",
+    "feature_desc_numKeysSkipToTime": "Ative a capacidade de pular para um horário específico no vídeo pressionando uma tecla numérica (0-9)",
+    "feature_desc_fixSpacing": "Corrigir problemas de espaçamento no layout",
+    "feature_helptext_fixSpacing": "Há vários locais na interface do usuário onde o espaçamento entre os elementos é inconsistente. Este recurso corrige esses problemas.",
+
+    "feature_desc_lyricsQueueButton": "Adicione um botão a cada música na fila para abrir rapidamente sua página de letras",
+    "feature_desc_deleteFromQueueButton": "Adicionar um botão a cada música na fila para removê-la rapidamente",
+    "feature_desc_listButtonsPlacement": "Onde os botões da fila devem aparecer?",
+    "feature_helptext_listButtonsPlacement": "Existem várias listas de músicas no site, como páginas de álbuns, listas de reprodução e a fila de reprodução atual. Com esta opção, você pode escolher onde os botões da fila devem aparecer.",
+    "feature_desc_scrollToActiveSongBtn": "Adicionar um botão à fila para rolar até a música que está tocando atualmente",
+
+    "feature_desc_disableBeforeUnloadPopup": "Evite a janela de confirmação que aparece ao tentar sair do site enquanto uma música está tocando",
+    "feature_helptext_disableBeforeUnloadPopup": "Ao tentar sair do site enquanto uma música está tocando, uma janela de confirmação pode aparecer, pedindo que você confirme que deseja sair do site. Pode dizer algo como \"você tem dados não salvos\" ou \"este site está perguntando se você deseja fechá-lo\".\nEste recurso desativa completamente essa janela de confirmação.",
+    "feature_desc_closeToastsTimeout": "Após quantos segundos fechar as notificações permanentes - 0 para fechá-las apenas manualmente (comportamento padrão)",
+    "feature_helptext_closeToastsTimeout": "A maioria das notificações que aparecem no canto inferior esquerdo fechará automaticamente após 3 segundos, com exceção de certas, como ao curtir uma música.\nEste recurso permite definir um tempo para fechar as notificações permanentes.\nO outro tipo de notificações permanecerá inalterado.\nDefina isso como 0 para o comportamento padrão de não fechar as notificações permanentes.",
+    "feature_desc_rememberSongTime": "Lembre-se do tempo da última música ao recarregar ou restaurar a guia",
+    "feature_helptext_rememberSongTime-1": "Às vezes, ao recarregar a página ou restaurá-la após fechá-la acidentalmente, você deseja retomar a audição no mesmo ponto. Este recurso permite que você faça isso.\nPara registrar o tempo da música, você precisa reproduzi-la por %1 segundo, então seu tempo será lembrado e restaurável por um curto período.",
+    "feature_helptext_rememberSongTime-n": "Às vezes, ao recarregar a página ou restaurá-la após fechá-la acidentalmente, você deseja retomar a audição no mesmo ponto. Este recurso permite que você faça isso.\nPara registrar o tempo da música, você precisa reproduzi-la por %1 segundos, então seu tempo será lembrado e restaurável por um curto período.",
+    "feature_desc_rememberSongTimeSites": "Em quais sites o tempo da música deve ser lembrado e restaurado?",
+
+    "feature_desc_arrowKeySupport": "Use as teclas de seta para pular para a próxima ou anterior música na fila",
+    "feature_helptext_arrowKeySupport": "Normalmente, você só pode pular para frente e para trás por um intervalo fixo de 10 segundos com as teclas \"H\" e \"L\". Este recurso permite que você use as teclas de seta também.\nPara alterar a quantidade de segundos a pular, use a opção abaixo.",
+    "feature_desc_arrowKeySkipBy": "Por quantos segundos pular ao usar as teclas de seta",
+    "feature_desc_switchBetweenSites": "Adicione um atalho para alternar entre os sites YT e YTM em um vídeo / música",
+    "feature_helptext_switchBetweenSites": "Pressionar este atalho alternará para o outro site se você estiver no YouTube ou YouTube Music, mantendo-se na mesma música / vídeo.",
+    "feature_desc_switchSitesHotkey": "Qual tecla de atalho precisa ser pressionada para alternar entre os sites?",
+    "feature_desc_anchorImprovements": "Adicione e melhore os links em toda a página para que as coisas possam ser abertas em uma nova guia com mais facilidade",
+    "feature_helptext_anchorImprovements": "Alguns elementos na página só podem ser clicados com o botão esquerdo do mouse, o que significa que você não pode abri-los em uma nova guia clicando com o botão do meio ou através do menu de contexto usando shift + clique com o botão direito. Este recurso adiciona links a muitos deles ou os aumenta para facilitar o clique.",
+
+    "feature_desc_geniusLyrics": "Adicione um botão aos controles de mídia da música que está tocando atualmente para abrir suas letras em genius.com",
+
+    "feature_desc_locale": "Idioma",
+    "feature_desc_versionCheck": "Verificar atualizações",
+    "feature_helptext_versionCheck": "Este recurso verifica atualizações a cada 24 horas, notifica você se uma nova versão estiver disponível e permite que você atualize o script manualmente.\nSe o seu gerenciador de scripts de usuário atualiza scripts automaticamente, você pode desativar este recurso.",
+    "feature_desc_logLevel": "Quanta informação registrar no console",
+    "feature_helptext_logLevel": "Alterar isso é realmente necessário apenas para fins de depuração como resultado de experimentar um problema.\nSe você tiver um, você pode aumentar o nível de log aqui, abrir o console JavaScript do seu navegador (geralmente com Ctrl + Shift + K) e anexar capturas de tela desse log em um problema do GitHub."
+  }
+}

+ 141 - 0
assets/translations/zh_CN.json

@@ -0,0 +1,141 @@
+{
+  "translations": {
+    "config_menu_option": "%1 配置",
+    "config_menu_title": "%1 - 配置",
+    "changelog_menu_title": "%1 - 更新日志",
+    "export_menu_title": "%1 - 导出配置",
+    "import_menu_title": "%1 - 导入配置",
+    "open_menu_tooltip": "打开 %1 的配置菜单",
+    "close_menu_tooltip": "关闭菜单",
+    "reload_hint": "您需要重新加载页面以应用任何更改。",
+    "reload_now": "立即重新加载",
+    "reload_tooltip": "重新加载页面",
+    "version_tooltip": "版本 %1 (构建 %2) - 点击打开更新日志",
+    "export": "导出",
+    "export_hint": "复制以下文本以导出您的配置:",
+    "export_tooltip": "导出当前配置",
+    "import": "导入",
+    "import_hint": "将要导入的配置粘贴到下面的字段中,然后点击导入按钮:",
+    "import_tooltip": "导入您先前导出的配置",
+    "start_import_tooltip": "点击导入您刚刚粘贴的配置",
+    "import_error_invalid": "导入的数据无效",
+    "import_error_no_format_version": "导入的数据不包含格式版本",
+    "import_error_no_data": "导入的对象不包含任何数据",
+    "import_error_wrong_format_version": "导入的数据格式版本不受支持(期望 %1 或更低版本,但得到 %2)",
+    "import_success_confirm_reload": "配置导入成功。\n您是否要重新加载页面以应用更改?",
+    "reset_tooltip": "将所有设置重置为默认值",
+    "reset_confirm": "您是否确实要将所有设置重置为默认值?\n页面将自动重新加载。",
+    "copy_to_clipboard": "复制到剪贴板",
+    "copy_config_tooltip": "将配置复制到剪贴板",
+    "copied_notice": "已复制!",
+    "open_github": "在 GitHub 上打开 %1",
+    "open_discord": "加入我的 Discord 服务器",
+    "open_greasyfork": "在 GreasyFork 上打开 %1",
+    "open_openuserjs": "在 OpenUserJS 上打开 %1",
+    "lang_changed_prompt_reload": "语言已更改。\n您是否要重新加载页面以应用更改?",
+
+    "reset": "重置",
+    "close": "关闭",
+    "log_level_debug": "调试(最多)",
+    "log_level_info": "信息(仅重要)",
+    "toggled_on": "开",
+    "toggled_off": "关",
+    "remove_from_queue": "从队列中删除此歌曲",
+    "delete_from_list": "从列表中删除此歌曲",
+    "couldnt_remove_from_queue": "无法从队列中删除此歌曲",
+    "couldnt_delete_from_list": "无法从列表中删除此歌曲",
+    "scroll_to_playing": "滚动到当前播放的歌曲",
+    "scroll_to_bottom": "点击滚动到底部",
+    "volume_tooltip": "音量:%1%(灵敏度:%2%)",
+    "middle_click_open_tab": "中键点击打开新标签页",
+    "boost_gain_enable_tooltip": "将音量提升到 %1%",
+    "boost_gain_disable_tooltip": "禁用音量提升",
+
+    "open_current_lyrics": "在新标签页中打开当前歌曲的歌词",
+    "open_lyrics": "在新标签页中打开这首歌的歌词",
+    "lyrics_loading": "正在加载歌词 URL...",
+    "lyrics_rate_limited-1": "您的请求正在被限制。\n请等待几秒钟再请求更多歌词。",
+    "lyrics_rate_limited-n": "您的请求正在被限制。\n请等待 %1 秒再请求更多歌词。",
+    "lyrics_not_found_confirm_open_search": "找不到这首歌的歌词页面。\n您是否要打开 genius.com 手动搜索?",
+    "lyrics_not_found_click_open_search": "找不到歌词 URL - 点击打开手动歌词搜索",
+
+    "hotkey_input_click_to_change": "点击更改",
+    "hotkey_input_click_to_change_tooltip": "点击,然后按下所需的键组合",
+    "hotkey_input_click_to_cancel_tooltip": "点击取消",
+    "hotkey_key_ctrl": "Ctrl",
+    "hotkey_key_shift": "Shift",
+    "hotkey_key_mac_option": "Option",
+    "hotkey_key_alt": "Alt",
+
+    "welcome_menu_title": "欢迎使用 %1!",
+    "config_menu": "配置菜单",
+    "open_config_menu_tooltip": "点击打开配置菜单",
+    "open_changelog": "更新日志",
+    "open_changelog_tooltip": "点击打开更新日志",
+    "feature_help_button_tooltip": "点击获取有关此功能的更多信息",
+    "welcome_text_line_1": "欢迎使用 %1!",
+    "welcome_text_line_2": "我希望您使用 %1 的过程中能够愉快 😃",
+    "welcome_text_line_3": "如果您喜欢 %1,请在 %2GreasyFork%3 或 %4OpenUserJS%5 上留下评分",
+    "welcome_text_line_4": "我的工作依赖于捐赠,所以请考虑 %1捐赠 ❤️%2",
+    "welcome_text_line_5": "发现了一个错误或想要建议一个功能?请 %1在 GitHub 上打开一个问题%2",
+    "list_button_placement_queue_only": "仅在队列中",
+    "list_button_placement_everywhere": "在每首歌曲列表中",
+    "remember_song_time_sites_all": "所有网站",
+    "remember_song_time_sites_yt": "仅 YouTube",
+    "remember_song_time_sites_ytm": "仅 YouTube Music",
+    "new_version_available": "%1 的新版本可用!\n当前安装: %2 - 新版本: %3\n(您可以在配置菜单中禁用此通知)\n\n是否要打开 %4 来手动安装它?",
+
+    "feature_category_layout": "布局",
+    "feature_category_songLists": "歌曲列表",
+    "feature_category_behavior": "行为",
+    "feature_category_input": "输入",
+    "feature_category_lyrics": "歌词",
+    "feature_category_general": "一般的",
+
+    "feature_desc_removeUpgradeTab": "删除升级 / 高级标签",
+    "feature_desc_volumeSliderLabel": "在音量滑块旁边添加百分比标签",
+    "feature_desc_volumeSliderSize": "音量滑块的宽度(像素)",
+    "feature_desc_volumeSliderStep": "音量滑块灵敏度(音量每次可以改变多少百分比)",
+    "feature_desc_volumeSliderScrollStep": "音量滑块滚动步长",
+    "feature_helptext_volumeSliderScrollStep": "当使用鼠标滚轮滚动音量滑块时,音量应该改变多少百分比。\n这应该是音量滑块灵敏度的倍数,否则在滚动音量时会出现小的不规则跳跃。",
+    "feature_desc_watermarkEnabled": "在网站标志下方显示一个水印,以打开此配置菜单",
+    "feature_helptext_watermarkEnabled": "如果禁用此功能,您仍然可以通过单击右上角的个人资料图片打开配置菜单中的选项。\n但是,要找到彩蛋将会更难 ;)",
+    "feature_desc_removeShareTrackingParam": "从共享弹出窗口中的链接中删除跟踪参数 \"&si\"",
+    "feature_helptext_removeShareTrackingParam": "出于分析目的,YouTube 在您可以复制的共享菜单中的 URL 末尾添加了一个跟踪参数。虽然不会直接有害,但它会使 URL 变得更长,并且会向 YouTube 提供有关您和您发送链接的人的更多信息。",
+    "feature_desc_numKeysSkipToTime": "启用按数字键(0-9)跳转到视频中的特定时间",
+    "feature_desc_fixSpacing": "修复布局中的间距问题",
+    "feature_helptext_fixSpacing": "在用户界面中有各种位置的元素之间的间距不一致。此功能修复了这些问题。",
+
+    "feature_desc_lyricsQueueButton": "在队列中的每首歌曲旁边添加一个按钮,以快速打开其歌词页面",
+    "feature_desc_deleteFromQueueButton": "在队列中的每首歌曲旁边添加一个按钮,以快速删除它",
+    "feature_desc_listButtonsPlacement": "队列按钮应该显示在哪里?",
+    "feature_helptext_listButtonsPlacement": "网站上有各种歌曲列表,如专辑页面、播放列表和当前播放的队列。使用此选项,您可以选择队列按钮应该显示在哪里。",
+    "feature_desc_scrollToActiveSongBtn": "在队列中添加一个按钮,以滚动到当前播放的歌曲",
+
+    "feature_desc_disableBeforeUnloadPopup": "防止在播放歌曲时尝试离开网站时出现的确认弹出窗口",
+    "feature_helptext_disableBeforeUnloadPopup": "当尝试在正在播放的歌曲中几秒钟后离开网站时,将出现一个弹出窗口,询问您是否要离开网站。它可能会说类似于 \"您有未保存的数据\" 或 \"此网站正在询问您是否要关闭它\"。\n此功能完全禁用了该弹出窗口。",
+    "feature_desc_closeToastsTimeout": "多少秒后关闭永久通知 - 0 仅手动关闭(默认行为)",
+    "feature_helptext_closeToastsTimeout": "出现在左下角的大多数弹出窗口将在 3 秒后自动关闭,但有一些例外,例如喜欢一首歌时。\n此功能允许您设置永久弹出窗口关闭的时间。\n其他类型的弹出窗口不受影响。\n将此设置为 0 以使用默认行为,即不关闭永久通知。",
+    "feature_desc_rememberSongTime": "记住重新加载或恢复标签时的最后一首歌的时间",
+    "feature_helptext_rememberSongTime-1": "有时在重新加载页面或意外关闭后恢复它时,您希望在相同的位置继续听歌。此功能允许您这样做。\n为了记录歌曲的时间,您需要播放它 %1 秒,然后它的时间将被记住,并在短时间内可以恢复。",
+    "feature_helptext_rememberSongTime-n": "有时在重新加载页面或意外关闭后恢复它时,您希望在相同的位置继续听歌。此功能允许您这样做。\n为了记录歌曲的时间,您需要播放它 %1 秒,然后它的时间将被记住,并在短时间内可以恢复。",
+    "feature_desc_rememberSongTimeSites": "在哪些网站上应该记住和恢复歌曲时间?",
+
+    "feature_desc_arrowKeySupport": "使用箭头键在当前播放的歌曲中前进和后退",
+    "feature_helptext_arrowKeySupport": "通常,您只能使用 \"H\" 和 \"L\" 键以固定的 10 秒间隔前进和后退。此功能允许您也使用箭头键。\n要更改要跳过的秒数,请使用下面的选项。",
+    "feature_desc_arrowKeySkipBy": "使用箭头键跳过多少秒",
+    "feature_desc_switchBetweenSites": "在视频 / 歌曲上添加一个热键,以在 YT 和 YTM 网站之间切换",
+    "feature_helptext_switchBetweenSites": "按下此热键将在 YT 或 YTM 上切换到另一个站点,同时保持在同一视频 / 歌曲上。",
+    "feature_desc_switchSitesHotkey": "需要按下哪个热键才能切换网站?",
+    "feature_desc_anchorImprovements": "在页面上添加和改进链接,以便更容易在新标签页中打开",
+    "feature_helptext_anchorImprovements": "页面上的一些元素只能使用鼠标左键单击,这意味着您无法通过中键单击或使用 shift + 右键单击的上下文菜单在新标签页中打开它们。此功能添加了很多链接或扩大了现有的链接,以使单击更容易。",
+
+    "feature_desc_geniusLyrics": "在当前播放的歌曲的媒体控件中添加一个按钮,以在 genius.com 上打开其歌词",
+
+    "feature_desc_locale": "语言",
+    "feature_desc_versionCheck": "检查更新",
+    "feature_helptext_versionCheck": "此功能每 24 小时检查更新,如果有新版本可用,会通知您并允许您手动更新脚本。\n如果您的用户脚本管理器扩展自动更新脚本,您可以禁用此功能。",
+    "feature_desc_logLevel": "更改扩展程序的日志级别",
+    "feature_helptext_logLevel": "更改这个只是为了调试目的,因为遇到了问题。\n如果您有一个,您可以在这里增加日志级别,打开您的浏览器的 JavaScript 控制台(通常是 Ctrl + Shift + K)并在 GitHub 问题中附上那个日志的截图。"
+  }
+}

+ 41 - 1
changelog.md

@@ -1,8 +1,41 @@
+## 1.1.0
+- **Features / Changes:**
+  - The userscript is now available in 9 languages! To submit or edit translations, please [view this guide](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#submitting-translations)
+  - Added an interface for user-created plugins ([see contributing guide for more info](https://github.com/Sv443/BetterYTM/blob/develop/contributing.md#developing-a-plugin-that-interfaces-with-betterytm))
+  - Made site switch hotkey customizable
+  - Userscript will now show a welcome page after first install / update
+  - Feature to restore last song's time on page reload
+  - Made interval of arrow key skip configurable
+  - A hint is now sent to Dark Reader to disable itself (see [this](https://github.com/darkreader/darkreader/discussions/6868#discussioncomment-3109841))
+  - Made volume slider scroll sensitivity configurable
+  - Added details / help dialog to menu feature list
+  - Added queue buttons to all types of song list
+  - Added manual version check (can be disabled in config menu)
+- **Fixes:**
+  - BetterYTM now uses a more reliable way to skip to a certain time
+  - Fixed resources not loading in Chrome
+  - Fixed album list spacing getting messed up by anchor improvements styling
+  - Fixed "Start at" option in share menu making tracking parameter reappear
+  - Fixed selector for player queue that was changed by a YTM update
+- **Internal Changes:**
+  - The license of the source code has been changed from MIT to [AGPL-3.0](https://github.com/Sv443/BetterYTM/blob/main/LICENSE.txt)
+  - Migrated to the Rollup bundler
+    - Now multiple versions of the script are compiled for the different hosts (GitHub, GreasyFork, OpenUserJS) with slight compatibility fixes each
+    - Target branch can now be specified while compiling instead of being tied to the bundler mode
+  - Added support for React JSX
+  - Added support for external libraries through `@require`
+
+[See pull request for more info](https://github.com/Sv443/BetterYTM/pull/35)
+
+<div class="split"></div>
+<br>
+
 ## 1.0.2
 - **Changes:**
   - Script is now published to OpenUserJS!
   - Added a OpenUserJS link to the configuration menu
 
+<div class="split"></div>
 <br>
 
 ## 1.0.1
@@ -10,6 +43,7 @@
   - Script is now published to GreasyFork!
   - Added a GreasyFork link to the configuration menu
 
+<div class="split"></div>
 <br>
 
 ## 1.0.0
@@ -22,7 +56,7 @@
   - Added percentage label next to the volume slider & title on hover
   - Improvements to link hitboxes & more links in general
   - Permanent toast notifications can be automatically closed now
-  - Remove tracking parameter `&si=...` from links in the share menu
+  - Remove tracking parameter `&si` from links in the share menu
   - Fix spacing issues throughout the site
   - Added a button to scroll to the currently active song in the queue
   - Added an easter egg to the watermark and config menu option :)
@@ -36,6 +70,9 @@
   - Site switch with <kbd>F9</kbd> will now keep the video time
   - Moved lots of utility code to my new library [UserUtils](https://github.com/Sv443-Network/UserUtils)
 
+[See pull request for more info](https://github.com/Sv443/BetterYTM/pull/9)
+
+<div class="split"></div>
 <br>
 
 ## 0.2.0
@@ -45,6 +82,9 @@
   - Search for song lyrics with new button in media controls
   - Remove "Upgrade to YTM Premium" tab
 
+[See pull request for more info](https://github.com/Sv443/BetterYTM/pull/3)
+
+<div class="split"></div>
 <br>
 
 ## 0.1.0

+ 701 - 13
contributing.md

@@ -1,27 +1,715 @@
 ## BetterYTM - Contributing Guide
+Thank you for your interest in contributing to BetterYTM!  
+This guide will help you get started with contributing to the project.  
+If you have any questions or need help, feel free to contact me, [see my homepage](https://sv443.net/) for contact info.
 
 <br>
 
-#### Setting up the project for local development:
+- [Submitting translations](#submitting-translations)
+  - [Adding translations for a new language](#adding-translations-for-a-new-language)
+  - [Editing an existing translation](#editing-an-existing-translation)
+- [Setting up the project for local development](#setting-up-the-project-for-local-development)
+  - [Requirements](#requirements)
+  - [CLI commands](#these-are-the-cli-commands-available-after-setting-up-the-project)
+  - [Extras](#extras)
+- [Developing a plugin that interfaces with BetterYTM](#developing-a-plugin-that-interfaces-with-betterytm)
+
+<br><br>
+
+### Submitting translations:
+Thank you so much for your interest in translating BetterYTM!  
+Before submitting a translation, please check on [this document](./assets/translations/README.md) if the language you want to translate to has already been translated and how many strings are still missing.
+
+<br>
+
+#### Adding translations for a new language:
+To submit a translation, please follow these steps:  
+1. Copy the contents of the default translation file [`assets/translations/en_US.json`](./assets/translations/en_US.json)
+2. Replace the `en_US` part of the file name with the language code and locale code of the language you want to translate to
+3. Translate the strings inside the file, while making sure not to change the keys on the left side of the colon and to preserve the placeholders with the format %n (where n is any number starting at 1).
+4. If you like, you may also create a translation for the [`README-summary.md`](./README-summary.md) file for display on the userscript distribution sites  
+  Please duplicate the file `README-summary.md` and call it `README-summary-languageCode_localeCode.md` and place it in the [`assets/translations/`](./assets/translations/) folder.
+5. If you want to submit a pull request with the translated file:
+    1. Duplicate the `en_US.json` file in the folder [`assets/translations/`](./assets/translations/) by keeping the format `languageCode_localeCode.json`
+    2. Edit it to your translated version and keep the left side of the colon unchanged
+    3. Create the mapping in `assets/locales.json` by copying the english one and editing it (please make sure it's alphabetically ordered)
+    4. Add your name to the respective `authors` property in [`assets/locales.json`](./assets/locales.json)
+    5. Test your changes by following [this section](#setting-up-the-project-for-local-development), then submit your pull request
+6. Alternatively send it to me directly, [see my homepage](https://sv443.net/) for contact info  
+  Make sure you also add your language to the contents of [`assets/locales.json`](./assets/locales.json)
+
+<br>
+
+#### Editing an existing translation:
+To edit an existing translation, please follow these steps:
+1. Set up the project for local development by following [this section](#setting-up-the-project-for-local-development)  
+  Make sure you have forked the repository and cloned your fork instead of cloning the original repository.  
+2. Find the file for the language you want to edit in the folder [`assets/translations/`](./assets/translations/)
+3. Run the command `npm run tr-format -- -p -o=languageCode_localeCode`, where `languageCode_localeCode` is the part of the file name before the `.json` extension  
+  This will prepare the file for translation by providing the missing keys once in English and once without any value and also formatting the file to have the same structure as the base file `en_US.json`
+4. Edit the strings inside the file, while making sure not to change the keys on the left side of the colon and to preserve the placeholders with the format %n (where n is any number starting at 1).
+5. Make sure there are no duplicate keys in the file
+6. Run the command `npm run tr-format -- -o=languageCode_localeCode` to make sure the file is formatted correctly
+7. Test for syntax errors and update translation progress with the command `npm run tr-progress`
+8. Open the file [`assets/translations/README.md`](./assets/translations/README.md) to see if you're still missing any untranslated keys (you don't have to translate them all, but it would of course be nice)
+9. I highly encourage you to test your changes to see if the wording fits into the respective context by following [this section](#setting-up-the-project-for-local-development)
+10. Submit your pull request by [clicking here](https://github.com/Sv443/BetterYTM/compare/) and setting the `compare:` dropdown to your fork
+11. Check that the CI checks just above the comment box pass and then wait for the pull request to be reviewed and merged
+
+<br><br><br>
+
+### Setting up the project for local development:
+#### Requirements:
 1. Have Node.js, npm and Git installed
-2. Download and extract or clone this repo
-3. Open a terminal in the project root and run `npm i`
-4. Copy the file `.env.template` to `.env` and modify the variables inside to your needs.
+2. Clone this repository (if you plan on contributing to the project, please [click here to fork it](https://github.com/Sv443/BetterYTM/fork) and clone your fork instead)
+3. Switch to the `develop` branch by running `git checkout develop` in the project root.  
+  Skip this step if you are using your own forked repository.
+4. Open a terminal in the project root and run `npm i`
+5. Copy the file `.env.template` to `.env` and modify the variables inside to your needs.
+6. Now you can run `npm run dev` to build the userscript and host it on a development server or check out the other commands below
 
 <br>
 
 #### These are the CLI commands available after setting up the project:
-| Command | Description |
-| --- | --- |
-| `npm i` | Run once to install dependencies |
-| `npm run build-prod` | Builds the userscript for production (minified) |
-| `npm run build-dev` | Builds the userscript for development |
-| `npm run dev` | Watches for any changes, then rebuilds and serves the userscript on port 8710, so it can be updated live if set up correctly in the userscript manager (see below). Configure request logging and more in `src/tools/serve.ts` |
-| `npm run lint` | Builds the userscript with the TypeScript compiler and lints it with ESLint |
+- **`npm i`**  
+  Run once to install dependencies
+- **`npm run dev`**  
+  This is the command you want to use to locally develop and test BetterYTM.  
+  It watches for any changes, then rebuilds and serves the userscript on port 8710, so it can be updated live if set up correctly in the userscript manager (see [extras](#extras)).  
+  Once it has finished building, a link will be printed to the console. Open it to install the userscript.  
+  You can also configure request logging and more in `.env` and `src/tools/serve.ts`, just make sure to restart the dev server after changing anything.  
+- **`npm run build-prod`**  
+  Builds the userscript for production for all hosts with their respective options already set.  
+  Outputs the files using a suffix predefined in the `package.json` file.  
+  Use this to build the userscript for distribution on all host/CDN platforms.
+- **`npm run build -- <arguments>`**  
+  Builds the userscript with custom options  
+  Arguments:  
+  - `--config-mode=<value>` - The mode to build in. Can be either `production` or `development` (default)
+  - `--config-branch=<value>` - The GitHub branch to target. Can be any branch name, but should be `main` for production and `develop` for development (default)
+  - `--config-host=<value>` - The host to build for. Can be either `github` (default), `greasyfork` or `openuserjs`
+  - `--config-suffix=<value>` - Suffix to add just before the `.user.js` extension. Defaults to an empty string
+    
+  Shorthand commands:
+  - `npm run build-prod-base` - Sets `--config-mode=production` and `--config-branch=main`  
+    Used for building for production, targeting the main branch
+  - `npm run build-develop` - Sets `--config-mode=development` and `--config-branch=develop`  
+    Used for building for experimental versions, targeting the develop branch
+- **`npm run lint`**  
+  Builds the userscript with the TypeScript compiler and lints it with ESLint. Doesn't verify *all* of the functionality of the script, only syntax and TypeScript errors!
+- **`npm run tr-progress`**  
+  Checks all translation files for missing strings and updates the progress table in `assets/translations/README.md`
+- **`npm run tr-format -- <arguments>`**  
+  Reformats all translation files so they match that of the base file `en_US.json`  
+  This includes sorting keys and adding the same empty lines and indentation.
+  Arguments:  
+  - `--prep` or `-p` - Prepares the files for translation via GitHub Copilot by providing the missing key once in English and once without any value
+  - `--only="<value>"` or `-o="<value>"` - Only applies formatting to the files of the specified locales. Has to be a comma separated list (e.g. `-o="fr_FR,de_DE"`)
+  - `--include-based` or `-b` - Also includes files which have a base locale specified
+- **`npm run --silent invisible -- "<command>"`**  
+  Runs the passed command as a detached child process without giving any console output.  
+  Remove `--silent` to see npm's info and error messages.
+- **`npm run node-ts -- <path>`**  
+  Runs the TypeScript file at the given path using the regular node binary and the node-ts loader.  
+  Also enables source map support and disables experimental warnings.
 
 <br>
 
 #### Extras:
-When using ViolentMonkey, after running the command `npm run dev`, open [`http://localhost:8710/BetterYTM.user.js`](http://localhost:8710/BetterYTM.user.js) and select the `Track local file` option.  
+When using ViolentMonkey, after letting the command `npm run dev` run in the background, open [`http://localhost:8710/BetterYTM.user.js`](http://localhost:8710/BetterYTM.user.js) and select the `Track local file` option.  
 This makes it so the userscript automatically updates when the code changes.  
-Note: the tab needs to stay open on Firefox or the script will not update itself.
+Note: the tab needs to stay open on Firefox or the script will not update itself.
+
+<br><br><br>
+
+### Developing a plugin that interfaces with BetterYTM:
+BetterYTM has a built-in interface based on events and exposed global constants and functions that allows other userscripts to benefit from its features.  
+If you want your plugin to be displayed in the readme and possibly inside the userscript itself, please [submit an issue using the plugin submission template](https://github.com/Sv443/BetterYTM/issues/new/choose)  
+  
+These are the ways to interact with BetterYTM; constants, events and global functions:  
+- Static interaction is done through constants that are exposed through the global `BYTM` object, which is available on the `window` object.  
+  These read-only properties tell you more about how BetterYTM is currently being run.  
+  You can find all properties that are available and their types in the `declare global` block of [`src/types.ts`](src/types.ts)
+
+- Dynamic interaction is done through events that are dispatched on the `window` object.  
+  They all have the prefix `bytm:eventName` and are all dispatched with the `CustomEvent` interface, meaning their data can be read using the `detail` property.  
+  You can find all events that are available and their types in [`src/interface.ts`](src/interface.ts)  
+    
+  Additionally BetterYTM has an internal system called SiteEvents. They are dispatched using the format `bytm:siteEvent:eventName`  
+  You may find all SiteEvents that are available and their types in [`src/siteEvents.ts`](src/siteEvents.ts)  
+  Note that the `detail` property will be an array of the arguments that can be found in the event handler at the top of [`src/siteEvents.ts`](src/siteEvents.ts)
+
+- Another way of dynamically interacting is through global functions, which are also exposed by BetterYTM through the global `BYTM` object.  
+  You can find all functions that are available in the `InterfaceFunctions` type in [`src/types.ts`](src/types.ts)  
+  There is also a summary with examples [below.](#global-functions)  
+  Additionally to those functions, the namespace `BYTM.UserUtils` is also exposed, which contains all functions from the [UserUtils](https://github.com/Sv443-Network/UserUtils) library.
+
+All of these interactions require the use of `unsafeWindow`, as the regular window object is pretty sandboxed in userscript managers.  
+  
+If you need specific events to be added or modified, please [submit an issue.](https://github.com/Sv443/BetterYTM/issues/new/choose)
+
+<br>
+
+<details><summary><b>Static interaction Example <i>(click to expand)</i></b></summary>
+
+#### Example:
+```ts
+const BYTM = unsafeWindow.BYTM;
+
+console.log(`BetterYTM was built in '${BYTM.mode}' mode`);
+console.log(`BetterYTM's locale is set to '${BYTM.locale}'`);
+console.log(`BetterYTM's version is '${BYTM.version} #${BYTM.buildNumber}'`);
+```
+
+</details>
+
+<br>
+
+<details><summary><b>Dynamic interaction examples <i>(click to expand)</i></b></summary>
+
+#### Basic format:
+```ts
+window.addEventListener("bytm:eventName", (event) => {
+  // can have any type, but usually it's an object or undefined
+  const { detail } = event as CustomEvent<{ foo: string }>;
+
+  console.log(detail.foo);
+});
+
+// for listening to SiteEvents:
+window.addEventListener("bytm:siteEvent:eventName", (event) => {
+  // always typed as array / tuple
+  const { detail } = event as CustomEvent<[ foo: HTMLElement ]>;
+
+  console.log(detail[0]);
+});
+```
+
+#### Practical Example:
+```ts
+// listening to generic events:
+window.addEventListener("bytm:ready", () => {
+  console.log("The DOM is loaded and all BetterYTM features have been initialized");
+});
+
+window.addEventListener("bytm:lyricsLoaded", (event) => {
+  const { detail } = event as CustomEvent<{ type: "current" | "queue", artists: string, title: string, url: string }>;
+
+  console.log(`Lyrics URL for "${detail.artists} - ${detail.title}" has been loaded: ${detail.url}`);
+
+  if(detail.type === "current")
+    console.log("This is from the currently playing song");
+  else
+    console.log("This is from a song in the queue");
+});
+
+// listening to a SiteEvent:
+window.addEventListener("bytm:siteEvent:queueChanged", (event) => {
+  const { detail } = event as CustomEvent<[ queueItem: HTMLElement ]>;
+
+  console.log(`The queue has been changed. It now contains ${detail[0].childNodes.length} items`);
+});
+```
+
+</details>
+
+<br>
+
+**For global function examples [see below.](#global-functions)**
+
+<br><br>
+
+### Shimming for TypeScript without errors & with autocomplete:
+In order for TypeScript to not throw errors while creating a plugin, you need to shim the types for BYTM.  
+To do this, create a .d.ts file (for example `bytm.d.ts`) and add the following code:
+```ts
+declare global {
+  interface Window {
+    BYTM: {
+      // add types here
+    };
+  }
+}
+```
+You may specify all types that you need in this file.  
+To find which types BetterYTM exposes, check out the `declare global` block in [`src/types.ts`](src/types.ts)  
+You may also just copy it entirely, as long as all the imports also exist in your project.  
+An easy way to do this might be to include BetterYTM as a Git submodule, as long as you ***stick to only using type imports***
+
+
+<br><br>
+
+### Global functions:
+These are the global functions that are exposed by BetterYTM through the `unsafeWindow.BYTM` object.  
+The usage and example blocks on each are written in TypeScript but can be used in JavaScript as well, after removing all type annotations.  
+  
+- BYTM-specific:
+  - [getResourceUrl()](#getresourceurl) - Returns a `blob:` URL provided by the local userscript extension for the specified BYTM resource file
+  - [getSessionId()](#getsessionid) - Returns the unique session ID that is generated on every started session
+- DOM:
+  - [addSelectorListener()](#addselectorlistener) - Adds a listener that checks for changes in DOM elements matching a CSS selector
+  - [getVideoTime()](#getvideotime) - Returns the current video time (on both YT and YTM)
+- Translations:
+  - [setLocale()](#setlocale) - Sets the locale for BetterYTM
+  - [getLocale()](#getlocale) - Returns the currently set locale
+  - [hasKey()](#haskey) - Checks if the specified translation key exists in the currently set locale
+  - [hasKeyFor()](#haskeyfor) - Checks if the specified translation key exists in the specified locale
+  - [t()](#t) - Translates the specified translation key using the currently set locale
+  - [tp()](#tp) - Translates the specified translation key including pluralization using the currently set locale
+- Feature config:
+  - [getFeatures()](#getfeatures) - Returns the current BYTM feature configuration object
+  - [saveFeatures()](#savefeatures) - Overwrites the current BYTM feature configuration object with the provided one
+- Lyrics:
+  - [fetchLyricsUrl](#fetchlyricsurl) - Fetches the URL to the lyrics page for the specified song
+  - [getLyricsCacheEntry](#getlyricscacheentry) - Tries to find a URL entry in the in-memory cache for the specified song
+  - [sanitizeArtists](#sanitizeartists) - Sanitizes the specified artist string to be used in fetching a lyrics URL
+  - [sanitizeSong](#sanitizesong) - Sanitizes the specified song title string to be used in fetching a lyrics URL
+
+<br>
+
+> #### getResourceUrl()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.getResourceUrl(): Promise<string>
+> ```
+>   
+> Description:  
+> Returns a `blob:` URL for the specified BYTM resource file.  
+> You can find a list of them by looking at the `@resource` directives in the userscript header or in the files `assets/resources.json` and `src/tools/post-build.ts`  
+> The resource and its URL are provided by the userscript extension and it is locally cached for quicker fetching.  
+>   
+> Should a resource not be defined, the function will return the equivalent URL from the GitHub repository instead.  
+> Should that also fail, it will try to return a base64-encoded `data:` URI version of the resource.  
+>   
+> Arguments:  
+> - `resourceName` - The name of the resource to get the URL for.
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> const deleteButtonImg = document.createElement("img");
+> deleteButtonImg.src = await unsafeWindow.BYTM.getResourceUrl("delete");
+> 
+> myElement.appendChild(deleteButtonImg);
+> ```
+> </details>
+
+<br>
+
+> #### getSessionId()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.getSessionId(): string
+> ```
+>   
+> Description:  
+> Returns the unique session ID that is generated on every page load.  
+> It should persist between history navigations, but not between page reloads.  
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> const sessionId = unsafeWindow.BYTM.getSessionId();
+> 
+> if(await GM.getValue("_myPlugin-sesId") !== sessionId) {
+>   console.log("New session started");
+>   // do something that should only be done once per session
+>   // or store values persistently that should be unique per session:
+>   await GM.setValue("_myPlugin-sesId", sessionId);
+> }
+> ```
+> </details>
+
+<br>
+
+> #### addSelectorListener()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.addSelectorListener<TElem extends Element>(observerName: ObserverName, selector: string, options: SelectorListenerOptions<TElem>): void
+> ```
+>   
+> Description:  
+> Adds a listener to the specified SelectorObserver instance that gets called when the element(s) behind the passed selector change.  
+> These instances are created by BetterYTM to observe the DOM for changes.  
+> See the [UserUtils SelectorObserver documentation](https://github.com/Sv443-Network/UserUtils#selectorobserver) for more info.  
+>   
+> Arguments:  
+> - `observerName` - The name of the SelectorObserver instance to add the listener to. You can find all available instances and which parent element they observe in the file [`src/observers.ts`](src/observers.ts).
+> - `selector` - The CSS selector to observe for changes.
+> - `options` - The options for the listener. See the [UserUtils SelectorObserver documentation](https://github.com/Sv443-Network/UserUtils#selectorobserver)
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> // wait for the observers to exist
+> unsafeWindow.addEventListener("bytm:observersReady", () => {
+>   // use the "lowest" possible SelectorObserver (playerBar)
+>   // and check if the lyrics button gets added or removed
+>   unsafeWindow.BYTM.addSelectorListener("playerBar", "#betterytm-lyrics-button", {
+>     listener: (elem) => {
+>       console.log("The BYTM lyrics button changed");
+>     },
+>   });
+> });
+> ```
+> </details>
+
+<br>
+
+> #### getVideoTime()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.getVideoTime(): Promise<number | null>
+> ```
+>   
+> Description:  
+> Returns the current video time (on both YT and YTM).  
+> In case it can't be determined on YT, mouse movement is simulated to bring up the video time element and read it.  
+> In order for that edge case not to throw an error, the function would need to be called in response to a user interaction event (e.g. click) due to the strict automated interaction policy in browsers.  
+> Resolves with a number of seconds or `null` if the time couldn't be determined.  
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> try {
+>   const videoTime = await unsafeWindow.BYTM.getVideoTime();
+>   console.log(`The video time is ${videoTime}s`);
+> }
+> catch(err) {
+>   console.error("Couldn't get the video time, probably due to automated interaction restrictions");
+> }
+> ```
+> </details>
+
+<br>
+
+> #### setLocale()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.setLocale(locale: string): void
+> ```
+>   
+> Description:  
+> Sets the locale for BetterYTM's translations.  
+> The new locale is used for all translations *after* this function is called.  
+>   
+> Arguments:  
+> - `locale` - The locale to set. Refer to the file [`assets/locales.json`](assets/locales.json) for a list of available locales.
+> 
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> unsafeWindow.BYTM.setLocale("en_UK");
+> ```
+> </details>
+
+<br>
+
+> #### getLocale()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.getLocale(): string
+> ```
+>   
+> Description:  
+> Returns the currently set locale.  
+> 
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> unsafeWindow.BYTM.getLocale(); // "en_US"
+> 
+> unsafeWindow.BYTM.setLocale("en_UK");
+> 
+> unsafeWindow.BYTM.getLocale(); // "en_UK"
+> ```
+> </details>
+
+<br>
+
+> #### hasKey()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.hasKey(key: string): boolean
+> ```
+>   
+> Description:  
+> Returns true if the specified translation key exists in the currently set locale.  
+>   
+> Arguments:  
+> - `key` - The key of the translation to check for.
+> 
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> unsafeWindow.BYTM.hasKey("lyrics_rate_limited"); // true
+> unsafeWindow.BYTM.hasKey("some_key_that_doesnt_exist"); // false
+> ```
+> </details>
+
+<br>
+
+> #### hasKeyFor()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.hasKeyFor(locale: string, key: string): boolean
+> ```
+>   
+> Description:  
+> Returns true if the specified translation key exists in the specified locale.  
+>   
+> Arguments:  
+> - `locale` - The locale to check for the translation key in.
+> - `key` - The key of the translation to check for.
+> 
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> unsafeWindow.BYTM.hasKeyFor("en_UK", "lyrics_rate_limited"); // true
+> unsafeWindow.BYTM.hasKeyFor("en_UK", "some_key_that_doesnt_exist"); // false
+> ```
+> </details>
+
+<br>
+
+> #### t()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.t(key: TFuncKey, ...values: Stringifiable[]): string
+> ```
+>   
+> Description:  
+> Returns the translation for the provided translation key and currently set locale.  
+> To see a list of translations, check the file [`assets/translations/en_US.json`](assets/translations/en_US.json)  
+>   
+> Arguments:  
+> - `translationKey` - The key of the translation to get.
+> - `...values` - A spread parameter of values that can be converted to strings to replace the numbered placeholders in the translation with.
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> const customConfigMenuTitle = document.createElement("div");
+> customConfigMenuTitle.innerText = unsafeWindow.BYTM.t("config_menu_title", "My cool BYTM Plugin");
+> // translated text: "My cool BYTM Plugin - Configuration" (if locale is en_US or en_UK)
+> ```
+> </details>
+
+<br>
+
+> #### tp()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.tp(key: TFuncKey, num: number | unknown[] | NodeList, ...values: Stringifiable[]): string
+> ```
+>   
+> Description:  
+> Returns the translation for the provided translation key, including pluralization identifier and currently set locale.  
+> To see a list of translations, check the file [`assets/translations/en_US.json`](assets/translations/en_US.json)  
+>   
+> The pluralization identifier is determined by the number of items in the second argument.  
+> It can be either "1" or "n" and will be appended to the translation key separated by a hyphen.  
+>   
+> Arguments:  
+> - `translationKey` - The key of the translation to get.
+> - `num` - The number of items to determine the pluralization identifier from. Can also be an array or NodeList.
+> - `...values` - A spread parameter of values that can be converted to strings to replace the numbered placeholders in the translation with.
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> try {
+>   const lyrics = await unsafeWindow.BYTM.fetchLyricsUrl("Michael Jackson", "Thriller");
+> }
+> catch(err) {
+>   if(err instanceof Error && err.status === 429) {
+>     // rate limited
+>     const retryAfter = err.response.headers["retry-after"];
+>     const retryAfterSeconds = retryAfter ? parseInt(retryAfter) : 60;
+>     const errorText = unsafeWindow.BYTM.tp("lyrics_rate_limited", retryAfterSeconds);
+>     // translation key: "lyrics_rate_limited-n"
+>     // translated text: "You are being rate limited.\nPlease wait 23 seconds before requesting more lyrics."
+>     alert(errorText);
+>   }
+> }
+> ```
+> </details>
+
+<br>
+
+> #### getFeatures()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.getFeatures(): FeatureConfig
+> ```
+>   
+> Description:  
+> Returns the current feature configuration object synchronously from memory.  
+> To see the structure of the object, check out the type `FeatureConfig` in the file [`src/types.ts`](src/types.ts)  
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> const features = unsafeWindow.BYTM.getFeatures();
+> console.log(`The volume slider step is currently set to ${features.volumeSliderStep}`);
+> ```
+> </details>
+
+<br>
+
+> #### saveFeatures()
+> Usage:  
+> ```ts
+> unsafeWindow.BYTM.saveFeatures(config: FeatureConfig): Promise<void>
+> ```
+>   
+> Description:  
+> Overwrites the current feature configuration object with the provided one.  
+> The object in memory is updated synchronously, while the one in GM storage is updated asynchronously once the Promise resolves.  
+>   
+> Arguments:  
+> - `config` - The full config object to save. If properties are missing, BYTM will break!  
+>   To see the structure of the object, check out the type `FeatureConfig` in the file [`src/types.ts`](src/types.ts)  
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> async function updateVolSliderStep() {
+>   const oldConfig = unsafeWindow.BYTM.getFeatures();
+>   const newConfig = { ...oldConfig, volumeSliderStep: 1 };
+> 
+>   const promise = unsafeWindow.BYTM.saveFeatures(newConfig);
+>   // new config is now saved in memory, but not yet in GM storage
+>   // so this already returns the updated config:
+>   console.log(unsafeWindow.BYTM.getFeatures());
+> 
+>   await promise;
+>   // now the data is saved persistently in GM storage and the page can
+>   // safely be reloaded without losing the updated config data
+> }
+> 
+> updateVolSliderStep();
+> ```
+> </details>
+
+<br>
+
+> #### fetchLyricsUrl()
+> Usage:
+> ```ts
+> unsafeWindow.BYTM.fetchLyricsUrl(artist: string, song: string): Promise<string | undefined>
+> ```
+>   
+> Description:  
+> Fetches the URL to the lyrics page for the specified song.  
+> If there is already an entry in the in-memory cache for the song, it will be returned without fetching anything new.  
+> URLs that are returned by this function are added to the cache automatically.  
+> Returns undefined if there was an error while fetching the URL.  
+>   
+> Arguments:  
+> - `artist` - The main artist of the song to fetch the lyrics URL for.  
+>   The value needs to be sanitized with [`sanitizeArtists()`](#sanitizeartists) before being passed to this function.
+> - `song` - The title of the song to fetch the lyrics URL for.  
+>   The value needs to be sanitized with [`sanitizeSong()`](#sanitizesong) before being passed to this function.
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> async function getLyricsUrl() {
+>   const lyricsUrl = await unsafeWindow.BYTM.fetchLyricsUrl("Michael Jackson", "Thriller");
+> 
+>   if(lyricsUrl)
+>     console.log(`The lyrics URL for Michael Jackson's Thriller is '${lyricsUrl}'`);
+>   else
+>     console.log("Couldn't find the lyrics URL for this song");
+> }
+> 
+> getLyricsUrl();
+> ```
+> </details>
+
+<br>
+
+> #### getLyricsCacheEntry()
+> Usage:
+> ```ts
+> unsafeWindow.BYTM.getLyricsCacheEntry(artists: string, song: string): string | undefined
+> ```
+>   
+> Description:  
+> Tries to find an entry in the in-memory cache for the specified song.  
+> Contrary to [`fetchLyricsUrl()`](#fetchlyricsurl), this function does not fetch anything new if there is no entry in the cache.  
+>   
+> Arguments:  
+> - `artist` - The main artist of the song to grab the lyrics URL for.  
+>   The value needs to be sanitized with [`sanitizeArtists()`](#sanitizeartists) before being passed to this function.
+> - `song` - The title of the song to grab the lyrics URL for.  
+>   The value needs to be sanitized with [`sanitizeSong()`](#sanitizesong) before being passed to this function.
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> function tryToGetLyricsUrl() {
+>   const lyricsUrl = unsafeWindow.BYTM.getLyricsCacheEntry("Michael Jackson", "Thriller");
+> 
+>   if(lyricsUrl)
+>     console.log(`The lyrics URL for Michael Jackson's Thriller is '${lyricsUrl}'`);
+>   else
+>     console.log("Couldn't find the lyrics URL for this song in cache");
+> }
+> 
+> tryToGetLyricsUrl();
+> ```
+> </details>
+
+<br>
+
+> #### sanitizeArtists()
+> Usage:
+> ```ts
+> unsafeWindow.BYTM.sanitizeArtists(artists: string): string
+> ```
+>   
+> Description:  
+> Sanitizes the specified artist string to be used in fetching a lyrics URL.  
+> This tries to strip out special characters and co-artist names, separated by a comma or ampersand.  
+> Returns (hopefully) a single artist name with leading and trailing whitespaces trimmed.  
+>   
+> Arguments:  
+> - `artists` - The string of artist name(s) to sanitize.
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> // usually artist strings will only have one of these characters but this is just an example
+> const sanitizedArtists = unsafeWindow.BYTM.sanitizeArtists(" Michael Jackson    • Paul McCartney & Freddy Mercury, Frank Sinatra");
+> console.log(sanitizedArtists); // "Michael Jackson"
+> ```
+> </details>
+
+<br>
+
+> #### sanitizeSong()
+> Usage:
+> ```ts
+> unsafeWindow.BYTM.sanitizeSong(songName: string): string
+> ```
+>   
+> Description:  
+> Sanitizes the specified song title string to be used in fetching a lyrics URL.  
+> This tries to strip out special characters and everything inside regular and square parentheses like `(Foo Remix)`.  
+> Returns (hopefully) a song title with leading and trailing whitespaces trimmed.  
+>   
+> Arguments:  
+> - `songName` - The string of the song title to sanitize.
+>   
+> <details><summary><b>Example <i>(click to expand)</i></b></summary>
+> 
+> ```ts
+> const sanitizedSong = unsafeWindow.BYTM.sanitizeSong(" Thriller (Freddy Mercury Cover) [Tommy Cash Remix]");
+> console.log(sanitizedSong); // "Thriller"
+> ```
+> </details>
+
+
+<br><br><br><br><br><br>

Різницю між файлами не показано, бо вона завелика
+ 916 - 637
dist/BetterYTM.user.js


+ 0 - 18
global.d.ts

@@ -1,18 +0,0 @@
-/** Import HTML as modules - https://stackoverflow.com/a/47705264/3323672 */
-declare module "*.html" {
-  /** Content of the HTML file as a string */
-  const htmlContent: string;
-  export default htmlContent;
-}
-
-declare module "*.svg" {
-  /** Content of the SVG file as a string */
-  const htmlContent: string;
-  export default htmlContent;
-}
-
-declare module "*.md" {
-  /** Content of the markdown file, converted to an HTML string */
-  const htmlContent: string;
-  export default htmlContent;
-}

Різницю між файлами не показано, бо вона завелика
+ 1466 - 653
package-lock.json


+ 66 - 27
package.json

@@ -1,21 +1,29 @@
 {
   "name": "betterytm",
   "userscriptName": "BetterYTM",
-  "version": "1.0.2",
-  "description": "Configurable layout and UX improvements for YouTube Music",
-  "description:de": "Konfigurierbares Layout und UX-Verbesserungen für YouTube Music",
+  "version": "1.1.0",
+  "description": "Configurable layout and user experience improvements for YouTube Music",
   "homepage": "https://github.com/Sv443/BetterYTM",
   "main": "./src/index.ts",
   "type": "module",
   "scripts": {
-    "test": "npm run node-ts -- ./test.ts",
-    "build-prod": "webpack --env mode=production",
-    "build-dev": "webpack --env mode=development",
-    "post-build": "npm run node-ts -- ./src/tools/post-build.ts",
+    "dev": "concurrently \"nodemon --exec npm run build-watch\" \"npm run serve\"",
     "serve": "npm run node-ts -- ./src/tools/serve.ts",
-    "dev": "concurrently \"nodemon --exec npm run build-dev\" \"npm run serve\"",
     "lint": "tsc --noEmit && eslint .",
-    "node-ts": "node --no-warnings=ExperimentalWarning --enable-source-maps --loader ts-node/esm"
+    "build": "rollup -c",
+    "build-watch": "rollup -c --config-mode development --config-host github --config-branch develop",
+    "build-develop": "rollup -c --config-mode production --config-host github --config-branch develop",
+    "build-prod": "npm run build-prod-gh && npm run build-prod-gf && npm run build-prod-oujs",
+    "build-prod-base": "rollup -c --config-mode production --config-branch main",
+    "build-prod-gh": "npm run build-prod-base -- --config-host github",
+    "build-prod-gf": "npm run build-prod-base -- --config-host greasyfork --config-suffix _gf",
+    "build-prod-oujs": "npm run build-prod-base -- --config-host openuserjs --config-suffix _oujs",
+    "post-build": "npm run node-ts -- ./src/tools/post-build.ts",
+    "tr-progress": "npm run node-ts -- ./src/tools/tr-progress.ts",
+    "tr-format": "npm run node-ts -- ./src/tools/tr-format.ts",
+    "node-ts": "node --no-warnings=ExperimentalWarning --enable-source-maps --loader ts-node/esm",
+    "invisible": "node src/tools/run-invisible.mjs",
+    "test": "npm run node-ts -- ./test.ts"
   },
   "engines": {
     "node": ">=18",
@@ -29,35 +37,64 @@
     "name": "Sv443",
     "url": "https://github.com/Sv443"
   },
-  "license": "MIT",
+  "license": "AGPL-3.0",
   "bugs": {
     "url": "https://github.com/Sv443/BetterYTM/issues"
   },
+  "funding": {
+    "type": "github",
+    "url": "https://github.com/sponsors/Sv443"
+  },
+  "hosts": {
+    "github": "https://github.com/Sv443/BetterYTM",
+    "greasyfork": "https://greasyfork.org/en/scripts/475682-betterytm",
+    "openuserjs": "https://openuserjs.org/scripts/Sv443/BetterYTM"
+  },
+  "updates": {
+    "github": "https://github.com/Sv443/BetterYTM/releases",
+    "greasyfork": "https://greasyfork.org/en/scripts/475682-betterytm",
+    "openuserjs": "https://openuserjs.org/scripts/Sv443/BetterYTM"
+  },
   "dependencies": {
-    "@sv443-network/userutils": "^1.1.2",
-    "nanoevents": "^8.0.0"
+    "@sv443-network/userutils": "^4.1.0",
+    "nanoevents": "^9.0.0"
   },
   "devDependencies": {
+    "@babel/cli": "^7.23.9",
+    "@babel/core": "^7.23.9",
+    "@babel/plugin-transform-class-properties": "^7.23.3",
+    "@babel/preset-react": "^7.23.3",
+    "@jackfranklin/rollup-plugin-markdown": "^0.4.0",
+    "@rollup/plugin-babel": "^6.0.4",
+    "@rollup/plugin-json": "^6.0.1",
+    "@rollup/plugin-node-resolve": "^15.2.3",
+    "@rollup/plugin-replace": "^5.0.5",
+    "@rollup/plugin-terser": "^0.4.4",
+    "@rollup/plugin-typescript": "^11.1.5",
     "@types/express": "^4.17.17",
     "@types/greasemonkey": "^4.0.4",
     "@types/node": "^20.2.4",
-    "@typescript-eslint/eslint-plugin": "^5.59.8",
-    "@typescript-eslint/parser": "^5.59.7",
+    "@types/react": "^18.2.55",
+    "@types/react-dom": "^18.2.19",
+    "@typescript-eslint/eslint-plugin": "^6.7.4",
+    "@typescript-eslint/parser": "^6.7.4",
     "concurrently": "^8.1.0",
-    "css-loader": "^6.8.1",
-    "css-minimizer-webpack-plugin": "^5.0.0",
-    "dotenv": "^16.1.4",
-    "eslint": "^7.32.0",
+    "dotenv": "^16.4.1",
+    "eslint": "^8.51.0",
+    "eslint-plugin-react": "^7.33.2",
+    "eslint-plugin-react-hooks": "^4.6.0",
     "express": "^4.18.2",
-    "html-loader": "^4.2.0",
-    "markdown-loader": "^8.0.0",
-    "mini-css-extract-plugin": "^2.7.6",
     "nodemon": "^3.0.1",
-    "ts-loader": "^9.4.3",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "rollup": "^4.6.0",
+    "rollup-plugin-commonjs": "^10.1.0",
+    "rollup-plugin-execute": "^1.1.1",
+    "rollup-plugin-html": "^0.2.1",
+    "rollup-plugin-import-css": "^3.3.5",
     "ts-node": "^10.9.1",
     "tslib": "^2.5.2",
-    "typescript": "^5.0.4",
-    "webpack-cli": "^5.1.1"
+    "typescript": "^5.0.4"
   },
   "browserslist": [
     "last 1 version",
@@ -68,10 +105,12 @@
     "watch": [
       "src/**",
       "assets/**",
-      "webpack.config.js",
-      ".env"
+      "rollup.config.mjs",
+      ".env",
+      "changelog.md",
+      "package.json"
     ],
-    "ext": "ts,js,json,html,css",
+    "ext": "ts,tsx,mts,js,jsx,mjs,json,html,css,svg,png",
     "ignore": [
       "dist/*",
       "dev/*"

+ 88 - 0
rollup.config.mjs

@@ -0,0 +1,88 @@
+import pluginTypeScript from "@rollup/plugin-typescript";
+import pluginNodeResolve from "@rollup/plugin-node-resolve";
+import pluginJson from "@rollup/plugin-json";
+import pluginHtml from "rollup-plugin-html";
+import pluginCss from "rollup-plugin-import-css";
+import pluginMarkdown from "@jackfranklin/rollup-plugin-markdown";
+import pluginReplace from "@rollup/plugin-replace";
+import pluginCJS from "rollup-plugin-commonjs";
+import pluginBabel from "@rollup/plugin-babel";
+import pluginExecute from "rollup-plugin-execute";
+
+import typescript from "typescript";
+
+const outputDir = "dist";
+const outputFile = getOutputFileName();
+
+/** @param {string} [suffix] */
+function getOutputFileName(suffix) {
+  return `BetterYTM${suffix ?? ""}.user.js`;
+}
+
+export default (/**@type {import("./src/types").RollupArgs}*/ args) => (async () => {
+  const mode = args["config-mode"] ?? (process.env.NODE_ENV === "production" ? "production" : "development");
+  const branch = args["config-branch"] ?? "develop";
+  const host = args["config-host"] ?? "github";
+  const suffix = args["config-suffix"] ?? "";
+
+  /** @type {import("rollup").RollupOptions} */
+  const config = {
+    input: "src/index.ts",
+    plugins: [
+      pluginReplace({
+        "process.env.NODE_ENV": JSON.stringify(mode),
+        ENVIRONMENT: JSON.stringify(mode),
+        preventAssignment: true,
+      }),
+      pluginNodeResolve({
+        extensions: [".ts", ".tsx", ".mts", ".json"],
+      }),
+      pluginTypeScript({
+        typescript,
+        sourceMap: mode === "development",
+      }),
+      pluginJson(),
+      pluginHtml(),
+      pluginCss({
+        output: "global.css",
+      }),
+      pluginMarkdown(),
+      pluginCJS({
+        include: [
+          "node_modules/**"
+        ],
+        exclude: [
+          "node_modules/process-es6/**"
+        ],
+      }),
+      pluginBabel({ babelHelpers: "bundled" }),
+      pluginExecute([
+        `npm run --silent post-build -- --mode=${mode} --branch=${branch} --host=${host} --suffix=${suffix}`,
+        ...(mode === "development" ? ["npm run --silent invisible -- \"npm run tr-progress\""] : []),
+      ]),
+    ],
+    output: {
+      file: `${outputDir}/${getOutputFileName(suffix)}`,
+      format: "iife",
+      sourcemap: mode === "development",
+      compact: mode === "development",
+      banner: "\n/* global React, ReactDOM */",
+      globals: {
+        react: "React",
+        "react-dom": "ReactDOM",
+      },
+    },
+    onwarn(warning) {
+      // ignore circular dependency warnings
+      if(warning.code !== "CIRCULAR_DEPENDENCY") {
+        const { message, ...rest } = warning;
+        console.error(`\x1b[33m(!)\x1b[0m ${message}\n`, rest);
+      }
+    },
+    external: id => /^react(-dom)?$/.test(id)
+  };
+
+  return config;
+})();
+
+export { outputDir, outputFile };

+ 11 - 0
src/README.md

@@ -0,0 +1,11 @@
+## Src
+This directory contains the entire runtime code for the userscript.  
+
+<br>
+
+### Subdirectories
+- [Dev](./dev/) - Contains random development files and notes that aren't included in the final build of the userscript
+- [Features](./features/) - Contains the code for all the separate features themselves
+- [Menu](./menu/) - Contains the code for the userscript's different menus
+- [Tools](./tools/) - Contains helper tools for building and developing the userscript
+- [Utils](./utils/) - Contains helper utilities that are used in the userscript's code itself

+ 41 - 15
src/config.ts

@@ -1,11 +1,11 @@
-import { ConfigManager, ConfigMigrationsDict } from "@sv443-network/userutils";
+import { ConfigManager, type ConfigMigrationsDict } from "@sv443-network/userutils";
 import { featInfo } from "./features/index";
-import { FeatureConfig } from "./types";
 import { info, log } from "./utils";
-import { siteEvents } from "./events";
+import { emitSiteEvent } from "./siteEvents";
+import type { FeatureConfig } from "./types";
 
 /** If this number is incremented, the features object data will be migrated to the new format */
-export const formatVersion = 3;
+export const formatVersion = 4;
 /** Config data format migration dictionary */
 export const migrations: ConfigMigrationsDict = {
   // 1 -> 2
@@ -21,14 +21,38 @@ export const migrations: ConfigMigrationsDict = {
   // 2 -> 3
   3: (oldData: Record<string, unknown>) => ({
     ...oldData,
-    removeShareTrackingParam: true,
-    numKeysSkipToTime: true,
-    fixSpacing: true,
-    scrollToActiveSongBtn: true,
-    logLevel: 1,
+    removeShareTrackingParam: getFeatureDefault("removeShareTrackingParam"),
+    numKeysSkipToTime: getFeatureDefault("numKeysSkipToTime"),
+    fixSpacing: getFeatureDefault("fixSpacing"),
+    scrollToActiveSongBtn: getFeatureDefault("scrollToActiveSongBtn"),
+    logLevel: getFeatureDefault("logLevel"),
   }),
+  // 3 -> 4
+  4: (oldData: Record<string, unknown>) => {
+    const oldSwitchSitesHotkey = oldData.switchSitesHotkey as Record<string, unknown>;
+    return {
+      ...oldData,
+      rememberSongTime: getFeatureDefault("rememberSongTime"),
+      rememberSongTimeSites: getFeatureDefault("rememberSongTimeSites"),
+      arrowKeySkipBy: 10,
+      switchSitesHotkey: {
+        code: oldSwitchSitesHotkey.key ?? "F9",
+        shift: Boolean(oldSwitchSitesHotkey.shift ?? false),
+        ctrl: Boolean(oldSwitchSitesHotkey.ctrl ?? false),
+        alt: Boolean(oldSwitchSitesHotkey.meta ?? false),
+      },
+      listButtonsPlacement: "queueOnly",
+      volumeSliderScrollStep: getFeatureDefault("volumeSliderScrollStep"),
+      locale: getFeatureDefault("locale"),
+      versionCheck: getFeatureDefault("versionCheck"),
+    };
+  },
 };
 
+function getFeatureDefault<TKey extends keyof typeof featInfo>(key: TKey): typeof featInfo[TKey]["default"] {
+  return featInfo[key].default;
+}
+
 export const defaultConfig = (Object.keys(featInfo) as (keyof typeof featInfo)[])
   .reduce<Partial<FeatureConfig>>((acc, key) => {
     acc[key] = featInfo[key].default as unknown as undefined;
@@ -60,17 +84,19 @@ export function getFeatures() {
 }
 
 /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */
-export async function saveFeatures(featureConf: FeatureConfig) {
-  await cfgMgr.setData(featureConf);
-  siteEvents.emit("configChanged", cfgMgr.getData());
+export function saveFeatures(featureConf: FeatureConfig) {
+  const res = cfgMgr.setData(featureConf);
+  emitSiteEvent("configChanged", cfgMgr.getData());
   info("Saved new feature config:", featureConf);
+  return res;
 }
 
 /** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */
-export async function setDefaultFeatures() {
-  await cfgMgr.saveDefaultData();
-  siteEvents.emit("configChanged", cfgMgr.getData());
+export function setDefaultFeatures() {
+  const res = cfgMgr.saveDefaultData();
+  emitSiteEvent("configChanged", cfgMgr.getData());
   info("Reset feature config to its default values");
+  return res;
 }
 
 /** Clears the feature config from the persistent storage - since the cache will be out of whack, this should only be run before a site re-/unload */

+ 12 - 7
src/constants.ts

@@ -1,23 +1,28 @@
-import type { LogLevel } from "./types";
+import { LogLevel } from "./types";
 
-const modeRaw = "{{MODE}}";
-const branchRaw = "{{BRANCH}}";
+const modeRaw = "#{{MODE}}";
+const branchRaw = "#{{BRANCH}}";
+const hostRaw = "#{{HOST}}";
 
 /** The mode in which the script was built (production or development) */
-export const mode = (modeRaw.match(/^{{.+}}$/) ? "production" : modeRaw) as "production" | "development";
+export const mode = (modeRaw.match(/^#{{.+}}$/) ? "production" : modeRaw) as "production" | "development";
 /** The branch to use in various URLs that point to the GitHub repo */
-export const branch = (branchRaw.match(/^{{.+}}$/) ? "main" : branchRaw) as "main" | "develop";
+export const branch = (branchRaw.match(/^#{{.+}}$/) ? "main" : branchRaw) as "main" | "develop";
+/** Path to the GitHub repo */
+export const repo = "Sv443/BetterYTM";
+/** Which host the userscript was installed from */
+export const host = (hostRaw.match(/^#{{.+}}$/) ? "github" : hostRaw) as "github" | "greasyfork" | "openuserjs";
 
 /**
  * How much info should be logged to the devtools console  
  * 0 = Debug (show everything) or 1 = Info (show only important stuff)
  */
-export const defaultLogLevel: LogLevel = mode === "production" ? 1 : 0;
+export const defaultLogLevel: LogLevel = mode === "production" ? LogLevel.Info : LogLevel.Debug;
 
 /** Info about the userscript, parsed from the userscript header (tools/post-build.js) */
 export const scriptInfo = {
   name: GM.info.script.name,
   version: GM.info.script.version,
   namespace: GM.info.script.namespace,
-  buildNumber: "{{BUILD_NUMBER}}" as string, // assert as generic string instead of literal
+  buildNumber: "#{{BUILD_NUMBER}}" as string, // asserted as generic string instead of literal
 };

+ 17 - 0
src/declarations.d.ts

@@ -0,0 +1,17 @@
+/** Import HTML as modules - https://stackoverflow.com/a/47705264/3323672 */
+declare module "*.html" {
+  /** Content of the HTML file as a string */
+  const htmlContent: string;
+  export default htmlContent;
+}
+
+declare module "*.md" {
+  interface Exports {
+    /** Content of the markdown file, converted to an HTML string */
+    html: string;
+    metadata: Record<string, unknown>;
+    filename: string;
+    path: string;
+  }
+  export default {} as Exports;
+}

+ 2 - 0
src/dev/README.md

@@ -0,0 +1,2 @@
+## Dev
+This directory contains random development files and notes that aren't included in the final build of the userscript.

+ 1 - 1
src/dev/discoveries.md

@@ -2,7 +2,7 @@
 
 ### The problem with userscripts and SPAs:
 YTM is an SPA (single page application), meaning navigating to a different part of the site doesn't trigger the website, and by extension userscripts, to entirely reload like traditional redirects on MPAs (multi-page applications).  
-This means userscripts like BetterYTM rely on detecting changes in the DOM using something like the [MutationObserver API](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) (see `initSiteEvents()` in [`src/events.ts`](../events.ts)) and [the onSelector() function of my library UserUtils.](https://github.com/Sv443-Network/UserUtils#onselector)  
+This means userscripts like BetterYTM rely on detecting changes in the DOM using something like the [MutationObserver API](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) (see `initSiteEvents()` in [`src/siteEvents.ts`](../siteEvents.ts)) and [the SelectorObserver class of my library UserUtils.](https://github.com/Sv443-Network/UserUtils#selectorobserver)  
 This causes a LOT of headaches (race conditions, detecting navigation, state consistency, performance impacts and more) but it's the only option as far as I'm aware.
 
 <br>

+ 0 - 17
src/dev/parseVideoTime.ts

@@ -1,17 +0,0 @@
-/** Parses a video time string in the format `[hh:m]m:ss` to the equivalent number of seconds - returns 0 if input couldn't be parsed */
-function parseVideoTime(videoTime: string) {
-  const matches = /^((\d{1,2}):)?(\d{1,2}):(\d{2})$/.exec(videoTime);
-  if(!matches)
-    return 0;
-
-  const [, , hrs, min, sec] = matches as unknown as [string, string | undefined, string | undefined, string, string];
-
-  let finalTime = 0;
-  if(hrs)
-    finalTime += Number(hrs) * 60 * 60;
-  finalTime += Number(min) * 60 + Number(sec);
-
-  return isNaN(finalTime) ? 0 : finalTime;
-}
-
-void [parseVideoTime];

+ 4 - 0
src/features/README.md

@@ -0,0 +1,4 @@
+## Features
+This directory contains the code of the userscript features themselves. The files are organized by category.  
+Each feature has an initialization function that is called when the userscript is initialized.  
+In the long term most features should have a cleanup function that should be called when the feature config is changed.

+ 232 - 0
src/features/behavior.ts

@@ -0,0 +1,232 @@
+import { clamp, pauseFor } from "@sv443-network/userutils";
+import { error, getDomain, getVideoTime, info, log, onSelectorOld, videoSelector } from "../utils";
+import { LogLevel, type FeatureConfig } from "../types";
+
+let features: FeatureConfig;
+
+export function preInitBehavior(feats: FeatureConfig) {
+  features = feats;
+}
+
+//#MARKER beforeunload popup
+
+let beforeUnloadEnabled = true;
+
+/** Disables the popup before leaving the site */
+export function disableBeforeUnload() {
+  beforeUnloadEnabled = false;
+  info("Disabled popup before leaving the site");
+}
+
+/** (Re-)enables the popup before leaving the site */
+export function enableBeforeUnload() {
+  beforeUnloadEnabled = true;
+  info("Enabled popup before leaving the site");
+}
+
+/**
+ * Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload` 
+ * event listeners before they can be called by the site.
+ */
+export async function initBeforeUnloadHook() {
+  Error.stackTraceLimit = 1000; // default is 25 on FF so this should hopefully be more than enough
+
+  (function(original: typeof window.addEventListener) {
+    // @ts-ignore
+    window.__proto__.addEventListener = function(...args: Parameters<typeof window.addEventListener>) {
+      const origListener = typeof args[1] === "function" ? args[1] : args[1].handleEvent;
+      args[1] = function(...a) {
+        if(!beforeUnloadEnabled && args[0] === "beforeunload") {
+          info("Prevented beforeunload event listener from being called");
+          return false;
+        }
+        else
+          return origListener.apply(this, a);
+      };
+      original.apply(this, args);
+    };
+    // @ts-ignore
+  })(window.__proto__.addEventListener);
+}
+
+//#MARKER auto close toasts
+
+/** Closes toasts after a set amount of time */
+export async function initAutoCloseToasts() {
+  try {
+    const animTimeout = 300;
+    const closeTimeout = Math.max(features.closeToastsTimeout * 1000 + animTimeout, animTimeout);
+
+    onSelectorOld("tp-yt-paper-toast#toast", {
+      all: true,
+      continuous: true,
+      listener: async (toastElems) => {
+        for(const toastElem of toastElems) {
+          if(!toastElem.hasAttribute("allow-click-through"))
+            continue;
+
+          if(toastElem.classList.contains("bytm-closing"))
+            continue;
+          toastElem.classList.add("bytm-closing");
+
+          await pauseFor(closeTimeout);
+
+          toastElem.classList.remove("paper-toast-open");
+          log(`Automatically closed toast '${toastElem.querySelector<HTMLDivElement>("#text-container yt-formatted-string")?.innerText}' after ${features.closeToastsTimeout * 1000}ms`);
+
+          // wait for the transition to finish
+          await pauseFor(animTimeout);
+
+          toastElem.style.display = "none";
+        }
+      },
+    });
+
+    log("Initialized automatic toast closing");
+  }
+  catch(err) {
+    error("Error in automatic toast closing:", err);
+  }
+}
+
+//#MARKER remember song time
+
+interface RemSongObj {
+  /** Watch ID */
+  watchID: string;
+  /** Time of the song */
+  songTime: number;
+  /** Timestamp this entry was last updated */
+  updateTimestamp: number;
+}
+
+/** After how many milliseconds a remembered entry should expire */
+const remSongEntryExpiry = 1000 * 60 * 1;
+/** Minimum time a song has to be played before it is committed to GM storage */
+export const remSongMinPlayTime = 10;
+
+let remSongsCache: RemSongObj[] = [];
+
+/** Remembers the time of the last played song and resumes playback from that time */
+export async function initRememberSongTime() {
+  if(features.rememberSongTimeSites !== "all" && features.rememberSongTimeSites !== getDomain())
+    return;
+
+  const storedDataRaw = await GM.getValue("bytm-rem-songs");
+  if(!storedDataRaw)
+    await GM.setValue("bytm-rem-songs", "[]");
+
+  remSongsCache = JSON.parse(String(storedDataRaw ?? "[]")) as RemSongObj[];
+
+  log(`Initialized song time remembering with ${remSongsCache.length} initial entries`);
+
+  if(location.pathname.startsWith("/watch"))
+    await restoreSongTime();
+
+  remSongUpdateEntry();
+  setInterval(remSongUpdateEntry, 1000);
+}
+
+/** Tries to restore the time of the currently playing song */
+async function restoreSongTime() {
+  if(location.pathname.startsWith("/watch")) {
+    const { searchParams } = new URL(location.href);
+    const watchID = searchParams.get("v");
+    if(!watchID)
+      return;
+
+    const entry = remSongsCache.find(entry => entry.watchID === watchID);
+    if(entry) {
+      if(Date.now() - entry.updateTimestamp > remSongEntryExpiry) {
+        await delRemSongData(entry.watchID);
+        return;
+      }
+      else {
+        onSelectorOld<HTMLVideoElement>(videoSelector, {
+          listener: async (vidElem) => {
+            if(vidElem) {
+              const applyTime = async () => {
+                if(isNaN(entry.songTime))
+                  return;
+                vidElem.currentTime = clamp(Math.max(entry.songTime, 0), 0, vidElem.duration);
+                await delRemSongData(entry.watchID);
+                info(`Restored song time to ${Math.floor(entry.songTime / 60)}m, ${(entry.songTime % 60).toFixed(1)}s`, LogLevel.Info);
+              };
+
+              if(vidElem.readyState === 4)
+                applyTime();
+              else
+                vidElem.addEventListener("canplay", applyTime, { once: true });
+            }
+          },
+        });
+      }
+    }
+  }
+}
+
+/** Updates the currently playing song's entry in GM storage */
+async function remSongUpdateEntry() {
+  if(location.pathname.startsWith("/watch")) {
+    const { searchParams } = new URL(location.href);
+    const watchID = searchParams.get("v");
+    if(!watchID)
+      return;
+
+    const songTime = await getVideoTime() ?? 0;
+
+    const paused = document.querySelector<HTMLVideoElement>(videoSelector)?.paused ?? false;
+
+    // don't immediately update to reduce race conditions and only update if the video is playing
+    // also it just sounds better if the song starts at the beginning if only a couple seconds have passed
+    if(songTime > remSongMinPlayTime && !paused) {
+      const entry = {
+        watchID,
+        songTime,
+        updateTimestamp: Date.now(),
+      };
+      await setRemSongData(entry);
+    }
+    // if the song is rewound to the beginning, delete the entry
+    else {
+      const entry = remSongsCache.find(entry => entry.watchID === watchID);
+      if(entry && songTime <= remSongMinPlayTime)
+        await delRemSongData(entry.watchID);
+    }
+  }
+
+  const expiredEntries = remSongsCache.filter(entry => Date.now() - entry.updateTimestamp > remSongEntryExpiry);
+  for(const entry of expiredEntries)
+    await delRemSongData(entry.watchID);
+}
+
+/** Adds an entry or updates it if it already exists */
+async function setRemSongData(data: RemSongObj) {
+  const foundIdx = remSongsCache.findIndex(entry => entry.watchID === data.watchID);
+  if(foundIdx >= 0)
+    remSongsCache[foundIdx] = data;
+  else
+    remSongsCache.push(data);
+
+  await GM.setValue("bytm-rem-songs", JSON.stringify(remSongsCache));
+}
+
+/** Deletes an entry */
+async function delRemSongData(watchID: string) {
+  remSongsCache = [...remSongsCache.filter(entry => entry.watchID !== watchID)];
+  await GM.setValue("bytm-rem-songs", JSON.stringify(remSongsCache));
+}
+
+//#MARKER disable darkreader
+
+/** Disables Dark Reader if it is enabled */
+export function disableDarkReader() {
+  if(document.querySelector(".darkreader")) {
+    const metaElem = document.createElement("meta");
+    metaElem.name = "darkreader-lock";
+    metaElem.classList.add("bytm-disable-darkreader");
+    document.head.appendChild(metaElem);
+
+    info("Sent hint to Dark Reader to disable itself");
+  }
+}

+ 182 - 65
src/features/index.ts

@@ -1,42 +1,73 @@
-import { scriptInfo } from "../constants";
+import { t, tp } from "../translations";
+import { getPreferredLocale, resourceToHTMLString } from "../utils";
+import langMapping from "../../assets/locales.json" assert { type: "json" };
+import { remSongMinPlayTime } from "./behavior";
+import { FeatureInfo } from "../types";
 
-export * from "./input";
 export * from "./layout";
+export * from "./behavior";
+export * from "./input";
 export * from "./lyrics";
-export { initMenu } from "../menu/menu";
-export * from "../menu/menu_old";
+export * from "./songLists";
+export * from "./versionCheck";
+
+type SelectOption = { value: number | string, label: string };
 
-/** Union of all feature keys */
-export type FeatInfoKey = keyof typeof featInfo;
+//#MARKER feature dependencies
 
-/** Union of all feature categories */
-export type FeatureCategory = typeof featInfo[FeatInfoKey]["category"];
+const localeOptions = Object.entries(langMapping).reduce((a, [locale, { name }]) => {
+  return [...a, {
+    value: locale,
+    label: name,
+  }];
+}, [] as SelectOption[])
+  .sort((a, b) => a.label.localeCompare(b.label));
 
-/** Mapping of feature category identifiers to readable strings */
-export const categoryNames: Record<FeatureCategory, string> = {
-  input: "Input",
-  layout: "Layout",
-  lyrics: "Lyrics",
-  misc: "Other",
-} as const;
+//#MARKER features
 
-/** Contains all possible features with their default values and other configuration */
+/**
+ * Contains all possible features with their default values and other configuration.  
+ *   
+ * **Required props:**
+ * | Property | Description |
+ * | :-- | :-- |
+ * | `type`               | type of the feature configuration element - use autocomplete or check `FeatureTypeProps` in `src/types.ts` |
+ * | `category`           | category of the feature - use autocomplete or check `FeatureCategory` in `src/types.ts` |
+ * | `default`            | default value of the feature - type of the value depends on the given `type` |
+ * | `enable(value: any)` | function that will be called when the feature is enabled / initialized for the first time |
+ *   
+ * **Optional props:**
+ * | Property | Description |
+ * | :-- | :-- |
+ * | `disable(newValue: any)`                    | for type `toggle` only - function that will be called when the feature is disabled - can be a synchronous or asynchronous function |
+ * | `change(prevValue: any, newValue: any)`     | for types `number`, `select`, `slider` and `hotkey` only - function that will be called when the value is changed |
+ * | `helpText(): string / () => string`         | function that returns an HTML string or the literal string itself that will be the help text for this feature - writing as function is useful for pluralizing or inserting values into the translation at runtime - if not set, translation with key `feature_helptext_featureKey` will be used instead, if available |
+ * | `textAdornment(): string / Promise<string>` | function that returns an HTML string that will be appended to the text in the config menu as an adornment element - TODO: to be replaced in the big menu rework |
+ * | `hidden`                                    | if true, the feature will not be shown in the settings - default is undefined (false) |
+ * | `min`                                       | Only if type is `number` or `slider` - Overwrites the default of the `min` property of the HTML input element |
+ * | `max`                                       | Only if type is `number` or `slider` - Overwrites the default of the `max` property of the HTML input element |
+ * | `step`                                      | Only if type is `number` or `slider` - Overwrites the default of the `step` property of the HTML input element |
+ * | `unit`                                      | Only if type is `number` or `slider` - The unit text that is displayed next to the input element, i.e. "px" |
+ *   
+ * **Notes:**
+ * - If no `disable()` or `change()` function is present, the page needs to be reloaded for the changes to take effect
+ */
 export const featInfo = {
   //#SECTION layout
   removeUpgradeTab: {
-    desc: "Remove the Upgrade / Premium tab",
     type: "toggle",
     category: "layout",
     default: true,
+    enable: () => void "TODO",
   },
   volumeSliderLabel: {
-    desc: "Add a percentage label next to the volume slider",
     type: "toggle",
     category: "layout",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
   },
   volumeSliderSize: {
-    desc: "The width of the volume slider in pixels",
     type: "number",
     category: "layout",
     min: 50,
@@ -44,124 +75,210 @@ export const featInfo = {
     step: 5,
     default: 150,
     unit: "px",
+    enable: () => void "TODO",
+    change: () => void "TODO",
   },
   volumeSliderStep: {
-    desc: "Volume slider sensitivity (by how little percent the volume can be changed at a time)",
     type: "slider",
     category: "layout",
     min: 1,
     max: 25,
     default: 2,
     unit: "%",
+    enable: () => void "TODO",
+    change: () => void "TODO",
   },
-  watermarkEnabled: {
-    desc: `Show a ${scriptInfo.name} watermark under the site logo that opens this config menu`,
-    type: "toggle",
+  volumeSliderScrollStep: {
+    type: "slider",
     category: "layout",
-    default: true,
+    min: 1,
+    max: 25,
+    default: 10,
+    unit: "%",
+    enable: () => void "TODO",
+    change: () => void "TODO",
   },
-  deleteFromQueueButton: {
-    desc: "Add a button to each song in the queue to quickly remove it",
+  watermarkEnabled: {
     type: "toggle",
     category: "layout",
     default: true,
-  },
-  closeToastsTimeout: {
-    desc: "After how many seconds to close permanent notifications - 0 to only close them manually (default behavior)",
-    type: "number",
-    category: "layout",
-    min: 0,
-    max: 30,
-    step: 0.5,
-    default: 0,
-    unit: "s",
+    enable: () => void "TODO",
+    disable: () => void "TODO",
   },
   removeShareTrackingParam: {
-    desc: "Remove the tracking parameter (&si=...) from links in the share popup",
     type: "toggle",
     category: "layout",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
   },
   fixSpacing: {
-    desc: "Fix spacing issues in the layout",
     type: "toggle",
     category: "layout",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
   },
   scrollToActiveSongBtn: {
-    desc: "Add a button to the queue to scroll to the currently playing song",
     type: "toggle",
     category: "layout",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
+  },
+
+  //#SECTION song lists
+  lyricsQueueButton: {
+    type: "toggle",
+    category: "songLists",
+    default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
+  },
+  deleteFromQueueButton: {
+    type: "toggle",
+    category: "songLists",
+    default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
+  },
+  listButtonsPlacement: {
+    type: "select",
+    category: "songLists",
+    options: () => [
+      { value: "queueOnly", label: t("list_button_placement_queue_only") },
+      { value: "everywhere", label: t("list_button_placement_everywhere") },
+    ],
+    default: "everywhere",
+    enable: () => void "TODO",
+    disable: () => void "TODO",
+  },
+
+  //#SECTION behavior
+  disableBeforeUnloadPopup: {
+    type: "toggle",
+    category: "behavior",
+    default: false,
+    enable: () => void "TODO",
+  },
+  closeToastsTimeout: {
+    type: "number",
+    category: "behavior",
+    min: 0,
+    max: 30,
+    step: 0.5,
+    default: 0,
+    unit: "s",
+    enable: () => void "TODO",
+    change: () => void "TODO",
+  },
+  rememberSongTime: {
+    type: "toggle",
+    category: "behavior",
+    default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO", // TODO: feasible?
+    helpText: () => tp("feature_helptext_rememberSongTime", remSongMinPlayTime, remSongMinPlayTime)
+  },
+  rememberSongTimeSites: {
+    type: "select",
+    category: "behavior",
+    options: () => [
+      { value: "all", label: t("remember_song_time_sites_all") },
+      { value: "yt", label: t("remember_song_time_sites_yt") },
+      { value: "ytm", label: t("remember_song_time_sites_ytm") },
+    ],
+    default: "ytm",
+    enable: () => void "TODO",
+    change: () => void "TODO",
   },
 
   //#SECTION input
   arrowKeySupport: {
-    desc: "Use arrow keys to skip forwards and backwards by 10 seconds",
     type: "toggle",
     category: "input",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
+  },
+  arrowKeySkipBy: {
+    type: "number",
+    category: "input",
+    min: 0.5,
+    max: 60,
+    step: 0.5,
+    default: 5,
+    enable: () => void "TODO",
+    change: () => void "TODO",
   },
   switchBetweenSites: {
-    desc: "Add F9 as a hotkey to switch between the YT and YTM sites on a video / song",
     type: "toggle",
     category: "input",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
   },
   switchSitesHotkey: {
-    hidden: true,
-    desc: "TODO(v1.1): Which hotkey needs to be pressed to switch sites?",
     type: "hotkey",
     category: "input",
     default: {
-      key: "F9",
+      code: "F9",
       shift: false,
       ctrl: false,
-      meta: false,
+      alt: false,
     },
-  },
-  disableBeforeUnloadPopup: {
-    desc: "Prevent the confirmation popup that appears when trying to leave the site while a song is playing",
-    type: "toggle",
-    category: "input",
-    default: false,
+    enable: () => void "TODO",
+    change: () => void "TODO",
   },
   anchorImprovements: {
-    desc: "Add and improve links all over the page so things can be opened in a new tab easier",
     type: "toggle",
     category: "input",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
   },
   numKeysSkipToTime: {
-    desc: "Enable skipping to a specific time in the video by pressing a number key (0-9)",
     type: "toggle",
     category: "input",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
   },
 
   //#SECTION lyrics
   geniusLyrics: {
-    desc: "Add a button to the media controls of the currently playing song to open its lyrics on genius.com",
     type: "toggle",
     category: "lyrics",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
   },
-  lyricsQueueButton: {
-    desc: "Add a button to each song in the queue to quickly open its lyrics page",
+
+  //#SECTION general
+  locale: {
+    type: "select",
+    category: "general",
+    options: localeOptions,
+    default: getPreferredLocale(),
+    enable: () => void "TODO",
+    // TODO: to be reworked or removed in the big menu rework
+    textAdornment: async () => await resourceToHTMLString("img-globe") ?? "",
+  },
+  versionCheck: {
     type: "toggle",
-    category: "lyrics",
+    category: "general",
     default: true,
+    enable: () => void "TODO",
+    disable: () => void "TODO",
   },
-
-  //#SECTION misc
   logLevel: {
-    desc: "How much information to log to the console",
     type: "select",
-    category: "misc",
-    options: [
-      { value: 0, label: "Debug (most)" },
-      { value: 1, label: "Info (only important)" },
+    category: "general",
+    options: () => [
+      { value: 0, label: t("log_level_debug") },
+      { value: 1, label: t("log_level_info") },
     ],
     default: 1,
+    enable: () => void "TODO",
   },
-} as const;
+} as const satisfies FeatureInfo;

+ 49 - 179
src/features/input.ts

@@ -1,11 +1,20 @@
-import { getUnsafeWindow } from "@sv443-network/userutils";
-import { error, getVideoTime, info, log, warn } from "../utils";
-import type { Domain } from "../types";
-import { isMenuOpen } from "../menu/menu_old";
+import { clamp } from "@sv443-network/userutils";
+import { error, getVideoTime, info, log, warn, videoSelector } from "../utils";
+import type { Domain, FeatureConfig } from "../types";
+import { isCfgMenuOpen } from "../menu/menu_old";
+import { disableBeforeUnload } from "./behavior";
+import { siteEvents } from "../siteEvents";
+import { featInfo } from "./index";
+
+let features: FeatureConfig;
+
+export function preInitInput(feats: FeatureConfig) {
+  features = feats;
+}
 
 //#MARKER arrow key skip
 
-export function initArrowKeySkip() {
+export async function initArrowKeySkip() {
   document.addEventListener("keydown", (evt) => {
     if(!["ArrowLeft", "ArrowRight"].includes(evt.code))
       return;
@@ -13,91 +22,47 @@ export function initArrowKeySkip() {
     if(["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName ?? "_"))
       return info(`Captured valid key to skip forward or backward but the current active element is <${document.activeElement?.tagName.toLowerCase()}>, so the keypress is ignored`);
 
-    onArrowKeyPress(evt);
-  });
-  log("Added arrow key press listener");
-}
-
-/** Called when the user presses any key, anywhere */
-function onArrowKeyPress(evt: KeyboardEvent) {
-  log(`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,
-    // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
-    view: getUnsafeWindow(),
-  };
-
-  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) {
-    const proxyProps = { code: "", ...defaultProps, ...keyProps };
+    evt.preventDefault();
+    evt.stopImmediatePropagation();
 
-    document.body.dispatchEvent(new KeyboardEvent("keydown", proxyProps));
+    let skipBy = features.arrowKeySkipBy ?? featInfo.arrowKeySkipBy.default;
+    if(evt.code === "ArrowLeft")
+      skipBy *= -1;
 
-    log(`Dispatched proxy keydown event: [${evt.code}] -> [${proxyProps.code}]`);
-  }
-  else
-    warn(`Captured key '${evt.code}' has no defined behavior`);
+    log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`);
+    
+    const vidElem = document.querySelector<HTMLVideoElement>(videoSelector);
+    
+    if(vidElem)
+      vidElem.currentTime = clamp(vidElem.currentTime + skipBy, 0, vidElem.duration);
+  });
+  log("Added arrow key press listener");
 }
 
 //#MARKER site switch
 
 /** switch sites only if current video time is greater than this value */
 const videoTimeThreshold = 3;
+let siteSwitchEnabled = true;
 
 /** Initializes the site switch feature */
-export function initSiteSwitch(domain: Domain) {
+export async function initSiteSwitch(domain: Domain) {
   document.addEventListener("keydown", (e) => {
-    if(e.key === "F9")
+    const hotkey = features.switchSitesHotkey;
+    if(siteSwitchEnabled && e.code === hotkey.code && e.shiftKey === hotkey.shift && e.ctrlKey === hotkey.ctrl && e.altKey === hotkey.alt)
       switchSite(domain === "yt" ? "ytm" : "yt");
   });
+  siteEvents.on("hotkeyInputActive", (state) => {
+    siteSwitchEnabled = !state;
+  });
   log("Initialized site switch listener");
 }
 
 /** Switches to the other site (between YT and YTM) */
 async function switchSite(newDomain: Domain) {
   try {
-    if(newDomain === "ytm" && !location.href.includes("/watch"))
-      return warn("Not on a video page, so the site switch is ignored");
+    if(!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v))))
+      return warn("Not on a supported page, so the site switch is ignored");
 
     let subdomain;
     if(newDomain === "ytm")
@@ -125,8 +90,8 @@ async function switchSite(newDomain: Domain) {
         ? `${cleanSearch.startsWith("?")
           ? cleanSearch
           : "?" + cleanSearch
-        }&t=${vt - 1}`
-        : `?t=${vt - 1}`
+        }&t=${vt}`
+        : `?t=${vt}`
       : cleanSearch;
     const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`;
 
@@ -138,53 +103,14 @@ async function switchSite(newDomain: Domain) {
   }
 }
 
-//#MARKER beforeunload popup
-
-let beforeUnloadEnabled = true;
-
-/** Disables the popup before leaving the site */
-export function disableBeforeUnload() {
-  beforeUnloadEnabled = false;
-  info("Disabled popup before leaving the site");
-}
-
-/** (Re-)enables the popup before leaving the site */
-export function enableBeforeUnload() {
-  beforeUnloadEnabled = true;
-  info("Enabled popup before leaving the site");
-}
-
-/**
- * Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload` 
- * event listeners before they can be called by the site.
- */
-export function initBeforeUnloadHook() {
-  Error.stackTraceLimit = 1000; // default is 25 on FF so this should hopefully be more than enough
-
-  (function(original: typeof window.addEventListener) {
-    // @ts-ignore
-    window.__proto__.addEventListener = function(...args: Parameters<typeof window.addEventListener>) {
-      const origListener = typeof args[1] === "function" ? args[1] : args[1].handleEvent;
-      args[1] = function(...a) {
-        if(!beforeUnloadEnabled && args[0] === "beforeunload")
-          return info("Prevented beforeunload event listener from being called");
-        else
-          return origListener.apply(this, a);
-      };
-      original.apply(this, args);
-    };
-    // @ts-ignore
-  })(window.__proto__.addEventListener);
-}
-
 //#MARKER number keys skip to time
 
 /** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */
-export function initNumKeysSkip() {
+export async function initNumKeysSkip() {
   document.addEventListener("keydown", (e) => {
     if(!e.key.trim().match(/^[0-9]$/))
       return;
-    if(isMenuOpen)
+    if(isCfgMenuOpen)
       return;
     // discard the event when a (text) input is currently active, like when editing a playlist or when the search bar is focused
     if(
@@ -194,71 +120,15 @@ export function initNumKeysSkip() {
     )
       return info("Captured valid key to skip video to but an unexpected element is focused, so the keypress is ignored");
 
-    skipToTimeKey(Number(e.key));
-  });
-  log("Added number key press listener");
-}
-
-/** Returns the x position as a fraction of timeKey in maxWidth */
-function getX(timeKey: number, maxWidth: number) {
-  if(timeKey >= 10)
-    return maxWidth;
-  return Math.floor((maxWidth / 10) * timeKey);
-}
-
-/** Calculates DOM-relative offsets of the bounding client rect of the passed element - see https://stackoverflow.com/a/442474/11187044 */
-function getOffsetRect(elem: HTMLElement) {
-  let left = 0;
-  let top = 0;
-  const rect = elem.getBoundingClientRect();
-  while(elem && !isNaN(elem.offsetLeft) && !isNaN(elem.offsetTop)) {
-    left += elem.offsetLeft - elem.scrollLeft;
-    top += elem.offsetTop - elem.scrollTop;
-    elem = elem.offsetParent as HTMLElement;
-  }
-  return {
-    top,
-    left,
-    width: rect.width,
-    height: rect.height,
-  };
-}
-
-/** Emulates a click on the video progress bar at the position calculated from the passed time key (0-9) */
-function skipToTimeKey(key: number) {
-  // not technically a progress element but behaves pretty much the same
-  const progressElem = document.querySelector<HTMLProgressElement>("tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar");
-  if(!progressElem)
-    return;
-
-  const rect = getOffsetRect(progressElem);
+    const vidElem = document.querySelector<HTMLVideoElement>(videoSelector);
+    if(!vidElem)
+      return warn("Could not find video element, so the keypress is ignored");
 
-  const x = getX(key, rect.width);
-  const y = rect.top - rect.height / 2;
-
-  log(`Skipping to time key ${key} (x offset: ${x}px of ${rect.width}px)`);
-
-  const evt = new MouseEvent("mousedown", {
-    clientX: x,
-    clientY: Math.round(y),
-    // @ts-ignore
-    layerX: x,
-    layerY: Math.round(rect.height / 2),
-    target: progressElem,
-    bubbles: true,
-    shiftKey: false,
-    ctrlKey: false,
-    altKey: false,
-    metaKey: false,
-    button: 0,
-    buttons: 1,
-    which: 1,
-    isTrusted: true,
-    offsetX: 0,
-    offsetY: 0,
-    // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue
-    view: getUnsafeWindow(),
+    const newVidTime = vidElem.duration / (10 / Number(e.key));
+    if(!isNaN(newVidTime)) {
+      log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`);
+      vidElem.currentTime = newVidTime;
+    }
   });
-
-  progressElem.dispatchEvent(evt);
+  log("Added number key press listener");
 }

+ 39 - 49
src/features/layout.css

@@ -11,16 +11,37 @@
   position: relative;
   vertical-align: middle;
   cursor: pointer;
-
   margin-left: 8px;
-  width: 40px;
-  height: 40px;
+
+  width: 36px;
+  height: 36px;
+
+  border: 1px solid transparent;
   border-radius: 100%;
   background-color: transparent;
+
+  transition: background-color 0.2s ease;
 }
 
 .bytm-generic-btn:hover {
-  background-color: var(--yt-spec-10-percent-layer, #1d1d1d);
+  background-color: rgba(255, 255, 255, 0.2);
+}
+
+.bytm-generic-btn:active {
+  background-color: #5f5f5f;
+  animation: flashBorder 0.4s ease 1;
+}
+
+@keyframes flashBorder {
+  0% {
+    border: 1px solid transparent;
+  }
+  20% {
+    border: 1px solid #727272;
+  }
+  100% {
+    border: 1px solid transparent;
+  }
 }
 
 .bytm-generic-btn-img {
@@ -28,7 +49,6 @@
   z-index: 10;
   width: 24px;
   height: 24px;
-  padding: 5px;
 }
 
 .bytm-spinner {
@@ -99,6 +119,17 @@ button.bytm-btn {
   text-transform: revert;
   color: revert;
   background: revert;
+  line-height: 1.4em;
+}
+
+.bytm-link, .bytm-markdown-container a {
+  color: #369bff;
+  text-decoration: none;
+  cursor: pointer;
+}
+
+.bytm-link:hover, .bytm-markdown-container a:hover {
+  text-decoration: underline;
 }
 
 /* #MARKER menu */
@@ -151,7 +182,7 @@ yt-multi-page-menu-section-renderer.ytd-multi-page-menu-renderer {
   display: inline-block;
   position: absolute;
   left: 97px;
-  top: 46px;
+  top: 45px;
   z-index: 10;
   color: white;
   text-decoration: none;
@@ -162,54 +193,13 @@ yt-multi-page-menu-section-renderer.ytd-multi-page-menu-renderer {
   text-decoration: underline;
 }
 
-/* #MARKER queue buttons */
-
-.side-panel.modular ytmusic-player-queue-item .song-info.ytmusic-player-queue-item {
-  position: relative;
-}
-
-.side-panel.modular ytmusic-player-queue-item .bytm-queue-btn-container {
-  background: rgb(0, 0, 0);
-  background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 15%);
-  display: none;
-  position: absolute;
-  right: 0;
-  padding-left: 25px;
-  height: 100%;
-}
-
-.side-panel.modular ytmusic-player-queue-item:hover .bytm-queue-btn-container {
-  display: inline-block;
-}
-
-.side-panel.modular ytmusic-player-queue-item[play-button-state="loading"] .bytm-queue-btn-container,
-.side-panel.modular ytmusic-player-queue-item[play-button-state="playing"] .bytm-queue-btn-container,
-.side-panel.modular ytmusic-player-queue-item[play-button-state="paused"] .bytm-queue-btn-container {
-  /* using a var() with predefined value from YTM is not viable since the nesting changes the actual value of the variable */
-  background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(29, 29, 29, 1) 15%);
-}
-
-ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown[data-bytm-hidden=true] {
-  display: none !important;
-}
-
-/* #MARKER anchor improvements */
-
-ytmusic-responsive-list-item-renderer:not([unplayable_]) .left-items {
-  margin-right: 0 !important;
-}
-
-.bytm-carousel-shelf-anchor {
-  margin-right: var(--ytmusic-responsive-list-item-thumbnail-margin-right, 24px);
-}
-
 /* #MARKER volume slider */
 
 #bytm-vol-slider-cont {
   position: relative;
 }
 
-.bytm-vol-slider-label {
+#bytm-vol-slider-label {
   opacity: 0.000001;
   position: absolute;
   font-size: 15px;
@@ -220,7 +210,7 @@ ytmusic-responsive-list-item-renderer:not([unplayable_]) .left-items {
   transition: opacity 0.2s ease;
 }
 
-.bytm-vol-slider-label.bytm-visible {
+#bytm-vol-slider-label.bytm-visible {
   opacity: 1;
 }
 

+ 139 - 315
src/features/layout.ts

@@ -1,11 +1,10 @@
-import { addGlobalStyle, addParent, autoPlural, fetchAdvanced, insertAfter, onSelector, openInNewTab, pauseFor } from "@sv443-network/userutils";
+import { addGlobalStyle, addParent, autoPlural, fetchAdvanced, insertAfter, pauseFor } from "@sv443-network/userutils";
 import type { FeatureConfig } from "../types";
 import { scriptInfo } from "../constants";
-import { error, getResourceUrl, log, warn } from "../utils";
-import { SiteEventsMap, siteEvents } from "../events";
-import { openMenu } from "../menu/menu_old";
-import { getGeniusUrl, createLyricsBtn, sanitizeArtists, sanitizeSong, getLyricsCacheEntry, splitVideoTitle } from "./lyrics";
-import { featInfo } from "./index";
+import { error, getResourceUrl, log, onSelectorOld, warn } from "../utils";
+import { t } from "../translations";
+import { openCfgMenu } from "../menu/menu_old";
+import { featInfo } from ".";
 import "./layout.css";
 
 let features: FeatureConfig;
@@ -16,44 +15,33 @@ export function preInitLayout(feats: FeatureConfig) {
 
 //#MARKER BYTM-Config buttons
 
-let menuOpenAmt = 0, logoExchanged = false;
+let logoExchanged = false, improveLogoCalled = false;
 
 /** Adds a watermark beneath the logo */
-export function addWatermark() {
+export async function addWatermark() {
   const watermark = document.createElement("a");
   watermark.role = "button";
   watermark.id = "bytm-watermark";
   watermark.className = "style-scope ytmusic-nav-bar bytm-no-select";
   watermark.innerText = scriptInfo.name;
-  watermark.title = "Open menu";
-  watermark.tabIndex = 1000;
+  watermark.ariaLabel = watermark.title = t("open_menu_tooltip", scriptInfo.name);
+  watermark.tabIndex = 0;
 
   improveLogo();
 
-  watermark.addEventListener("click", (e) => {
+  const watermarkOpenMenu = (e: MouseEvent | KeyboardEvent) => {
     e.stopPropagation();
-    menuOpenAmt++;
 
-    if((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
-      openMenu();
-    if((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
+    if((!e.shiftKey && !e.ctrlKey) || logoExchanged)
+      openCfgMenu();
+    if(!logoExchanged && (e.shiftKey || e.ctrlKey))
       exchangeLogo();
-  });
-
-  // when using the tab key to navigate
-  watermark.addEventListener("keydown", (e) => {
-    if(e.key === "Enter") {
-      e.stopPropagation();
-      menuOpenAmt++;
+  };
 
-      if((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
-        openMenu();
-      if((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
-        exchangeLogo();
-    }
-  });
+  watermark.addEventListener("click", watermarkOpenMenu);
+  watermark.addEventListener("keydown", (e) => e.key === "Enter" && watermarkOpenMenu(e));
 
-  onSelector("ytmusic-nav-bar #left-content", {
+  onSelectorOld("ytmusic-nav-bar #left-content", {
     listener: (logoElem) => insertAfter(logoElem, watermark),
   });
 
@@ -61,12 +49,16 @@ export function addWatermark() {
 }
 
 /** Turns the regular `<img>`-based logo into inline SVG to be able to animate and modify parts of it */
-async function improveLogo() {
+export async function improveLogo() {
   try {
+    if(improveLogoCalled)
+      return;
+    improveLogoCalled = true;
+
     const res = await fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg");
     const svg = await res.text();
-    
-    onSelector("ytmusic-logo a", {
+
+    onSelectorOld("ytmusic-logo a", {
       listener: (logoElem) => {
         logoElem.classList.add("bytm-mod-logo", "bytm-no-select");
         logoElem.innerHTML = svg;
@@ -88,7 +80,7 @@ async function improveLogo() {
 
 /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */
 function exchangeLogo() {
-  onSelector(".bytm-mod-logo", {
+  onSelectorOld(".bytm-mod-logo", {
     listener: async (logoElem) => {
       if(logoElem.classList.contains("bytm-logo-exchanged"))
         return;
@@ -96,7 +88,7 @@ function exchangeLogo() {
       logoExchanged = true;
       logoElem.classList.add("bytm-logo-exchanged");
 
-      const iconUrl = await getResourceUrl("icon");
+      const iconUrl = await getResourceUrl("img-logo");
 
       const newLogo = document.createElement("img");
       newLogo.className = "bytm-mod-logo-img";
@@ -118,32 +110,34 @@ function exchangeLogo() {
 /** Called whenever the avatar popover menu exists to add a BYTM-Configuration button to the user menu popover */
 export async function addConfigMenuOption(container: HTMLElement) {
   const cfgOptElem = document.createElement("div");
-  cfgOptElem.role = "button";
   cfgOptElem.className = "bytm-cfg-menu-option";
   
   const cfgOptItemElem = document.createElement("div");
   cfgOptItemElem.className = "bytm-cfg-menu-option-item";
-  cfgOptItemElem.ariaLabel = cfgOptItemElem.title = "Click to open BetterYTM's configuration menu";
-  cfgOptItemElem.addEventListener("click", async (e) => {
+  cfgOptItemElem.role = "button";
+  cfgOptItemElem.tabIndex = 0;
+  cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name);
+  const cfgOptItemClicked = async (e: MouseEvent | KeyboardEvent) => {
     const settingsBtnElem = document.querySelector<HTMLElement>("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button");
     settingsBtnElem?.click();
-    menuOpenAmt++;
 
-    await pauseFor(100);
+    await pauseFor(20);
 
-    if((!e.shiftKey || logoExchanged) && menuOpenAmt !== 5)
-      openMenu();
-    if((!logoExchanged && e.shiftKey) || menuOpenAmt === 5)
+    if((!e.shiftKey && !e.ctrlKey) || logoExchanged)
+      openCfgMenu();
+    if(!logoExchanged && (e.shiftKey || e.ctrlKey))
       exchangeLogo();
-  });
+  };
+  cfgOptItemElem.addEventListener("click", cfgOptItemClicked);
+  cfgOptItemElem.addEventListener("keydown", (e) => e.key === "Enter" && cfgOptItemClicked(e));
 
   const cfgOptIconElem = document.createElement("img");
   cfgOptIconElem.className = "bytm-cfg-menu-option-icon";
-  cfgOptIconElem.src = await getResourceUrl("icon");
+  cfgOptIconElem.src = await getResourceUrl("img-logo");
 
   const cfgOptTextElem = document.createElement("div");
   cfgOptTextElem.className = "bytm-cfg-menu-option-text";
-  cfgOptTextElem.innerText = "BetterYTM Configuration";
+  cfgOptTextElem.innerText = t("config_menu_option", scriptInfo.name);
 
   cfgOptItemElem.appendChild(cfgOptIconElem);
   cfgOptItemElem.appendChild(cfgOptTextElem);
@@ -152,20 +146,22 @@ export async function addConfigMenuOption(container: HTMLElement) {
 
   container.appendChild(cfgOptElem);
 
+  improveLogo();
+
   log("Added BYTM-Configuration button to menu popover");
 }
 
 //#MARKER remove upgrade tab
 
 /** Removes the "Upgrade" / YT Music Premium tab from the sidebar */
-export function removeUpgradeTab() {
-  onSelector("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
+export async function removeUpgradeTab() {
+  onSelectorOld("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
     listener: (tabElemLarge) => {
       tabElemLarge.remove();
       log("Removed large upgrade tab");
     },
   });
-  onSelector("ytmusic-app-layout #mini-guide ytmusic-guide-renderer #sections ytmusic-guide-section-renderer[is-primary] #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
+  onSelectorOld("ytmusic-app-layout #mini-guide ytmusic-guide-renderer #sections ytmusic-guide-section-renderer[is-primary] #items ytmusic-guide-entry-renderer:nth-of-type(4)", {
     listener: (tabElemSmall) => {
       tabElemSmall.remove();
       log("Removed small upgrade tab");
@@ -175,13 +171,35 @@ export function removeUpgradeTab() {
 
 //#MARKER volume slider
 
-export function initVolumeFeatures() {
+export async function initVolumeFeatures() {
   // not technically an input element but behaves pretty much the same
-  onSelector<HTMLInputElement>("tp-yt-paper-slider#volume-slider", {
+  onSelectorOld<HTMLInputElement>("tp-yt-paper-slider#volume-slider", {
     listener: (sliderElem) => {
       const volSliderCont = document.createElement("div");
       volSliderCont.id = "bytm-vol-slider-cont";
 
+      if(features.volumeSliderScrollStep !== featInfo.volumeSliderScrollStep.default) {
+        for(const evtName of ["wheel", "scroll", "mousewheel", "DOMMouseScroll"]) {
+          volSliderCont.addEventListener(evtName, (e) => {
+            e.preventDefault();
+            // cancels all the other events that would be fired
+            e.stopImmediatePropagation();
+
+            const delta = (e as WheelEvent).deltaY ?? (e as CustomEvent<number | undefined>).detail ?? 1;
+            const volumeDir = -Math.sign(delta);
+            const newVolume = String(Number(sliderElem.value) + (features.volumeSliderScrollStep * volumeDir));
+
+            sliderElem.value = newVolume;
+            sliderElem.setAttribute("aria-valuenow", newVolume);
+            // make the site actually change the volume
+            sliderElem.dispatchEvent(new Event("change", { bubbles: true }));
+          }, {
+            // takes precedence over the slider's own event listener
+            capture: true,
+          });
+        }
+      }
+
       addParent(sliderElem, volSliderCont);
 
       if(typeof features.volumeSliderSize === "number")
@@ -196,42 +214,37 @@ export function initVolumeFeatures() {
 }
 
 /** Adds a percentage label to the volume slider and tooltip */
-function addVolumeSliderLabel(sliderElem: HTMLInputElement, sliderCont: HTMLDivElement) {
+function addVolumeSliderLabel(sliderElem: HTMLInputElement, sliderContainer: HTMLDivElement) {
   const labelElem = document.createElement("div");
-  labelElem.className = "bytm-vol-slider-label";
+  labelElem.id = "bytm-vol-slider-label";
   labelElem.innerText = `${sliderElem.value}%`;
 
   // prevent video from minimizing
   labelElem.addEventListener("click", (e) => e.stopPropagation());
 
-  const getLabelTexts = (slider: HTMLInputElement) => {
-    const labelShort = `${slider.value}%`;
-    const sensText = features.volumeSliderStep !== featInfo.volumeSliderStep.default ? ` (Sensitivity: ${slider.step}%)` : "";
-    const labelFull = `Volume: ${labelShort}${sensText}`;
+  const getLabelText = (slider: HTMLInputElement) =>
+    t("volume_tooltip", slider.value, features.volumeSliderStep ?? slider.step);
 
-    return { labelShort, labelFull };
-  };
-
-  const { labelFull } = getLabelTexts(sliderElem);
-  sliderCont.setAttribute("title", labelFull);
+  const labelFull = getLabelText(sliderElem);
+  sliderContainer.setAttribute("title", labelFull);
   sliderElem.setAttribute("title", labelFull);
   sliderElem.setAttribute("aria-valuetext", labelFull);
 
   const updateLabel = () => {
-    const { labelShort, labelFull } = getLabelTexts(sliderElem);
+    const labelFull = getLabelText(sliderElem);
 
-    sliderCont.setAttribute("title", labelFull);
+    sliderContainer.setAttribute("title", labelFull);
     sliderElem.setAttribute("title", labelFull);
     sliderElem.setAttribute("aria-valuetext", labelFull);
 
-    const labelElem2 = document.querySelector<HTMLDivElement>(".bytm-vol-slider-label");
+    const labelElem2 = document.querySelector<HTMLDivElement>("#bytm-vol-slider-label");
     if(labelElem2)
-      labelElem2.innerText = labelShort;
+      labelElem2.innerText = `${sliderElem.value}%`;
   };
 
   sliderElem.addEventListener("change", () => updateLabel());
 
-  onSelector("#bytm-vol-slider-cont", {
+  onSelectorOld("#bytm-vol-slider-cont", {
     listener: (volumeCont) => {
       volumeCont.appendChild(labelElem);
     },
@@ -275,187 +288,18 @@ function setVolSliderStep(sliderElem: HTMLInputElement) {
   sliderElem.setAttribute("step", String(features.volumeSliderStep));
 }
 
-//#MARKER queue buttons
-
-export function initQueueButtons() {
-  const addQueueBtns = (
-    evt: Parameters<SiteEventsMap["queueChanged" | "autoplayQueueChanged"]>[0],
-  ) => {
-    let amt = 0;
-    for(const queueItm of evt.childNodes as NodeListOf<HTMLElement>) {
-      if(!queueItm.classList.contains("bytm-has-queue-btns")) {
-        addQueueButtons(queueItm);
-        amt++;
-      }
-    }
-    if(amt > 0)
-      log(`Added buttons to ${amt} new queue ${autoPlural("item", amt)}`);
-  };
-
-  siteEvents.on("queueChanged", addQueueBtns);
-  siteEvents.on("autoplayQueueChanged", addQueueBtns);
-
-  const queueItems = document.querySelectorAll<HTMLElement>("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
-  if(queueItems.length === 0)
-    return;
-
-  queueItems.forEach(itm => addQueueButtons(itm));
-
-  log(`Added buttons to ${queueItems.length} existing queue ${autoPlural("item", queueItems)}`);
-}
-
-/**
- * Adds the buttons to each item in the current song queue.  
- * Also observes for changes to add new buttons to new items in the queue.
- * @param queueItem The element with tagname `ytmusic-player-queue-item` to add queue buttons to
- */
-async function addQueueButtons(queueItem: HTMLElement) {
-  //#SECTION general queue item stuff
-  const queueBtnsCont = document.createElement("div");
-  queueBtnsCont.className = "bytm-queue-btn-container";
-
-  const lyricsIconUrl = await getResourceUrl("lyrics");
-  const deleteIconUrl = await getResourceUrl("delete");
-
-  //#SECTION lyrics btn
-  let lyricsBtnElem: HTMLAnchorElement | undefined;
-
-  if(features.lyricsQueueButton) {
-    lyricsBtnElem = await createLyricsBtn(undefined, false);
-
-    lyricsBtnElem.title = "Open this song's lyrics in a new tab";
-    lyricsBtnElem.style.display = "inline-flex";
-    lyricsBtnElem.style.visibility = "initial";
-    lyricsBtnElem.style.pointerEvents = "initial";
-
-    lyricsBtnElem.addEventListener("click", async (e) => {
-      e.stopPropagation();
-
-      const songInfo = queueItem.querySelector<HTMLElement>(".song-info");
-      if(!songInfo)
-        return;
-
-      const [songEl, artistEl] = songInfo.querySelectorAll<HTMLElement>("yt-formatted-string");
-      const song = songEl?.innerText;
-      const artist = artistEl?.innerText;
-      if(!song || !artist)
-        return;
-
-      let lyricsUrl: string | undefined;
-
-      const artistsSan = sanitizeArtists(artist);
-      const songSan = sanitizeSong(song);
-      const splitTitle = splitVideoTitle(songSan);
-
-      const cachedLyricsUrl = songSan.includes("-")
-        ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
-        : getLyricsCacheEntry(artistsSan, songSan);
-
-      if(cachedLyricsUrl)
-        lyricsUrl = cachedLyricsUrl;
-      else if(!songInfo.hasAttribute("data-bytm-loading")) {
-        const imgEl = lyricsBtnElem?.querySelector<HTMLImageElement>("img");
-        if(!imgEl)
-          return;
-
-        if(!cachedLyricsUrl) {
-          songInfo.setAttribute("data-bytm-loading", "");
-
-          imgEl.src = await getResourceUrl("spinner");
-          imgEl.classList.add("bytm-spinner");
-        }
-
-        lyricsUrl = cachedLyricsUrl ?? await getGeniusUrl(artistsSan, songSan);
-
-        const resetImgElem = () => {
-          imgEl.src = lyricsIconUrl;
-          imgEl.classList.remove("bytm-spinner");
-        };
-
-        if(!cachedLyricsUrl) {
-          songInfo.removeAttribute("data-bytm-loading");
-
-          // so the new image doesn't "blink"
-          setTimeout(resetImgElem, 100);
-        }
-
-        if(!lyricsUrl) {
-          resetImgElem();
-          if(confirm("Couldn't find a lyrics page for this song.\nDo you want to open genius.com to manually search for it?"))
-            openInNewTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} ${songSan}`)}`);
-          return;
-        }
-      }
+//#MARKER anchor improvements
 
-      lyricsUrl && openInNewTab(lyricsUrl);
-    });
+/** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
+export async function addAnchorImprovements() {
+  try {
+    const css = await (await fetchAdvanced(await getResourceUrl("css-anchor_improvements"))).text();
+    css && addGlobalStyle(css);
   }
-
-  //#SECTION delete from queue btn
-  let deleteBtnElem: HTMLAnchorElement | undefined;
-
-  if(features.deleteFromQueueButton) {
-    deleteBtnElem = document.createElement("a");
-    Object.assign(deleteBtnElem, {
-      title: "Remove this song from the queue",
-      className: "ytmusic-player-bar bytm-delete-from-queue bytm-generic-btn",
-      role: "button",
-    });
-    deleteBtnElem.style.visibility = "initial";
-
-    deleteBtnElem.addEventListener("click", async (e) => {
-      e.stopPropagation();
-
-      // container of the queue item popup menu - element gets reused for every queue item
-      let queuePopupCont = document.querySelector<HTMLElement>("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
-      try {
-        // three dots button to open the popup menu of a queue item
-        const dotsBtnElem = queueItem.querySelector<HTMLButtonElement>("ytmusic-menu-renderer yt-button-shape button");
-
-        if(queuePopupCont)
-          queuePopupCont.setAttribute("data-bytm-hidden", "true");
-
-        dotsBtnElem?.click();
-        await pauseFor(20);
-
-        queuePopupCont = document.querySelector<HTMLElement>("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
-        queuePopupCont?.setAttribute("data-bytm-hidden", "true");
-
-        // a little bit janky and unreliable but the only way afaik
-        const removeFromQueueBtn = queuePopupCont?.querySelector<HTMLElement>("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)");
-
-        await pauseFor(10);
-
-        removeFromQueueBtn?.click();
-      }
-      catch(err) {
-        error("Couldn't remove song from queue due to error:", err);
-      }
-      finally {
-        queuePopupCont?.removeAttribute("data-bytm-hidden");
-      }
-    });
-
-    const imgElem = document.createElement("img");
-    imgElem.className = "bytm-generic-btn-img";
-    imgElem.src = deleteIconUrl;
-
-    deleteBtnElem.appendChild(imgElem);
+  catch(err) {
+    error("Couldn't add anchor improvements CSS due to an error:", err);
   }
 
-  //#SECTION append elements to DOM
-
-  lyricsBtnElem && queueBtnsCont.appendChild(lyricsBtnElem);
-  deleteBtnElem && queueBtnsCont.appendChild(deleteBtnElem);
-
-  queueItem.querySelector<HTMLElement>(".song-info")?.appendChild(queueBtnsCont);
-  queueItem.classList.add("bytm-has-queue-btns");
-}
-
-//#MARKER anchor improvements
-
-/** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */
-export function addAnchorImprovements() {
   //#SECTION carousel shelves
   try {
     const preventDefault = (e: MouseEvent) => e.preventDefault();
@@ -488,7 +332,7 @@ export function addAnchorImprovements() {
 
     // home page
 
-    onSelector<HTMLElement>("#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
+    onSelectorOld<HTMLElement>("#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", {
       continuous: true,
       all: true,
       listener: addListItemAnchors,
@@ -496,7 +340,7 @@ export function addAnchorImprovements() {
 
     // related tab in /watch
 
-    onSelector<HTMLElement>("ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
+    onSelectorOld<HTMLElement>("ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", {
       continuous: true,
       all: true,
       listener: addListItemAnchors,
@@ -504,7 +348,7 @@ export function addAnchorImprovements() {
 
     // playlists
 
-    onSelector<HTMLElement>("#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
+    onSelectorOld<HTMLElement>("#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", {
       continuous: true,
       all: true,
       listener: addListItemAnchors,
@@ -512,7 +356,7 @@ export function addAnchorImprovements() {
 
     // generic shelves
 
-    onSelector<HTMLElement>("#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
+    onSelectorOld<HTMLElement>("#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", {
       continuous: true,
       all: true,
       listener: addListItemAnchors,
@@ -531,14 +375,14 @@ export function addAnchorImprovements() {
       return items.length;
     };
 
-    onSelector("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
+    onSelectorOld("ytmusic-app-layout tp-yt-app-drawer #contentContainer #guide-content #items ytmusic-guide-entry-renderer", {
       listener: (sidebarCont) => {
         const itemsAmt = addSidebarAnchors(sidebarCont);
         log(`Added anchors around ${itemsAmt} sidebar ${autoPlural("item", itemsAmt)}`);
       },
     });
 
-    onSelector("ytmusic-app-layout #mini-guide ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
+    onSelectorOld("ytmusic-app-layout #mini-guide ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", {
       listener: (miniSidebarCont) => {
         const itemsAmt = addSidebarAnchors(miniSidebarCont);
         log(`Added anchors around ${itemsAmt} mini sidebar ${autoPlural("item", itemsAmt)}`);
@@ -567,7 +411,7 @@ function improveSidebarAnchors(sidebarItems: NodeListOf<HTMLElement>) {
     anchorElem.role = "button";
     anchorElem.target = "_self";
     anchorElem.href = sidebarPaths[i] ?? "#";
-    anchorElem.title = "Middle click to open in a new tab";
+    anchorElem.ariaLabel = anchorElem.title = t("middle_click_open_tab");
     anchorElem.addEventListener("click", (e) => {
       e.preventDefault();
     });
@@ -576,65 +420,44 @@ function improveSidebarAnchors(sidebarItems: NodeListOf<HTMLElement>) {
   });
 }
 
-//#MARKER auto close toasts
-
-/** Closes toasts after a set amount of time */
-export function initAutoCloseToasts() {
-  try {
-    const animTimeout = 300;
-    const closeTimeout = Math.max(features.closeToastsTimeout * 1000 + animTimeout, animTimeout);
-
-    onSelector("tp-yt-paper-toast#toast", {
-      all: true,
-      continuous: true,
-      listener: async (toastElems) => {
-        for(const toastElem of toastElems) {
-          if(!toastElem.hasAttribute("allow-click-through"))
-            continue;
-
-          if(toastElem.classList.contains("bytm-closing"))
-            continue;
-          toastElem.classList.add("bytm-closing");
-
-          await pauseFor(closeTimeout);
+//#MARKER remove share tracking param
 
-          toastElem.classList.remove("paper-toast-open");
-          log(`Automatically closed toast '${toastElem.querySelector<HTMLDivElement>("#text-container yt-formatted-string")?.innerText}' after ${features.closeToastsTimeout * 1000}ms`);
+let lastShareVal = "";
 
-          // wait for the transition to finish
-          await pauseFor(animTimeout);
+/** Removes the ?si tracking parameter from share URLs */
+export async function removeShareTrackingParam() {
+  const removeSiParam = (inputElem: HTMLInputElement) => {
+    try {
+      if(lastShareVal === inputElem.value)
+        return;
 
-          toastElem.style.display = "none";
-        }
-      },
-    });
+      const url = new URL(inputElem.value);
+      if(!url.searchParams.has("si"))
+        return;
 
-    log("Initialized automatic toast closing");
-  }
-  catch(err) {
-    error("Error in automatic toast closing:", err);
-  }
-}
+      lastShareVal = inputElem.value;
 
-//#MARKER remove share tracking param
+      url.searchParams.delete("si");
+      inputElem.value = String(url);
+      log(`Removed tracking parameter from share link: ${url}`);
+    }
+    catch(err) {
+      warn("Couldn't remove tracking parameter from share link due to error:", err);
+    }
+  };
 
-/** Continuously removes the ?si tracking parameter from share URLs */
-export function removeShareTrackingParam() {
-  onSelector<HTMLInputElement>("yt-copy-link-renderer input#share-url", {
-    continuous: true,
-    listener: (inputElem) => {
-      try {
-        const url = new URL(inputElem.value);
-        if(!url.searchParams.has("si"))
-          return;
+  onSelectorOld<HTMLInputElement>("tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", {
+    listener: (sharePanelEl) => {
+      const obs = new MutationObserver(() => {
+        const inputElem = sharePanelEl.querySelector<HTMLInputElement>("input#share-url");
+        inputElem && removeSiParam(inputElem);
+      });
 
-        url.searchParams.delete("si");
-        inputElem.value = String(url);
-        log(`Removed tracking parameter from share link: ${url}`);
-      }
-      catch(err) {
-        warn("Couldn't remove tracking parameter from share link due to error:", err);
-      }
+      obs.observe(sharePanelEl, {
+        childList: true,
+        subtree: true,
+        attributeFilter: ["aria-hidden", "checked"],
+      });
     },
   });
 }
@@ -642,20 +465,21 @@ export function removeShareTrackingParam() {
 //#MARKER fix margins
 
 /** Applies global CSS to fix various spacings */
-export function fixSpacing() {
-  addGlobalStyle(`\
-ytmusic-carousel-shelf-renderer ytmusic-carousel ytmusic-responsive-list-item-renderer {
-  margin-bottom: var(--ytmusic-carousel-item-margin-bottom, 16px) !important;
+export async function fixSpacing() {
+  try {
+    const css = await (await fetchAdvanced(await getResourceUrl("css-fix_spacing"))).text();
+    css && addGlobalStyle(css);
+  }
+  catch(err) {
+    error("Couldn't fix spacing due to an error:", err);
+  }
 }
 
-ytmusic-carousel-shelf-renderer ytmusic-carousel {
-  --ytmusic-carousel-item-height: 60px !important;
-}`);
-}
+//#MARKER scroll to active song
 
 /** Adds a button to the queue to scroll to the active song */
-export function addScrollToActiveBtn() {
-  onSelector(".side-panel.modular #tabsContent tp-yt-paper-tab:nth-of-type(1)", {
+export async function addScrollToActiveBtn() {
+  onSelectorOld("#side-panel #tabsContent tp-yt-paper-tab:nth-of-type(1)", {
     listener: async (tabElem) => {
       const containerElem = document.createElement("div");
       containerElem.id = "bytm-scroll-to-active-btn-cont";
@@ -663,15 +487,15 @@ export function addScrollToActiveBtn() {
       const linkElem = document.createElement("div");
       linkElem.id = "bytm-scroll-to-active-btn";
       linkElem.className = "ytmusic-player-bar bytm-generic-btn";
-      linkElem.title = "Click to scroll to the currently playing song";
+      linkElem.ariaLabel = linkElem.title = t("scroll_to_playing");
       linkElem.role = "button";
 
       const imgElem = document.createElement("img");
       imgElem.className = "bytm-generic-btn-img";
-      imgElem.src = await getResourceUrl("skip_to");
+      imgElem.src = await getResourceUrl("img-skip_to");
 
       linkElem.addEventListener("click", (e) => {
-        const activeItem = document.querySelector<HTMLElement>(".side-panel.modular .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], .side-panel.modular .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], .side-panel.modular .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
+        const activeItem = document.querySelector<HTMLElement>("#side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]");
         if(!activeItem)
           return;
 

+ 47 - 30
src/features/lyrics.ts

@@ -1,5 +1,8 @@
-import { clamp, fetchAdvanced, insertAfter, onSelector } from "@sv443-network/userutils";
-import { error, getResourceUrl, info, log, warn } from "../utils";
+import { clamp, fetchAdvanced, insertAfter } from "@sv443-network/userutils";
+import { constructUrlString, error, getResourceUrl, info, log, onSelectorOld, warn } from "../utils";
+import { t, tp } from "../translations";
+import { emitInterface } from "../interface";
+import { scriptInfo } from "../constants";
 
 /** Base URL of geniURL */
 export const geniUrlBase = "https://api.sv443.net/geniurl";
@@ -15,7 +18,7 @@ const threshold = 0.55;
 const geniUrlRatelimitTimeframe = 30;
 
 const thresholdParam = threshold ? `&threshold=${clamp(threshold, 0, 1)}` : "";
-void thresholdParam; // TODO: remove once geniURL 1.4 is released
+void thresholdParam; // TODO: re-add once geniURL 1.4 is released
 
 //#MARKER cache
 
@@ -45,8 +48,8 @@ export function addLyricsCacheEntry(artists: string, song: string, lyricsUrl: st
 let currentSongTitle = "";
 
 /** Adds a lyrics button to the media controls bar */
-export function addMediaCtrlLyricsBtn(): void {
-  onSelector(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", { listener: addActualMediaCtrlLyricsBtn });
+export async function addMediaCtrlLyricsBtn() {
+  onSelectorOld(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", { listener: addActualMediaCtrlLyricsBtn });
 }
 
 /** Actually adds the lyrics button after the like button renderer has been verified to exist */
@@ -70,9 +73,9 @@ async function addActualMediaCtrlLyricsBtn(likeContainer: HTMLElement) {
 
   currentSongTitle = songTitleElem.title;
 
-  const spinnerIconUrl = await getResourceUrl("spinner");
-  const lyricsIconUrl = await getResourceUrl("lyrics");
-  const errorIconUrl = await getResourceUrl("error");
+  const spinnerIconUrl = await getResourceUrl("img-spinner");
+  const lyricsIconUrl = await getResourceUrl("img-lyrics");
+  const errorIconUrl = await getResourceUrl("img-error");
 
   const onMutation = async (mutations: MutationRecord[]) => {
     for await(const mut of mutations) {
@@ -109,7 +112,7 @@ async function addActualMediaCtrlLyricsBtn(likeContainer: HTMLElement) {
           const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : "";
 
           imgElem.src = errorIconUrl;
-          imgElem.title = "Couldn't find lyrics URL - click to open the manual lyrics search";
+          imgElem.ariaLabel = imgElem.title = t("lyrics_not_found_click_open_search");
           lyricsBtn.style.cursor = "pointer";
           lyricsBtn.style.pointerEvents = "all";
           lyricsBtn.style.display = "inline-flex";
@@ -120,7 +123,7 @@ async function addActualMediaCtrlLyricsBtn(likeContainer: HTMLElement) {
 
         lyricsBtn.href = url;
 
-        lyricsBtn.title = "Open the current song's lyrics in a new tab";
+        lyricsBtn.ariaLabel = lyricsBtn.title = t("open_current_lyrics");
         lyricsBtn.style.cursor = "pointer";
         lyricsBtn.style.visibility = "visible";
         lyricsBtn.style.display = "inline-flex";
@@ -170,27 +173,34 @@ export async function getCurrentLyricsUrl() {
     const isVideo = typeof document.querySelector("ytmusic-player")?.hasAttribute("video-mode");
 
     const songTitleElem = document.querySelector<HTMLElement>(".content-info-wrapper > yt-formatted-string");
-    const songMetaElem = document.querySelector<HTMLElement>("span.subtitle > yt-formatted-string:first-child");
+    const songMetaElem = document.querySelector<HTMLElement>("span.subtitle > yt-formatted-string :first-child");
 
-    if(!songTitleElem || !songMetaElem || !songTitleElem.title)
+    if(!songTitleElem || !songMetaElem)
       return undefined;
 
     const songNameRaw = songTitleElem.title;
-    const songName = sanitizeSong(songNameRaw);
-
-    const artistName = sanitizeArtists(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, song } = splitVideoTitle(songName);
+    let songName = songNameRaw;
+    let artistName = songMetaElem.innerText;
+
+    if(isVideo) {
+      // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
+      if(songName.includes("-")) {
+        const split = splitVideoTitle(songName);
+        songName = split.song;
+        artistName = split.artist;
+      }
+    }
 
-      return await getGeniusUrl(artist, song);
-    };
+    const url = await fetchLyricsUrl(sanitizeArtists(artistName), sanitizeSong(songName));
 
-    const url = isVideo ? await getGeniusUrlVideo() : await getGeniusUrl(artistName, songName);
+    if(url) {
+      emitInterface("bytm:lyricsLoaded", {
+        type: "current",
+        artists: artistName,
+        title: songName,
+        url,
+      });
+    }
 
     return url;
   }
@@ -201,7 +211,7 @@ export async function getCurrentLyricsUrl() {
 }
 
 /** Fetches the actual lyrics URL from geniURL - **the passed parameters need to be sanitized first!** */
-export async function getGeniusUrl(artist: string, song: string): Promise<string | undefined> {
+export async function fetchLyricsUrl(artist: string, song: string): Promise<string | undefined> {
   try {
     const cacheEntry = getLyricsCacheEntry(artist, song);
     if(cacheEntry) {
@@ -210,13 +220,20 @@ export async function getGeniusUrl(artist: string, song: string): Promise<string
     }
 
     const startTs = Date.now();
-    const fetchUrl = `${geniURLSearchTopUrl}?disableFuzzy&artist=${encodeURIComponent(artist)}&song=${encodeURIComponent(song)}`;
+    const fetchUrl = constructUrlString(geniURLSearchTopUrl, {
+      disableFuzzy: null,
+      utm_source: "BetterYTM",
+      utm_content: `v${scriptInfo.version}`,
+      artist,
+      song,
+    });
 
     log(`Requesting URL from geniURL at '${fetchUrl}'`);
 
     const fetchRes = await fetchAdvanced(fetchUrl);
     if(fetchRes.status === 429) {
-      alert(`You are being rate limited.\nPlease wait ${fetchRes.headers.get("retry-after") ?? geniUrlRatelimitTimeframe} seconds before requesting more lyrics.`);
+      const waitSeconds = Number(fetchRes.headers.get("retry-after") ?? geniUrlRatelimitTimeframe);
+      alert(tp("lyrics_rate_limited", waitSeconds, waitSeconds));
       return undefined;
     }
     else if(fetchRes.status < 200 || fetchRes.status >= 300) {
@@ -247,7 +264,7 @@ export async function getGeniusUrl(artist: string, song: string): Promise<string
 export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true) {
   const linkElem = document.createElement("a");
   linkElem.className = "ytmusic-player-bar bytm-generic-btn";
-  linkElem.title = geniusUrl ? "Click to open this song's lyrics in a new tab" : "Loading lyrics URL...";
+  linkElem.ariaLabel = linkElem.title = geniusUrl ? t("open_lyrics") : t("lyrics_loading");
   if(geniusUrl)
     linkElem.href = geniusUrl;
   linkElem.role = "button";
@@ -258,7 +275,7 @@ export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true)
 
   const imgElem = document.createElement("img");
   imgElem.className = "bytm-generic-btn-img";
-  imgElem.src = await getResourceUrl("lyrics");
+  imgElem.src = await getResourceUrl("img-lyrics");
 
   linkElem.appendChild(imgElem);
 

+ 68 - 0
src/features/songLists.css

@@ -0,0 +1,68 @@
+/* #MARKER queue buttons */
+
+#side-panel ytmusic-player-queue-item .song-info.ytmusic-player-queue-item {
+  position: relative;
+}
+
+#side-panel ytmusic-player-queue-item .bytm-queue-btn-container {
+  background: rgb(0, 0, 0);
+  background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #030303 15%);
+  display: none;
+  position: absolute;
+  right: 0;
+  padding-left: 25px;
+  height: 100%;
+}
+
+#side-panel ytmusic-player-queue-item[selected] .bytm-queue-btn-container {
+  background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #1D1D1D 15%);
+}
+
+.bytm-generic-list-queue-btn-container {
+  /* otherwise the queue buttons render over the currently playing song page */
+  z-index: 1;
+}
+
+#side-panel ytmusic-player-queue-item:hover .bytm-queue-btn-container,
+ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer:hover .bytm-queue-btn-container,
+ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer:hover .bytm-queue-btn-container {
+  display: inline-block;
+}
+
+ytmusic-responsive-list-item-renderer .title-column {
+  align-items: center;
+}
+
+#side-panel ytmusic-player-queue-item[play-button-state="loading"] .bytm-queue-btn-container,
+#side-panel ytmusic-player-queue-item[play-button-state="playing"] .bytm-queue-btn-container,
+#side-panel ytmusic-player-queue-item[play-button-state="paused"] .bytm-queue-btn-container {
+  /* using a var() with predefined value from YTM is not viable since the nesting changes the actual value of the variable */
+  background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #030303 15%);
+}
+
+#side-panel ytmusic-player-queue-item[selected][play-button-state="loading"] .bytm-queue-btn-container,
+#side-panel ytmusic-player-queue-item[selected][play-button-state="playing"] .bytm-queue-btn-container,
+#side-panel ytmusic-player-queue-item[selected][play-button-state="paused"] .bytm-queue-btn-container {
+  /* using a var() with predefined value from YTM is not viable since the nesting changes the actual value of the variable */
+  background: linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, #1D1D1D 15%);
+}
+
+ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown[data-bytm-hidden=true] {
+  display: none !important;
+}
+
+ytmusic-responsive-list-item-renderer.bytm-has-queue-btns .bytm-generic-list-queue-btn-container {
+  visibility: hidden;
+}
+
+ytmusic-responsive-list-item-renderer.bytm-has-queue-btns .bytm-generic-list-queue-btn-container a.bytm-generic-btn {
+  visibility: hidden !important;
+}
+
+ytmusic-responsive-list-item-renderer.bytm-has-queue-btns:hover .bytm-generic-list-queue-btn-container {
+  visibility: visible;
+}
+
+ytmusic-responsive-list-item-renderer.bytm-has-queue-btns:hover .bytm-generic-list-queue-btn-container a.bytm-generic-btn {
+  visibility: visible !important;
+}

+ 289 - 0
src/features/songLists.ts

@@ -0,0 +1,289 @@
+import { autoPlural, openInNewTab, pauseFor } from "@sv443-network/userutils";
+import { clearInner, error, getResourceUrl, log, onSelectorOld, warn } from "../utils";
+import { t } from "../translations";
+import { SiteEventsMap, siteEvents } from "../siteEvents";
+import { emitInterface } from "../interface";
+import { fetchLyricsUrl, createLyricsBtn, sanitizeArtists, sanitizeSong, getLyricsCacheEntry, splitVideoTitle } from "./lyrics";
+import type { FeatureConfig } from "../types";
+import "./songLists.css";
+
+let features: FeatureConfig;
+
+export function preInitSongLists(feats: FeatureConfig) {
+  features = feats;
+}
+
+/** Initializes the queue buttons */
+export async function initQueueButtons() {
+  const addCurrentQueueBtns = (
+    evt: Parameters<SiteEventsMap["queueChanged" | "autoplayQueueChanged"]>[0],
+  ) => {
+    let amt = 0;
+    for(const queueItm of evt.childNodes as NodeListOf<HTMLElement>) {
+      if(!queueItm.classList.contains("bytm-has-queue-btns")) {
+        addQueueButtons(queueItm, undefined, "currentQueue");
+        amt++;
+      }
+    }
+    if(amt > 0)
+      log(`Added buttons to ${amt} new queue ${autoPlural("item", amt)}`);
+  };
+
+  // current queue
+
+  siteEvents.on("queueChanged", addCurrentQueueBtns);
+  siteEvents.on("autoplayQueueChanged", addCurrentQueueBtns);
+
+  const queueItems = document.querySelectorAll<HTMLElement>("#contents.ytmusic-player-queue > ytmusic-player-queue-item");
+  if(queueItems.length > 0) {
+    queueItems.forEach(itm => addQueueButtons(itm, undefined, "currentQueue"));
+    log(`Added buttons to ${queueItems.length} existing "current song queue" ${autoPlural("item", queueItems)}`);
+  }
+
+  // generic lists
+  // TODO:FIXME: dragging the items around removes the queue buttons
+
+  const addGenericListQueueBtns = (listElem: HTMLElement) => {
+    if(listElem.classList.contains("bytm-list-has-queue-btns"))
+      return;
+
+    const queueItems = listElem.querySelectorAll<HTMLElement>("ytmusic-responsive-list-item-renderer");
+    if(queueItems.length === 0)
+      return;
+
+    listElem.classList.add("bytm-list-has-queue-btns");
+    queueItems.forEach(itm => addQueueButtons(itm, ".flex-columns", "genericQueue", ["bytm-generic-list-queue-btn-container"]));
+
+    log(`Added buttons to ${queueItems.length} new "generic song list" ${autoPlural("item", queueItems)}`);
+  };
+
+  const listSelectors = [
+    "ytmusic-playlist-shelf-renderer #contents",
+    "ytmusic-section-list-renderer[main-page-type=\"MUSIC_PAGE_TYPE_ALBUM\"] ytmusic-shelf-renderer #contents",
+    "ytmusic-section-list-renderer[main-page-type=\"MUSIC_PAGE_TYPE_ARTIST\"] ytmusic-shelf-renderer #contents",
+  ];
+
+  if(features.listButtonsPlacement === "everywhere") {
+    for(const selector of listSelectors) {
+      onSelectorOld(selector, {
+        all: true,
+        continuous: true,
+        listener: (songLists) => {
+          for(const list of songLists)
+            addGenericListQueueBtns(list);
+        },
+      });
+    }
+  }
+
+  // TODO: support grids?
+}
+
+/**
+ * Adds the buttons to each item in the current song queue.  
+ * Also observes for changes to add new buttons to new items in the queue.
+ * @param queueItem The element with tagname `ytmusic-player-queue-item` to add queue buttons to
+ * @param listType The type of list the queue item is in
+ * @param classes Extra CSS classes to apply to the container
+ */
+async function addQueueButtons(
+  queueItem: HTMLElement,
+  containerParentSelector: string = ".song-info",
+  listType: "currentQueue" | "genericQueue" = "currentQueue",
+  classes: string[] = [],
+) {
+  //#SECTION general queue item stuff
+  const queueBtnsCont = document.createElement("div");
+  queueBtnsCont.classList.add("bytm-queue-btn-container", ...classes);
+
+  const lyricsIconUrl = await getResourceUrl("img-lyrics");
+  const deleteIconUrl = await getResourceUrl("img-delete");
+
+  //#SECTION lyrics btn
+  let lyricsBtnElem: HTMLAnchorElement | undefined;
+
+  if(features.lyricsQueueButton) {
+    lyricsBtnElem = await createLyricsBtn(undefined, false);
+
+    lyricsBtnElem.ariaLabel = lyricsBtnElem.title = t("open_lyrics");
+    lyricsBtnElem.style.display = "inline-flex";
+    lyricsBtnElem.style.visibility = "initial";
+    lyricsBtnElem.style.pointerEvents = "initial";
+    lyricsBtnElem.role = "link";
+    lyricsBtnElem.tabIndex = 0;
+
+    const lyricsBtnClicked = async (e: MouseEvent | KeyboardEvent) => {
+      e.preventDefault();
+      e.stopImmediatePropagation();
+
+      let song: string | undefined,
+        artist: string | undefined;
+
+      if(listType === "currentQueue") {
+        const songInfo = queueItem.querySelector<HTMLElement>(".song-info");
+        if(!songInfo)
+          return;
+      
+        const [songEl, artistEl] = songInfo.querySelectorAll<HTMLElement>("yt-formatted-string");
+        song = songEl?.innerText;
+        artist = artistEl?.innerText;
+      }
+      else if(listType === "genericQueue") {
+        const songEl = queueItem.querySelector<HTMLElement>(".title-column yt-formatted-string a");
+        let artistEl: HTMLElement | null = null;
+
+        if(location.pathname.startsWith("/playlist"))
+          artistEl = document.querySelector<HTMLElement>("ytmusic-detail-header-renderer .metadata .subtitle-container yt-formatted-string a");
+        else
+          artistEl = queueItem.querySelector<HTMLElement>(".secondary-flex-columns yt-formatted-string:first-child a");
+
+        song = songEl?.innerText;
+        artist = artistEl?.innerText;
+      }
+      else return;
+
+      if(!song || !artist)
+        return error("Couldn't get song or artist name from queue item - song:", song, "- artist:", artist);
+
+      let lyricsUrl: string | undefined;
+
+      const artistsSan = sanitizeArtists(artist);
+      const songSan = sanitizeSong(song);
+      const splitTitle = splitVideoTitle(songSan);
+
+      const cachedLyricsUrl = songSan.includes("-")
+        ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song)
+        : getLyricsCacheEntry(artistsSan, songSan);
+
+      if(cachedLyricsUrl)
+        lyricsUrl = cachedLyricsUrl;
+      else if(!queueItem.hasAttribute("data-bytm-loading")) {
+        const imgEl = lyricsBtnElem?.querySelector<HTMLImageElement>("img");
+        if(!imgEl)
+          return;
+
+        if(!cachedLyricsUrl) {
+          queueItem.setAttribute("data-bytm-loading", "");
+
+          imgEl.src = await getResourceUrl("img-spinner");
+          imgEl.classList.add("bytm-spinner");
+        }
+
+        lyricsUrl = cachedLyricsUrl ?? await fetchLyricsUrl(artistsSan, songSan);
+
+        if(lyricsUrl) {
+          emitInterface("bytm:lyricsLoaded", {
+            type: "queue",
+            artists: artist,
+            title: song,
+            url: lyricsUrl,
+          });
+        }
+
+        const resetImgElem = () => {
+          imgEl.src = lyricsIconUrl;
+          imgEl.classList.remove("bytm-spinner");
+        };
+
+        if(!cachedLyricsUrl) {
+          queueItem.removeAttribute("data-bytm-loading");
+
+          // so the new image doesn't "blink"
+          setTimeout(resetImgElem, 100);
+        }
+
+        if(!lyricsUrl) {
+          resetImgElem();
+          if(confirm(t("lyrics_not_found_confirm_open_search")))
+            openInNewTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} - ${songSan}`)}`);
+          return;
+        }
+      }
+
+      lyricsUrl && openInNewTab(lyricsUrl);
+    };
+
+    lyricsBtnElem.addEventListener("click", lyricsBtnClicked);
+    lyricsBtnElem.addEventListener("keydown", (e) => e.key === "Enter" && lyricsBtnClicked(e));
+  }
+
+  //#SECTION delete from queue btn
+  let deleteBtnElem: HTMLAnchorElement | undefined;
+
+  if(features.deleteFromQueueButton) {
+    deleteBtnElem = document.createElement("a");
+    deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("remove_from_queue") : t("delete_from_list"));
+    deleteBtnElem.classList.add("ytmusic-player-bar", "bytm-delete-from-queue", "bytm-generic-btn");
+    deleteBtnElem.role = "button";
+    deleteBtnElem.tabIndex = 0;
+    deleteBtnElem.style.visibility = "initial";
+
+    const imgElem = document.createElement("img");
+    imgElem.classList.add("bytm-generic-btn-img");
+    imgElem.src = deleteIconUrl;
+
+    const deleteBtnClicked = async (e: MouseEvent | KeyboardEvent) => {
+      e.preventDefault();
+      e.stopImmediatePropagation();
+
+      // container of the queue item popup menu - element gets reused for every queue item
+      let queuePopupCont = document.querySelector<HTMLElement>("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
+      try {
+        // three dots button to open the popup menu of a queue item
+        const dotsBtnElem = queueItem.querySelector<HTMLButtonElement>("ytmusic-menu-renderer yt-button-shape[id=\"button-shape\"] button");
+
+        if(dotsBtnElem) {
+          if(queuePopupCont)
+            queuePopupCont.setAttribute("data-bytm-hidden", "true");
+
+          dotsBtnElem.click();
+          await pauseFor(10);
+
+          queuePopupCont = document.querySelector<HTMLElement>("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown");
+          queuePopupCont?.setAttribute("data-bytm-hidden", "true");
+
+          // a little bit janky and unreliable but the only way afaik
+          const removeFromQueueBtn = queuePopupCont?.querySelector<HTMLElement>("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)");
+
+          await pauseFor(10);
+
+          removeFromQueueBtn?.click();
+
+          // queue items aren't removed automatically outside of the current queue
+          if(removeFromQueueBtn && listType === "genericQueue") {
+            await pauseFor(500);
+            clearInner(queueItem);
+            queueItem.remove();
+          }
+
+          if(!removeFromQueueBtn) {
+            warn("Couldn't find 'remove from queue' button in queue item three dots menu");
+            dotsBtnElem.click();
+            imgElem.src = await getResourceUrl("img-error");
+            if(deleteBtnElem)
+              deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("couldnt_remove_from_queue") : t("couldnt_delete_from_list"));
+          }
+        }
+      }
+      catch(err) {
+        error("Couldn't remove song from queue due to error:", err);
+      }
+      finally {
+        queuePopupCont?.removeAttribute("data-bytm-hidden");
+      }
+    };
+
+    deleteBtnElem.addEventListener("click", deleteBtnClicked);
+    deleteBtnElem.addEventListener("keydown", (e) => e.key === "Enter" && deleteBtnClicked(e));
+
+    deleteBtnElem.appendChild(imgElem);
+  }
+
+  //#SECTION append elements to DOM
+
+  lyricsBtnElem && queueBtnsCont.appendChild(lyricsBtnElem);
+  deleteBtnElem && queueBtnsCont.appendChild(deleteBtnElem);
+
+  queueItem.querySelector<HTMLElement>(containerParentSelector)?.appendChild(queueBtnsCont);
+  queueItem.classList.add("bytm-has-queue-btns");
+}

+ 95 - 0
src/features/versionCheck.tsx

@@ -0,0 +1,95 @@
+import { t } from "../translations";
+import { scriptInfo, host } from "../constants";
+import { getFeatures } from "../config";
+import { error, info, sendRequest } from "../utils";
+import pkg from "../../package.json" assert { type: "json" };
+// import { BytmMenu } from "src/menu/new/BytmMenu";
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import React from "react";
+
+const releaseURL = "https://github.com/Sv443/BetterYTM/releases/latest";
+
+export async function checkVersion() {
+  try {
+    if(getFeatures().versionCheck === false)
+      return info("Version check is disabled");
+
+    const lastCheck = await GM.getValue("bytm-version-check", 0);
+    if(Date.now() - lastCheck < 1000 * 60 * 60 * 24)
+      return;
+
+    await GM.setValue("bytm-version-check", Date.now());
+
+    const res = await sendRequest({
+      method: "GET",
+      url: releaseURL,
+    });
+
+    const latestTag = res.finalUrl.split("/").pop()?.replace(/[a-zA-Z]/g, "");
+
+    if(!latestTag)
+      return;
+
+    const versionComp = compareVersions(scriptInfo.version, latestTag);
+
+    info("Version check - current version:", scriptInfo.version, "- latest version:", latestTag);
+
+    if(versionComp < 0) {
+      const platformNames: Record<typeof host, string> = {
+        github: "GitHub",
+        greasyfork: "GreasyFork",
+        openuserjs: "OpenUserJS",
+      };
+
+      // const menu = new BytmMenu({
+      //   id: "version-check",
+      //   closeOnBgClick: false,
+      //   closeOnEscPress: false,
+      //   renderBody() {
+      //     return (
+      //       <div>
+      //         <p>
+      //           {t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, platformNames[host])}
+      //         </p>
+      //         <button
+      //           className="bytm-btn"
+      //           onClick={() => window.open(pkg.updates[host])}
+      //         >
+      //           {t("update_now")}
+      //         </button>
+      //       </div>
+      //     );
+      //   },
+      // });
+
+      // menu.on("close", () => menu.destroy());
+
+      // await menu.open();
+
+      // TODO: replace with custom dialog
+      if(confirm(t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, platformNames[host])))
+        window.open(pkg.updates[host]);
+    }
+  }
+  catch(err) {
+    error("Version check failed:", err);
+  }
+}
+
+/**
+ * Crudely compares two semver version strings.  
+ * @returns Returns 1 if a > b or -1 if a < b or 0 if a == b
+ */
+function compareVersions(a: string, b: string) {
+  const pa = a.split(".");
+  const pb = b.split(".");
+  for(let i = 0; i < 3; i++) {
+    const na = Number(pa[i]);
+    const nb = Number(pb[i]);
+    if(na > nb) return 1;
+    if(nb > na) return -1;
+    if(!isNaN(na) && isNaN(nb)) return 1;
+    if(isNaN(na) && !isNaN(nb)) return -1;
+  }
+  return 0;
+}

+ 198 - 54
src/index.ts

@@ -1,23 +1,43 @@
-import { addGlobalStyle, initOnSelector, onSelector } from "@sv443-network/userutils";
+import { addGlobalStyle } from "@sv443-network/userutils";
+import { initOnSelector } from "./utils";
 import { clearConfig, getFeatures, initConfig } from "./config";
 import { defaultLogLevel, mode, scriptInfo } from "./constants";
-import { error, getDomain, log, setLogLevel } from "./utils";
-import { initSiteEvents } from "./events";
+import { error, getDomain, info, getSessionId, log, setLogLevel } from "./utils";
+import { initSiteEvents, siteEvents } from "./siteEvents";
+import { initTranslations, setLocale } from "./translations";
+import { emitInterface, initInterface } from "./interface";
+import { addCfgMenu } from "./menu/menu_old";
+import { addWelcomeMenu, showWelcomeMenu } from "./menu/welcomeMenu";
+import { initObservers, observers } from "./observers";
 import {
+  // other:
+  featInfo,
+
+  // features:
   // layout
-  initQueueButtons, addWatermark,
-  preInitLayout, removeUpgradeTab,
-  initVolumeFeatures, initAutoCloseToasts,
+  preInitLayout,
+  addWatermark,
+  removeUpgradeTab, initVolumeFeatures,
   removeShareTrackingParam, fixSpacing,
   addScrollToActiveBtn,
-  // lyrics
-  addMediaCtrlLyricsBtn, geniUrlBase,
+  // song lists
+  preInitSongLists,
+  initQueueButtons,
+  // behavior
+  preInitBehavior,
+  initBeforeUnloadHook, disableBeforeUnload,
+  initAutoCloseToasts, initRememberSongTime,
+  disableDarkReader,
   // input
+  preInitInput,
   initArrowKeySkip, initSiteSwitch,
-  initBeforeUnloadHook, disableBeforeUnload,
   addAnchorImprovements, initNumKeysSkip,
+  // lyrics
+  addMediaCtrlLyricsBtn, geniUrlBase,
   // menu
-  addMenu, addConfigMenuOption,
+  addConfigMenuOption,
+  // other
+  checkVersion,
 } from "./features/index";
 
 {
@@ -27,25 +47,29 @@ import {
 
   console.log();
   console.log(
-    `%c${scriptInfo.name}%cv${scriptInfo.version}%c\n\nBuild #${scriptInfo.buildNumber} ─ ${scriptInfo.namespace}`,
+    `%c${scriptInfo.name}%cv${scriptInfo.version}%c\n\nBuild ${scriptInfo.buildNumber} ─ ${scriptInfo.namespace}`,
     `font-weight: bold; ${styleCommon} ${styleGradient}`,
     `background-color: #333; ${styleCommon}`,
     "padding: initial;",
   );
   console.log([
     "Powered by:",
-    "─ lots of ambition",
-    `─ my song metadata API: ${geniUrlBase}`,
-    "─ my userscript utility library: https://github.com/Sv443-Network/UserUtils",
-    "─ this tiny event listener library: https://github.com/ai/nanoevents",
+    "─ Lots of ambition",
+    `─ My song metadata API: ${geniUrlBase}`,
+    "─ My userscript utility library: https://github.com/Sv443-Network/UserUtils",
+    "─ This tiny event listener library: https://github.com/ai/nanoevents",
+    "─ The React library: https://github.com/facebook/react",
   ].join("\n"));
   console.log();
 }
 
+let domLoaded = false;
 const domain = getDomain();
 
 /** Stuff that needs to be called ASAP, before anything async happens */
 function preInit() {
+  log("Session ID:", getSessionId());
+  initInterface();
   setLogLevel(defaultLogLevel);
 
   if(domain === "ytm")
@@ -62,32 +86,41 @@ async function init() {
     void e;
   }
 
-  // init DOM-dependant stuff like features
   try {
-    document.addEventListener("DOMContentLoaded", onDomLoad);
-  }
-  catch(err) {
-    error("General Error:", err);
-  }
+    document.addEventListener("DOMContentLoaded", () => {
+      domLoaded = true;
+    });
 
-  // init config
-  try {
-    const ftConfig = await initConfig();
+    const features = await initConfig();
+
+    await initTranslations(features.locale ?? "en_US");
+    setLocale(features.locale ?? "en_US");
 
-    setLogLevel(getFeatures().logLevel);
+    setLogLevel(features.logLevel);
 
-    preInitLayout(ftConfig);
+    preInitLayout(features);
+    preInitBehavior(features);
+    preInitInput(features);
+    preInitSongLists(features);
 
-    if(getFeatures().disableBeforeUnloadPopup)
+    if(features.disableBeforeUnloadPopup && domain === "ytm")
       disableBeforeUnload();
+
+    if(!domLoaded)
+      document.addEventListener("DOMContentLoaded", onDomLoad);
+    else
+      onDomLoad();
+
+    if(features.rememberSongTime)
+      initRememberSongTime();
   }
   catch(err) {
-    error("Error while initializing ConfigManager:", err);
+    error("General Error:", err);
   }
 
   // init menu separately from features
   try {
-    void "TODO(v1.1):";
+    void "TODO(v1.2):";
     // initMenu();
   }
   catch(err) {
@@ -97,95 +130,169 @@ async function init() {
 
 /** Called when the DOM has finished loading and can be queried and altered by the userscript */
 async function onDomLoad() {
-  // post-build these double quotes are replaced by backticks (because if backticks are used here, webpack converts them to double quotes)
-  addGlobalStyle("{{GLOBAL_STYLE}}");
+  // post-build these double quotes are replaced by backticks (because if backticks are used here, the bundler converts them to double quotes)
+  addGlobalStyle("#{{GLOBAL_STYLE}}");
 
+  initObservers();
   initOnSelector();
 
   const features = getFeatures();
+  const ftInit = [] as Promise<void>[];
+
+  await checkVersion();
 
-  log(`Initializing features for domain "${domain}"...`);
+  log(`DOM loaded. Initializing features for domain "${domain}"...`);
 
   try {
     if(domain === "ytm") {
+      disableDarkReader();
+
+      ftInit.push(initSiteEvents());
+
+      if(typeof await GM.getValue("bytm-installed") !== "string") {
+        // open welcome menu with language selector
+        await addWelcomeMenu();
+        info("Showing welcome menu");
+        await showWelcomeMenu();
+        await GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version }));
+      }
+
       try {
-        addMenu(); // TODO(v1.1): remove
+        ftInit.push(addCfgMenu()); // TODO(v1.2): remove
       }
       catch(err) {
         error("Couldn't add menu:", err);
       }
 
-      initSiteEvents();
-
-      onSelector("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", { listener: addConfigMenuOption });
+      observers.body.addListener("tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", {
+        listener: addConfigMenuOption,
+      });
 
       if(features.arrowKeySupport)
-        initArrowKeySkip();
+        ftInit.push(initArrowKeySkip());
 
       if(features.removeUpgradeTab)
-        removeUpgradeTab();
+        ftInit.push(removeUpgradeTab());
 
       if(features.watermarkEnabled)
-        addWatermark();
+        ftInit.push(addWatermark());
 
       if(features.geniusLyrics)
-        addMediaCtrlLyricsBtn();
+        ftInit.push(addMediaCtrlLyricsBtn());
 
       if(features.deleteFromQueueButton || features.lyricsQueueButton)
-        initQueueButtons();
+        ftInit.push(initQueueButtons());
 
       if(features.anchorImprovements)
-        addAnchorImprovements();
+        ftInit.push(addAnchorImprovements());
 
       if(features.closeToastsTimeout > 0)
-        initAutoCloseToasts();
+        ftInit.push(initAutoCloseToasts());
 
       if(features.removeShareTrackingParam)
-        removeShareTrackingParam();
+        ftInit.push(removeShareTrackingParam());
 
       if(features.numKeysSkipToTime)
-        initNumKeysSkip();
+        ftInit.push(initNumKeysSkip());
 
       if(features.fixSpacing)
-        fixSpacing();
+        ftInit.push(fixSpacing());
 
       if(features.scrollToActiveSongBtn)
-        addScrollToActiveBtn();
+        ftInit.push(addScrollToActiveBtn());
 
-      initVolumeFeatures();
+      ftInit.push(initVolumeFeatures());
     }
 
     if(["ytm", "yt"].includes(domain)) {
       if(features.switchBetweenSites)
-        initSiteSwitch(domain);
+        ftInit.push(initSiteSwitch(domain));
     }
+
+    Promise.allSettled(ftInit).then(() => {
+      emitInterface("bytm:ready");
+    });
   }
   catch(err) {
     error("Feature error:", err);
   }
 }
 
+void ["TODO:", initFeatures];
+async function initFeatures() {
+  const ftInit = [] as Promise<void>[];
+
+  log(`DOM loaded. Initializing features for domain "${domain}"...`);
+
+  for(const [ftKey, ftInfo] of Object.entries(featInfo)) {
+    try {
+      const res = ftInfo.enable() as void | Promise<void>;
+      if(res instanceof Promise)
+        ftInit.push(res);
+      else
+        ftInit.push(Promise.resolve());
+    }
+    catch(err) {
+      error(`Couldn't initialize feature "${ftKey}" due to error:`, err);
+    }
+  }
+
+  siteEvents.on("configOptionChanged", (ftKey, oldValue, newValue) => {
+    try {
+      // @ts-ignore
+      if(featInfo[ftKey].change) {
+        // @ts-ignore
+        featInfo[ftKey].change(oldValue, newValue);
+      }
+      // @ts-ignore
+      else if(featInfo[ftKey].disable) {
+        // @ts-ignore
+        const disableRes = featInfo[ftKey].disable();
+        if(disableRes instanceof Promise)
+          disableRes.then(() => featInfo[ftKey].enable());
+        else
+          featInfo[ftKey].enable();
+      }
+      else {
+        // TODO: set "page reload required" flag in new menu
+        if(confirm("[Work in progress]\nYou changed an option that requires a page reload to be applied.\nReload the page now?")) {
+          disableBeforeUnload();
+          location.reload();
+        }
+      }
+    }
+    catch(err) {
+      error(`Couldn't change feature "${ftKey}" due to error:`, err);
+    }
+  });
+
+  Promise.all(ftInit).then(() => {
+    emitInterface("bytm:ready");
+  });
+}
+
 function registerMenuCommands() {
   if(mode === "development") {
     GM.registerMenuCommand("Reset config", async () => {
-      if(confirm("Are you sure you want to reset the configuration to its default values?\nThis will automatically reload the page.")) {
+      if(confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) {
         await clearConfig();
+        disableBeforeUnload();
         location.reload();
       }
     }, "r");
 
     GM.registerMenuCommand("List GM values", async () => {
-      alert("See console.");
       const keys = await GM.listValues();
       console.log("GM values:");
       if(keys.length === 0)
         console.log("  No values found.");
       for(const key of keys)
         console.log(`  ${key} -> ${await GM.getValue(key)}`);
+      alert("See console.");
     }, "l");
 
-    GM.registerMenuCommand("Clear all GM values", async () => {
-      if(confirm("Are you sure you want to clear all GM values?")) {
+    GM.registerMenuCommand("Delete all GM values", async () => {
+      if(confirm("Clear all GM values?\nSee console for details.")) {
         const keys = await GM.listValues();
         console.log("Clearing GM values:");
         if(keys.length === 0)
@@ -195,7 +302,44 @@ function registerMenuCommands() {
           console.log(`  Deleted ${key}`);
         }
       }
-    }, "c");
+    }, "d");
+
+    GM.registerMenuCommand("Delete GM value by name", async () => {
+      const key = prompt("Enter the name of the GM value to delete.\nEmpty input cancels the operation.");
+      if(key && key.length > 0) {
+        const oldVal = await GM.getValue(key);
+        await GM.deleteValue(key);
+        console.log(`Deleted GM value '${key}' with previous value '${oldVal}'`);
+      }
+    }, "n");
+
+    GM.registerMenuCommand("Reset install timestamp", async () => {
+      await GM.deleteValue("bytm-installed");
+      console.log("Reset install time.");
+    }, "t");
+
+    GM.registerMenuCommand("Reset version check timestamp", async () => {
+      await GM.deleteValue("bytm-version-check");
+      console.log("Reset version check time.");
+    }, "v");
+
+    GM.registerMenuCommand("List active selector listeners", async () => {
+      const lines = [] as string[];
+      let listenersAmt = 0;
+      for(const [obsName, obs] of Object.entries(observers)) {
+        const listeners = obs.getAllListeners();
+        lines.push(`- "${obsName}" (${listeners.size} listeners):`);
+        [...listeners].forEach(([k, v]) => {
+          listenersAmt += v.length;
+          lines.push(`    [${v.length}] ${k}`);
+          v.forEach(({ all, continuous }, i) => {
+            lines.push(`        ${v.length > 1 && i !== v.length - 1 ? "├" : "└"}> ${continuous ? "continuous" : "single-shot"}, ${all ? "select multiple" : "select single"}`);
+          });
+        });
+      }
+      console.log(`Showing currently active listeners for ${Object.keys(observers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`);
+      alert("See console.");
+    }, "s");
   }
 }
 

+ 89 - 0
src/interface.ts

@@ -0,0 +1,89 @@
+import * as UserUtils from "@sv443-network/userutils";
+import { mode, branch, scriptInfo } from "./constants";
+import { getResourceUrl, getSessionId, getVideoTime, log } from "./utils";
+import { setLocale, getLocale, hasKey, hasKeyFor, t, tp, type TrLocale } from "./translations";
+import { addSelectorListener } from "./observers";
+import { getFeatures, saveFeatures } from "./config";
+import { fetchLyricsUrl, getLyricsCacheEntry, sanitizeArtists, sanitizeSong } from "./features/lyrics";
+import type { SiteEventsMap } from "./siteEvents";
+
+const { getUnsafeWindow } = UserUtils;
+
+/** All events that can be emitted on the BYTM interface and the data they provide */
+export interface InterfaceEvents {
+  /** Emitted when BYTM has finished initializing all features */
+  "bytm:ready": undefined;
+  /** Emitted whenever the lyrics URL for a song is loaded */
+  "bytm:lyricsLoaded": { type: "current" | "queue", artists: string, title: string, url: string };
+  /** Emitted whenever the locale is changed */
+  "bytm:setLocale": { locale: TrLocale };
+  /**
+   * Emitted whenever the SelectorObserver instances have been initialized  
+   * Use `unsafeWindow.BYTM.addObserverListener()` to add custom listener functions to the observers
+   */
+  "bytm:observersReady": undefined;
+
+  // additionally all events from SiteEventsMap in `src/siteEvents.ts`
+  // are emitted in this format: "bytm:siteEvent:nameOfSiteEvent"
+}
+
+const globalFuncs = {
+  addSelectorListener,
+  getResourceUrl,
+  getSessionId,
+  getVideoTime,
+  setLocale,
+  getLocale,
+  hasKey,
+  hasKeyFor,
+  t,
+  tp,
+  getFeatures,
+  saveFeatures,
+  fetchLyricsUrl,
+  getLyricsCacheEntry,
+  sanitizeArtists,
+  sanitizeSong,
+};
+
+/** Initializes the BYTM interface */
+export function initInterface() {
+  const props = {
+    mode,
+    branch,
+    ...scriptInfo,
+    ...globalFuncs,
+    UserUtils,
+  };
+
+  for(const [key, value] of Object.entries(props))
+    setGlobalProp(key, value);
+
+  log("Initialized BYTM interface");
+}
+
+/** Sets a global property on the window.BYTM object */
+export function setGlobalProp<
+  TKey extends keyof Window["BYTM"],
+  TValue = Window["BYTM"][TKey],
+> (
+  key: TKey | (string & {}),
+  value: TValue,
+) {
+  // use unsafeWindow so the properties are available outside of the userscript's scope
+  const win = getUnsafeWindow();
+  if(!win.BYTM)
+    win.BYTM = {} as typeof win.BYTM;
+  win.BYTM[key] = value;
+}
+
+/** Emits an event on the BYTM interface */
+export function emitInterface<
+  TEvt extends keyof InterfaceEvents,
+  TDetail extends InterfaceEvents[TEvt],
+>(
+  type: TEvt | `bytm:siteEvent:${keyof SiteEventsMap}`,
+  ...data: (TDetail extends undefined ? [undefined?] : [TDetail])
+) {
+  getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: data[0] }));
+}

+ 4 - 0
src/menu/README.md

@@ -0,0 +1,4 @@
+## Menu
+This directory contains the code of all the different menus of the userscript.  
+Some of them are built with JS directly (with `document.createElement()`) and some are built using React JSX.  
+In the long term all of these will be built and rendered using React JSX.

+ 17 - 0
src/menu/hotkeyInput.css

@@ -0,0 +1,17 @@
+.bytm-hotkey-wrapper {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-end;
+}
+
+.bytm-hotkey-reset {
+  font-size: 0.9em;
+  margin-left: 5px;
+}
+
+.bytm-hotkey-info {
+  font-size: 0.9em;
+  margin-right: 5px;
+  white-space: nowrap;
+}

+ 140 - 0
src/menu/hotkeyInput.ts

@@ -0,0 +1,140 @@
+import { getFeatures } from "../config";
+import { siteEvents } from "../siteEvents";
+import { t } from "../translations";
+import type { HotkeyObj } from "../types";
+import "./hotkeyInput.css";
+
+interface HotkeyInputProps {
+  initialValue?: HotkeyObj;
+  resetValue?: HotkeyObj;
+  onChange: (hotkey: HotkeyObj) => void;
+}
+
+/** Creates a hotkey input element */
+export function createHotkeyInput({ initialValue, resetValue, onChange }: HotkeyInputProps): HTMLElement {
+  const wrapperElem = document.createElement("div");
+  wrapperElem.classList.add("bytm-hotkey-wrapper");
+
+  const infoElem = document.createElement("span");
+  infoElem.classList.add("bytm-hotkey-info");
+  
+  const inputElem = document.createElement("input");
+  inputElem.type = "button";
+  inputElem.classList.add("bytm-ftconf-input", "bytm-hotkey-input", "bytm-btn");
+  inputElem.dataset.state = "inactive";
+  inputElem.value = initialValue?.code ?? t("hotkey_input_click_to_change");
+  inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_change_tooltip");
+
+  const resetElem = document.createElement("span");
+  resetElem.classList.add("bytm-hotkey-reset", "bytm-link");
+  resetElem.role = "button";
+  resetElem.tabIndex = 0;
+  resetElem.innerText = `(${t("reset")})`;
+
+  const resetClicked = (e: MouseEvent | KeyboardEvent) => {
+    e.preventDefault();
+    e.stopImmediatePropagation();
+
+    onChange(resetValue!);
+    inputElem.value = resetValue!.code;
+    inputElem.dataset.state = "inactive";
+    infoElem.innerText = getHotkeyInfo(resetValue!);
+  };
+
+  resetElem.addEventListener("click", resetClicked);
+  resetElem.addEventListener("keydown", (e) => e.key === "Enter" && resetClicked(e));
+
+  if(initialValue)
+    infoElem.innerText = getHotkeyInfo(initialValue);
+
+  let lastKeyDown: HotkeyObj | undefined;
+
+  document.addEventListener("keypress", (e) => {
+    if(inputElem.dataset.state !== "active")
+      return;
+    if(lastKeyDown?.code === e.code && lastKeyDown?.shift === e.shiftKey && lastKeyDown?.ctrl === e.ctrlKey && lastKeyDown?.alt === e.altKey)
+      return;
+    e.preventDefault();
+    e.stopImmediatePropagation();
+
+    const hotkey = {
+      code: e.code,
+      shift: e.shiftKey,
+      ctrl: e.ctrlKey,
+      alt: e.altKey,
+    } as HotkeyObj;
+    inputElem.value = hotkey.code;
+    inputElem.dataset.state = "inactive";
+    infoElem.innerText = getHotkeyInfo(hotkey);
+    onChange(hotkey);
+  });
+
+  document.addEventListener("keydown", (e) => {
+    if(inputElem.dataset.state !== "active")
+      return;
+    if(["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight"].includes(e.code))
+      return;
+    e.preventDefault();
+    e.stopImmediatePropagation();
+
+    const hotkey = {
+      code: e.code,
+      shift: e.shiftKey,
+      ctrl: e.ctrlKey,
+      alt: e.altKey,
+    } as HotkeyObj;
+    lastKeyDown = hotkey;
+
+    inputElem.value = hotkey.code;
+    inputElem.dataset.state = "inactive";
+    infoElem.innerText = getHotkeyInfo(hotkey);
+    inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_cancel_tooltip");
+    onChange(hotkey);
+  });
+
+  const deactivate = () => {
+    siteEvents.emit("hotkeyInputActive", false);
+    const curVal = getFeatures().switchSitesHotkey ?? initialValue;
+    inputElem.value = curVal?.code ?? t("hotkey_input_click_to_change");
+    inputElem.dataset.state = "inactive";
+    inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_change_tooltip");
+    infoElem.innerText = curVal ? getHotkeyInfo(curVal) : "";
+  };
+
+  const activate = () => {
+    siteEvents.emit("hotkeyInputActive", true);
+    inputElem.value = "< ... >";
+    inputElem.dataset.state = "active";
+    inputElem.ariaLabel = inputElem.title = t("hotkey_input_click_to_cancel_tooltip");
+  };
+
+  siteEvents.on("cfgMenuClosed", deactivate);
+
+  inputElem.addEventListener("click", () => {
+    if(inputElem.dataset.state === "active")
+      deactivate();
+    else
+      activate();
+  });
+
+  wrapperElem.appendChild(infoElem);
+  wrapperElem.appendChild(inputElem);
+  resetValue && wrapperElem.appendChild(resetElem);
+
+  return wrapperElem;
+}
+
+function getHotkeyInfo(hotkey: HotkeyObj) {
+  const modifiers = [] as string[];
+  hotkey.ctrl && modifiers.push(t("hotkey_key_ctrl"));
+  hotkey.shift && modifiers.push(t("hotkey_key_shift"));
+  hotkey.alt && modifiers.push(getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt"));
+  return modifiers.reduce((a, c) => a += `${c} + `, "");
+}
+
+/** Crude OS detection for keyboard layout purposes */
+function getOS() {
+  if(navigator.userAgent.match(/mac(\s?os|intel)/i))
+    return "mac";
+  return "other";
+}

+ 0 - 38
src/menu/menu.css

@@ -1,38 +0,0 @@
-/* #bytm-menu-backdrop {
-    display: none;
-    flex-direction: column;
-    justify-content: center;
-    align-items: center;
-}
-
-#bytm-menu-backdrop[data-menu-open="true"] {
-    display: flex;
-} */
-
-#bytm-menu-header-container {
-    display: flex;
-    justify-content: flex-start;
-    align-items: center;
-    border-color: #ffffff;
-    border-style: none solid none none;
-}
-
-.bytm-menu-header-option {
-    display: "flex";
-    justify-content: center;
-    align-items: center;
-    border-color: #ffffff;
-    border-style: solid none solid none;
-}
-
-#bytm-menu-header-option h3 {
-    margin: 0;
-}
-
-.bytm-menu-tab[data-active="true"] {
-    display: none;
-}
-
-.bytm-menu-tab[data-active="false"] {
-    display: none;
-}

+ 0 - 20
src/menu/menu.html

@@ -1,20 +0,0 @@
-<dialog id="bytm-menu-dialog">
-  <div id="bytm-menu-header-container">
-    <div class="bytm-menu-header-option" id="bytm-menu-tab-options-header" data-active="true">
-      <h3>Options</h3>
-    </div>
-    <div class="bytm-menu-header-option" id="bytm-menu-tab-info-header" data-active="false">
-      <h3>Info</h3>
-    </div>
-    <div class="bytm-menu-header-option" id="bytm-menu-tab-changelog-header" data-active="false">
-      <h3>Changelog</h3>
-    </div>
-  </div>
-  <div id="bytm-menu-body">
-    <div class="bytm-menu-tab-content" id="bytm-menu-tab-options-content" data-active="true"></div>
-    <div class="bytm-menu-tab-content" id="bytm-menu-tab-info-content" data-active="false">
-      ayo info
-    </div>
-    <div class="bytm-menu-tab-content" id="bytm-menu-tab-changelog-content" data-active="false"></div>
-  </div>
-</dialog>

+ 0 - 95
src/menu/menu.ts

@@ -1,95 +0,0 @@
-import changelogContent from "../../changelog.md";
-import menuContent from "./menu.html";
-import "./menu.css";
-
-// REQUIREMENTS:
-// - modal using the <dialog> element
-// - sections with headers
-// - support for "custom widgets"
-// - debounce or save on button press to store new configuration
-// - much better scaling including no vw and vh units
-// - cleanup function per feature so a page reload is not always needed
-
-//#MARKER menu
-
-/**
- * The base selector values for the menu tabs  
- * Header selector format: `#${baseValue}-header`  
- * Content selector format: `#${baseValue}-content`
- */
-const tabsSelectors = {
-  options: "bytm-menu-tab-options",
-  info: "bytm-menu-tab-info",
-  changelog: "bytm-menu-tab-changelog",
-};
-
-/** Called from init(), before DOMContentLoaded is fired  */
-export function initMenu() {
-  document.addEventListener("DOMContentLoaded", () => {
-    // create menu container
-    const menuContainer = document.createElement("div");
-    menuContainer.id = "bytm-menu-container";
-    // add menu html
-    menuContainer.innerHTML = menuContent;
-
-    document.body.appendChild(menuContainer);
-
-    initMenuContents();
-  });
-}
-
-function initMenuContents() {
-  // hook events
-  for(const tab in tabsSelectors) {
-    const selector = tabsSelectors[tab as keyof typeof tabsSelectors];
-    document.querySelector(`#${selector}-header`)?.addEventListener("click", () => {
-      setActiveTab(tab as keyof typeof tabsSelectors);
-    });
-  }
-
-  // init tab contents
-  initOptionsContent();
-  initInfoContent();
-  initChangelogContent();
-}
-
-/** Opens the specified tab */
-export function setActiveTab(tab: keyof typeof tabsSelectors) {
-  const tabs = { ...tabsSelectors };
-  delete tabs[tab];
-  // disable all but new active tab
-  for(const [, val] of Object.entries(tabs)) {
-    document.querySelector<HTMLElement>(`#${val}-header`)!.dataset.active = "false";
-    document.querySelector<HTMLElement>(`#${val}-content`)!.dataset.active = "false";
-  }
-  // enable new active tab
-  document.querySelector<HTMLElement>(`#${tabsSelectors[tab]}-header`)!.dataset.active = "true";
-  document.querySelector<HTMLElement>(`#${tabsSelectors[tab]}-content`)!.dataset.active = "true";
-}
-  
-/** Opens the modal menu dialog */
-export function openMenu() {
-  document.querySelector<HTMLDialogElement>("#bytm-menu-dialog")?.showModal();
-}
-  
-/** Closes the modal menu dialog */
-export function closeMenu() {
-  document.querySelector<HTMLDialogElement>("#bytm-menu-dialog")?.close();
-}
-
-//#MARKER menu tab contents
-
-function initOptionsContent() {
-  const tab = document.querySelector("#bytm-menu-tab-options-content")!;
-  void tab;
-}
-
-function initInfoContent() {
-  const tab = document.querySelector("#bytm-menu-tab-info-content")!;
-  void tab;
-}
-
-function initChangelogContent() {
-  const tab = document.querySelector("#bytm-menu-tab-changelog-content")!;
-  tab.innerHTML = changelogContent;
-}

+ 128 - 29
src/menu/menu_old.css

@@ -1,6 +1,7 @@
 .bytm-menu-bg {
   --bytm-menu-bg: #333333;
-  --bytm-menu-bg-highlight: #1e1e1e;
+  --bytm-menu-bg-highlight: #252525;
+  --bytm-scroll-indicator-bg: rgba(10, 10, 10, 0.7);
   --bytm-menu-separator-color: #797979;
   --bytm-menu-border-radius: 10px;
 }
@@ -20,6 +21,11 @@
   --bytm-menu-width-max: 600px;
 }
 
+#bytm-feat-help-menu-bg {
+  --bytm-menu-height-max: 400px;
+  --bytm-menu-width-max: 600px;
+}
+
 .bytm-menu-bg {
   display: block;
   position: fixed;
@@ -27,7 +33,7 @@
   height: 100%;
   top: 0;
   left: 0;
-  z-index: 15;
+  z-index: 5;
   background-color: rgba(0, 0, 0, 0.6);
 }
 
@@ -42,7 +48,7 @@
   left: 50%;
   top: 50%;
   transform: translate(-50%, -50%);
-  z-index: 16;
+  z-index: 6;
   color: #fff;
   background-color: var(--bytm-menu-bg);
 }
@@ -62,6 +68,7 @@
 .bytm-menu-header {
   display: flex;
   justify-content: space-between;
+  align-items: center;
   margin-bottom: 6px;
   padding: 15px 20px 15px 20px;
   background-color: var(--bytm-menu-bg);
@@ -70,16 +77,41 @@
   border-radius: var(--bytm-menu-border-radius) var(--bytm-menu-border-radius) 0px 0px;
 }
 
-#bytm-menu-titlecont {
+.bytm-menu-header.small {
+  padding: 10px 15px;
+}
+
+.bytm-menu-titlecont {
   display: flex;
   align-items: center;
 }
 
-#bytm-menu-title {
+.bytm-menu-titlecont-no-title {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+}
+
+.bytm-menu-title {
+  position: relative;
   display: inline-block;
   font-size: 22px;
 }
 
+#bytm-menu-version {
+  position: absolute;
+  width: 100%;
+  bottom: -10px;
+  left: 0;
+  font-size: 10px;
+  font-weight: normal;
+  z-index: 7;
+}
+
+#bytm-menu-version .bytm-link {
+  color: #c6d2db;
+}
+
 #bytm-menu-linkscont {
   display: flex;
   align-items: center;
@@ -96,27 +128,48 @@
   margin-right: 10px;
 }
 
+.bytm-menu-link .bytm-menu-img {
+  position: relative;
+  border-radius: 50%;
+  bottom: 0px;
+  transition: bottom 0.15s ease-out;
+}
+
+.bytm-menu-link:hover .bytm-menu-img {
+  bottom: 5px;
+}
+
 .bytm-menu-close {
   width: 32px;
   height: 32px;
   cursor: pointer;
 }
 
+.bytm-menu-close.small {
+  width: 24px;
+  height: 24px;
+}
+
 .bytm-menu-footer {
   font-size: 17px;
   text-decoration: underline;
 }
 
-#bytm-menu-footer-cont {
+.bytm-menu-footer.hidden {
+  display: none;
+}
+
+.bytm-menu-footer-cont {
   display: flex;
   flex-direction: row;
   justify-content: space-between;
   margin-top: 6px;
-  padding: 20px 20px 8px 20px;
+  padding: 15px 20px;
   background: var(--bytm-menu-bg);
   background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, var(--bytm-menu-bg) 30%, var(--bytm-menu-bg) 100%);
   border: 2px solid var(--bytm-menu-separator-color);
   border-style: solid none none none;
+  border-radius: 0px 0px var(--bytm-menu-border-radius) var(--bytm-menu-border-radius);
 }
 
 #bytm-menu-footer-buttons-cont button:not(:last-of-type) {
@@ -130,12 +183,8 @@
   margin-top: 15px;
 }
 
-#bytm-menu-version-cont {
-  display: flex;
-  justify-content: space-around;
-  font-size: 1.2em;
-  padding-bottom: 8px;
-  border-radius: var(--bytm-menu-border-radius) var(--bytm-menu-border-radius) 0px 0px;
+#bytm-menu-footer-left-buttons-cont button:not(:last-of-type) {
+  margin-right: 15px;
 }
 
 #bytm-menu-scroll-indicator {
@@ -148,8 +197,8 @@
   transform: translateX(-50%);
   width: 32px;
   height: 32px;
-  z-index: 101;
-  background-color: var(--bytm-menu-bg-highlight);
+  z-index: 7;
+  background-color: var(--bytm-scroll-indicator-bg);
   border-radius: 50%;
   cursor: pointer;
 }
@@ -176,12 +225,24 @@
   align-items: center;
   font-size: 1.4em;
   padding: 8px 20px;
+  transition: background-color 0.15s ease-out;
+}
+
+.bytm-ftitem:hover {
+  background-color: var(--bytm-menu-bg-highlight);
+}
+
+.bytm-ftitem-leftside {
+  display: flex;
+  align-items: center;
+  min-height: 24px;
 }
 
 .bytm-ftconf-ctrl {
   display: inline-flex;
   align-items: center;
   white-space: nowrap;
+  margin-left: 10px;
 }
 
 .bytm-ftconf-label {
@@ -197,6 +258,11 @@
   padding-right: 5px;
 }
 
+.bytm-ftconf-input.bytm-hotkey-input {
+  cursor: pointer;
+  min-width: 50px;
+}
+
 .bytm-ftconf-input[type=number] {
   width: 75px;
 }
@@ -235,18 +301,8 @@
 
 /* Markdown stuff */
 
-.bytm-markdown-container a, #bytm-menu-version {
-  color: #369bff;
-  text-decoration: none;
-  cursor: pointer;
-}
-
-.bytm-markdown-container a:hover, #bytm-menu-version:hover {
-  text-decoration: underline;
-}
-
 .bytm-markdown-container kbd {
-  --easing: cubic-bezier(0.31, 0.58, 0.24, 1.15);
+  --bytm-easing: cubic-bezier(0.31, 0.58, 0.24, 1.15);
   display: inline-block;
   vertical-align: bottom;
   padding: 4px;
@@ -257,12 +313,12 @@
   border: 1px solid #777;
   border-radius: 5px;
   box-shadow: inset 0 -2px 0 #515559;
-  transition: padding 0.1s var(--easing), box-shadow 0.1s var(--easing);
+  transition: padding 0.1s var(--bytm-easing), box-shadow 0.1s var(--bytm-easing);
 }
 
 .bytm-markdown-container kbd:active {
   padding-bottom: 2px;
-  box-shadow: inset 0 0 0 #61666c;
+  box-shadow: inset 0 0 0 initial;
 }
 
 .bytm-markdown-container kbd::selection {
@@ -280,7 +336,7 @@
 }
 
 .bytm-markdown-container h2:not(:first-of-type) {
-  margin-top: 20px;
+  margin-top: 30px;
 }
 
 .bytm-markdown-container ul li::before {
@@ -293,3 +349,46 @@
   content: "    • ";
   font-weight: bolder;
 }
+
+#bytm-feat-help-menu-desc, #bytm-feat-help-menu-text {
+  overflow-wrap: break-word;
+  white-space: pre-wrap;
+  padding: 10px 10px 15px 20px;
+  font-size: 1.5em;
+}
+
+#bytm-feat-help-menu-desc {
+  font-size: 1.65em;
+  padding-bottom: 5px;
+}
+
+.bytm-ftitem-help-btn {
+  width: 24px !important;
+  height: 24px !important;
+}
+
+.bytm-ftitem-help-btn svg {
+  width: 18px !important;
+  height: 18px !important;
+}
+
+.bytm-ftitem-help-btn svg > path {
+  fill: #b3bec7 !important;
+}
+
+hr {
+  display: block;
+  margin: 8px 0px 12px 0px;
+  border: revert;
+}
+
+.bytm-ftitem-adornment {
+  display: inline-flex;
+  justify-content: flex-start;
+  align-items: center;
+  margin-left: 8px;
+}
+
+#bytm-ftitem-locale-adornment svg path {
+  fill: #4595c7;
+}

Різницю між файлами не показано, бо вона завелика
+ 511 - 200
src/menu/menu_old.ts


+ 242 - 0
src/menu/new/BytmMenu.tsx

@@ -0,0 +1,242 @@
+import { createRoot } from "react-dom/client";
+import * as React from "react";
+// hoist the class declaration because either rollup or babel is being a hoe
+import { NanoEmitter } from "../../utils/NanoEmitter";
+import { clearInner, getResourceUrl, warn } from "../../utils";
+import { t } from "../../translations";
+
+export interface BytmMenuOptions {
+  /** ID that gets added to child element IDs - has to be unique and conform to HTML ID naming rules! */
+  id: string;
+  /** Whether the menu should close when the background is clicked - defaults to true */
+  closeOnBgClick?: boolean;
+  /** Whether the menu should close when the escape key is pressed - defaults to true */
+  closeOnEscPress?: boolean;
+  /** Whether the close button should be enabled - defaults to true */
+  closeBtnEnabled?: boolean;
+  /** Called to render the body of the menu */
+  renderBody: () => React.ReactNode;
+  /** Called to render the header of the menu - leave undefined for a blank header */
+  renderHeader?: () => React.ReactNode;
+  /** Called to render the footer of the menu - leave undefined for no footer */
+  renderFooter?: () => React.ReactNode;
+}
+
+/** ID of the last opened (top-most) menu */
+let lastMenuId: string | null = null;
+
+/** Creates and manages a modal menu element */
+export class BytmMenu extends NanoEmitter<{
+  /** Emitted just after the menu is closed */
+  close: () => void;
+  /** Emitted just after the menu is opened */
+  open: () => void;
+  /** Emitted just after the menu contents are rendered */
+  render: () => void;
+  /** Emitted just after the menu contents are cleared */
+  clear: () => void;
+  /** Emitted just before the menu is destroyed and all listeners are removed */
+  destroy: () => void;
+}> {
+  public readonly options;
+  public readonly id;
+
+  private menuOpen = false;
+  private menuRendered = false;
+  private listenersAttached = false;
+
+  constructor(options: BytmMenuOptions) {
+    super();
+
+    this.options = {
+      closeOnBgClick: true,
+      closeOnEscPress: true,
+      closeBtnEnabled: true,
+      ...options,
+    };
+    this.id = options.id;
+  }
+
+  /** Call after DOMContentLoaded to pre-render the menu (or call just before calling open()) */
+  public async render() {
+    if(this.menuRendered)
+      return;
+    this.menuRendered = true;
+
+    const bgElem = document.createElement("div");
+    bgElem.id = `bytm-${this.id}-menu-bg`;
+    bgElem.classList.add("bytm-menu-bg");
+    if(this.options.closeOnBgClick)
+      bgElem.ariaLabel = bgElem.title = t("close_menu_tooltip");
+
+    bgElem.style.visibility = "hidden";
+    bgElem.style.display = "none";
+    bgElem.inert = true;
+
+    document.body.appendChild(bgElem);
+
+    const root = createRoot(bgElem);
+    root.render(await this.getMenuContent());
+
+    this.attachListeners(bgElem);
+
+    this.events.emit("render");
+  }
+
+  /** Clears all menu contents in preparation for a new rendering call */
+  public clear() {
+    this.menuRendered = false;
+
+    const clearSelectors = [
+      `#bytm-${this.id}-menu-bg`,
+    ];
+
+    for(const selector of clearSelectors) {
+      const elem = document.querySelector<HTMLElement>(selector);
+      if(!elem)
+        continue;
+      clearInner(elem);
+    }
+
+    document.querySelector(`#bytm-${this.id}-menu-bg`)?.remove();
+
+    this.events.emit("clear");
+  }
+
+  /** Clears and then re-renders the menu */
+  public async rerender() {
+    this.clear();
+    await this.render();
+  }
+
+  /**
+   * Opens the menu - renders it if it hasn't been rendered yet  
+   * Prevents default action and immediate propagation of the passed event
+   */
+  public async open(e?: MouseEvent | KeyboardEvent) {
+    e?.preventDefault();
+    e?.stopImmediatePropagation();
+
+    if(this.isOpen())
+      return;
+    this.menuOpen = true;
+
+    if(!this.isRendered())
+      await this.render();
+
+    document.body.classList.add("bytm-disable-scroll");
+    document.querySelector("ytmusic-app")?.setAttribute("inert", "true");
+    const menuBg = document.querySelector<HTMLElement>(`#bytm-${this.id}-menu-bg`);
+
+    if(!menuBg)
+      return warn(`Couldn't find background element for menu with ID '${this.id}'`);
+
+    menuBg.style.visibility = "visible";
+    menuBg.style.display = "block";
+    menuBg.inert = false;
+
+    lastMenuId = this.id;
+
+    this.events.emit("open");
+  }
+
+  /** Closes the menu - prevents default action and immediate propagation of the passed event */
+  public close(e?: MouseEvent | KeyboardEvent) {
+    e?.preventDefault();
+    e?.stopImmediatePropagation();
+
+    if(!this.isOpen())
+      return;
+    this.menuOpen = false;
+
+    document.body.classList.remove("bytm-disable-scroll");
+    document.querySelector("ytmusic-app")?.removeAttribute("inert");
+    const menuBg = document.querySelector<HTMLElement>(`#bytm-${this.id}-menu-bg`);
+
+    if(!menuBg)
+      return warn(`Couldn't find background element for menu with ID '${this.id}'`);
+
+    menuBg.style.visibility = "hidden";
+    menuBg.style.display = "none";
+    menuBg.inert = true;
+
+    if(BytmMenu.getLastMenuId() === this.id)
+      lastMenuId = null;
+
+    this.events.emit("close");
+  }
+
+  /** Returns true if the menu is open */
+  public isOpen() {
+    return this.menuOpen;
+  }
+
+  /** Returns true if the menu has been rendered */
+  public isRendered() {
+    return this.menuRendered;
+  }
+
+  /** Clears the menu and removes all event listeners */
+  public destroy() {
+    this.events.emit("destroy");
+    this.clear();
+    this.unsubscribeAll();
+  }
+
+  /** Returns the ID of the top-most menu (the menu that has been opened last) */
+  public static getLastMenuId() {
+    return lastMenuId;
+  }
+
+  /** Called once to attach all generic event listeners */
+  private attachListeners(bgElem: HTMLElement) {
+    if(this.listenersAttached)
+      return;
+    this.listenersAttached = true;
+
+    if(this.options.closeOnBgClick) {
+      bgElem.addEventListener("click", (e) => {
+        if(this.isOpen() && (e.target as HTMLElement)?.id === `bytm-${this.id}-menu-bg`)
+          this.close(e);
+      });
+    }
+
+    if(this.options.closeOnEscPress) {
+      document.body.addEventListener("keydown", (e) => {
+        if(e.key === "Escape" && this.isOpen() && BytmMenu.getLastMenuId() === this.id)
+          this.close(e);
+      });
+    }
+  }
+
+  private async getMenuContent() {
+    const closeSrc = await getResourceUrl("img-close");
+
+    const header = this.options.renderHeader?.();
+    const footer = this.options.renderFooter?.();
+
+    // TODO:
+    return (
+      <div id={`bytm-${this.id}-menu`} className="bytm-menu" title="" aria-label="">
+        <div className="bytm-menu-header">
+          {header ? (
+            <div className="bytm-menu-title-wrapper" role="heading" aria-level={1}>
+              {header}
+            </div>
+          ) : null}
+          {this.options.closeBtnEnabled ? (
+            <img className="bytm-menu-close" src={closeSrc} role="button" tabIndex={0} onClick={() => this.close()} />
+          ) : null}
+        </div>
+        <div>
+          {this.options.renderBody()}
+        </div>
+        {footer ? (
+          <div>
+            {footer}
+          </div>
+        ) : null}
+      </div>
+    );
+  }
+}

+ 5 - 0
src/menu/updateMenu.css

@@ -0,0 +1,5 @@
+#bytm-update-menu-bg {
+  --bytm-menu-height-max: 500px;
+  --bytm-menu-width-max: 600px;
+}
+

+ 78 - 0
src/menu/updateMenu.ts

@@ -0,0 +1,78 @@
+import { warn } from "../utils";
+import "./updateMenu.css";
+import menuTemplateHtml from "./new/menuTemplate.html";
+
+// TODO: implement using BytmMenu class
+
+let isUpdateMenuOpen = false;
+
+export function createUpdateMenu() {
+  const bgElem = document.createElement("div");
+  bgElem.id = "bytm-update-menu-bg";
+  bgElem.innerHTML = menuTemplateHtml;
+  bgElem.classList.add("bytm-menu-bg");
+  bgElem.style.visibility = "hidden";
+  bgElem.style.display = "none";
+
+  document.body.appendChild(bgElem);
+
+  const menuContElem = bgElem.querySelector(".bytm-menu");
+  if(menuContElem) {
+    menuContElem.id = "bytm-update-menu";
+  }
+}
+
+/** Closes the update menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
+function closeUpdateMenu(evt?: MouseEvent | KeyboardEvent) {
+  if(!isUpdateMenuOpen)
+    return;
+  isUpdateMenuOpen = false;
+  evt?.bubbles && evt.stopPropagation();
+
+  document.body.classList.remove("bytm-disable-scroll");
+  document.querySelector("ytmusic-app")?.removeAttribute("inert");
+  const menuBg = document.querySelector<HTMLElement>("#bytm-update-menu-bg");
+
+  if(!menuBg)
+    return warn("Couldn't find update menu background element");
+
+  menuBg.style.visibility = "hidden";
+  menuBg.style.display = "none";
+}
+
+void closeUpdateMenu;
+
+/** Opens the update menu if it is closed */
+export function openUpdateMenu(newVersion: string) {
+  if(isUpdateMenuOpen)
+    return;
+  isUpdateMenuOpen = true;
+
+  document.body.classList.add("bytm-disable-scroll");
+  document.querySelector("ytmusic-app")?.setAttribute("inert", "true");
+  const menuBg = document.querySelector<HTMLElement>("#bytm-update-menu-bg");
+
+  if(!menuBg)
+    return warn("Couldn't find update menu background element");
+
+  const changes = {
+    "#update-menu-version": (el: HTMLElement) => el.innerText = newVersion,
+    "#update-menu-changelog-url": (el: HTMLAnchorElement) => {
+      el.href = `https://github.com/Sv443/BetterYTM/blob/main/changelog.md#${newVersion.replace(/\./g, "")}`;
+    },
+  };
+
+  for(const [selector, cb] of Object.entries(changes)) {
+    const elem = document.querySelector<HTMLElement>(selector);
+    if(!elem) {
+      warn(`Couldn't find element ${selector} in welcome menu`);
+      continue;
+    }
+
+    // @ts-ignore
+    cb(elem);
+  }
+
+  menuBg.style.visibility = "visible";
+  menuBg.style.display = "block";
+}

+ 49 - 0
src/menu/welcomeMenu.css

@@ -0,0 +1,49 @@
+#bytm-welcome-menu-bg {
+  --bytm-menu-height-max: 500px;
+  --bytm-menu-width-max: 700px;
+}
+
+#bytm-welcome-menu-title-wrapper {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+#bytm-welcome-menu-title-logo {
+  width: 32px;
+  height: 32px;
+  margin-right: 20px;
+}
+
+#bytm-welcome-menu-content-wrapper {
+  overflow-y: auto;
+}
+
+#bytm-welcome-menu-locale-cont {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: flex-start;
+}
+
+#bytm-welcome-menu-locale-img {
+  width: 80px;
+  height: 80px;
+  margin-bottom: 10px;
+}
+
+#bytm-welcome-menu-text {
+  font-size: 1.6em;
+  padding: 8px 20px;
+  margin: 10px 0px;
+  line-height: 20px;
+}
+
+#bytm-welcome-menu-locale-select {
+  font-size: 1.6em;
+}
+
+#bytm-welcome-menu-footer-cont {
+  border-radius: 0px 0px var(--bytm-menu-border-radius) var(--bytm-menu-border-radius);
+  padding: 20px;
+}

+ 282 - 0
src/menu/welcomeMenu.ts

@@ -0,0 +1,282 @@
+import { getResourceUrl, warn } from "../utils";
+import { TrLocale, initTranslations, setLocale, t } from "../translations";
+import { getFeatures, saveFeatures } from "../config";
+import { siteEvents } from "../siteEvents";
+import { scriptInfo } from "../constants";
+import { addCfgMenu, openCfgMenu, openChangelogMenu } from "./menu_old";
+import locales from "../../assets/locales.json" assert { type: "json" };
+import pkg from "../../package.json" assert { type: "json" };
+import "./welcomeMenu.css";
+
+//#MARKER menu
+
+let isWelcomeMenuOpen = false;
+
+/** Adds the welcome menu to the DOM */
+export async function addWelcomeMenu() {
+  //#SECTION backdrop & menu container
+  const backgroundElem = document.createElement("div");
+  backgroundElem.id = "bytm-welcome-menu-bg";
+  backgroundElem.classList.add("bytm-menu-bg");
+  backgroundElem.style.visibility = "hidden";
+  backgroundElem.style.display = "none";
+
+  const menuContainer = document.createElement("div");
+  menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards
+  menuContainer.classList.add("bytm-menu");
+  menuContainer.id = "bytm-welcome-menu";
+
+  //#SECTION title bar
+  const headerElem = document.createElement("div");
+  headerElem.classList.add("bytm-menu-header");
+
+  const titleWrapperElem = document.createElement("div");
+  titleWrapperElem.id = "bytm-welcome-menu-title-wrapper";
+
+  const titleLogoElem = document.createElement("img");
+  titleLogoElem.id = "bytm-welcome-menu-title-logo";
+  titleLogoElem.classList.add("bytm-no-select");
+  titleLogoElem.src = await getResourceUrl("img-logo");
+
+  const titleElem = document.createElement("h2");
+  titleElem.id = "bytm-welcome-menu-title";
+  titleElem.className = "bytm-menu-title";
+  titleElem.role = "heading";
+  titleElem.ariaLevel = "1";
+
+  titleWrapperElem.appendChild(titleLogoElem);
+  titleWrapperElem.appendChild(titleElem);
+
+  headerElem.appendChild(titleWrapperElem);
+
+  //#SECTION footer
+  const footerCont = document.createElement("div");
+  footerCont.id = "bytm-welcome-menu-footer-cont";
+  footerCont.className = "bytm-menu-footer-cont";
+
+  const openCfgElem = document.createElement("button");
+  openCfgElem.id = "bytm-welcome-menu-open-cfg";
+  openCfgElem.classList.add("bytm-btn");
+  openCfgElem.addEventListener("click", () => {
+    closeWelcomeMenu();
+    openCfgMenu();
+  });
+
+  const openChangelogElem = document.createElement("button");
+  openChangelogElem.id = "bytm-welcome-menu-open-changelog";
+  openChangelogElem.classList.add("bytm-btn");
+  openChangelogElem.addEventListener("click", async () => {
+    closeWelcomeMenu();
+    await addCfgMenu();
+    openChangelogMenu("exit");
+  });
+
+  const closeBtnElem = document.createElement("button");
+  closeBtnElem.id = "bytm-welcome-menu-footer-close";
+  closeBtnElem.classList.add("bytm-btn");
+  closeBtnElem.addEventListener("click", async () => {
+    closeWelcomeMenu();
+  });
+
+  const leftButtonsCont = document.createElement("div");
+  leftButtonsCont.id = "bytm-menu-footer-left-buttons-cont";
+
+  leftButtonsCont.appendChild(openCfgElem);
+  leftButtonsCont.appendChild(openChangelogElem);
+
+  footerCont.appendChild(leftButtonsCont);
+  footerCont.appendChild(closeBtnElem);
+
+  //#SECTION content
+  const contentWrapper = document.createElement("div");
+  contentWrapper.id = "bytm-welcome-menu-content-wrapper";
+
+  // locale switcher
+
+  const localeCont = document.createElement("div");
+  localeCont.id = "bytm-welcome-menu-locale-cont";
+
+  const localeImg = document.createElement("img");
+  localeImg.id = "bytm-welcome-menu-locale-img";
+  localeImg.classList.add("bytm-no-select");
+  localeImg.src = await getResourceUrl("img-globe");
+
+  const localeSelectElem = document.createElement("select");
+  localeSelectElem.id = "bytm-welcome-menu-locale-select";
+
+  for(const [locale, { name }] of Object.entries(locales)) {
+    const localeOptionElem = document.createElement("option");
+    localeOptionElem.value = locale;
+    localeOptionElem.innerText = name;
+    localeSelectElem.appendChild(localeOptionElem);
+  }
+  localeSelectElem.value = getFeatures().locale;
+
+  localeSelectElem.addEventListener("change", async () => {
+    const selectedLocale = localeSelectElem.value;
+    const feats = Object.assign({}, getFeatures());
+    feats.locale = selectedLocale as TrLocale;
+    saveFeatures(feats);
+
+    await initTranslations(selectedLocale as TrLocale);
+    setLocale(selectedLocale as TrLocale);
+    retranslateWelcomeMenu();
+  });
+
+  localeCont.appendChild(localeImg);
+  localeCont.appendChild(localeSelectElem);
+
+  contentWrapper.appendChild(localeCont);
+
+  // text
+
+  const textCont = document.createElement("div");
+  textCont.id = "bytm-welcome-menu-text-cont";
+
+  const textElem = document.createElement("p");
+  textElem.id = "bytm-welcome-menu-text";
+
+  const textElems = [] as HTMLElement[];
+
+  const line1Elem = document.createElement("span");
+  line1Elem.id = "bytm-welcome-text-line1";
+  textElems.push(line1Elem);
+
+  const br1Elem = document.createElement("br");
+  textElems.push(br1Elem);
+
+  const line2Elem = document.createElement("span");
+  line2Elem.id = "bytm-welcome-text-line2";
+  textElems.push(line2Elem);
+
+  const br2Elem = document.createElement("br");
+  textElems.push(br2Elem);
+  const br3Elem = document.createElement("br");
+  textElems.push(br3Elem);
+
+  const line3Elem = document.createElement("span");
+  line3Elem.id = "bytm-welcome-text-line3";
+  textElems.push(line3Elem);
+
+  const br4Elem = document.createElement("br");
+  textElems.push(br4Elem);
+
+  const line4Elem = document.createElement("span");
+  line4Elem.id = "bytm-welcome-text-line4";
+  textElems.push(line4Elem);
+
+  const br5Elem = document.createElement("br");
+  textElems.push(br5Elem);
+  const br6Elem = document.createElement("br");
+  textElems.push(br6Elem);
+
+  const line5Elem = document.createElement("span");
+  line5Elem.id = "bytm-welcome-text-line5";
+  textElems.push(line5Elem);
+
+  textElems.forEach((elem) => textElem.appendChild(elem));
+  textCont.appendChild(textElem);
+  contentWrapper.appendChild(textCont);
+
+  //#SECTION finalize
+  menuContainer.appendChild(headerElem);
+  menuContainer.appendChild(contentWrapper);
+  menuContainer.appendChild(footerCont);
+
+  backgroundElem.appendChild(menuContainer);
+
+  document.body.appendChild(backgroundElem);
+  retranslateWelcomeMenu();
+}
+
+//#MARKER (re-)translate
+
+/** Retranslates all elements inside the welcome menu */
+function retranslateWelcomeMenu() {
+  const getLink = (href: string): [string, string] => {
+    return [`<a href="${href}" class="bytm-link" target="_blank" rel="noopener noreferrer">`, "</a>"];
+  };
+
+  const changes = {
+    "#bytm-welcome-menu-title": (e: HTMLElement) => e.innerText = t("welcome_menu_title", scriptInfo.name),
+    "#bytm-welcome-menu-title-close": (e: HTMLElement) => e.ariaLabel = e.title = t("close_menu_tooltip"),
+    "#bytm-welcome-menu-open-cfg": (e: HTMLElement) => {
+      e.innerText = t("config_menu");
+      e.ariaLabel = e.title = t("open_config_menu_tooltip");
+    },
+    "#bytm-welcome-menu-open-changelog": (e: HTMLElement) => {
+      e.innerText = t("open_changelog");
+      e.ariaLabel = e.title = t("open_changelog_tooltip");
+    },
+    "#bytm-welcome-menu-footer-close": (e: HTMLElement) => {
+      e.innerText = t("close");
+      e.ariaLabel = e.title = t("close_menu_tooltip");
+    },
+    "#bytm-welcome-text-line1": (e: HTMLElement) => e.innerHTML = t("welcome_text_line_1"),
+    "#bytm-welcome-text-line2": (e: HTMLElement) => e.innerHTML = t("welcome_text_line_2", scriptInfo.name),
+    "#bytm-welcome-text-line3": (e: HTMLElement) => e.innerHTML = t("welcome_text_line_3", scriptInfo.name, ...getLink(`${pkg.hosts.greasyfork}/feedback`), ...getLink(pkg.hosts.openuserjs)),
+    "#bytm-welcome-text-line4": (e: HTMLElement) => e.innerHTML = t("welcome_text_line_4", ...getLink(pkg.funding.url)),
+    "#bytm-welcome-text-line5": (e: HTMLElement) => e.innerHTML = t("welcome_text_line_5", ...getLink(pkg.bugs.url)),
+  };
+
+  for(const [selector, cb] of Object.entries(changes)) {
+    const elem = document.querySelector<HTMLElement>(selector);
+    if(!elem) {
+      warn(`Couldn't find element ${selector} in welcome menu`);
+      continue;
+    }
+
+    cb(elem);
+  }
+}
+
+/** Closes the welcome menu if it is open. If a bubbling event is passed, its propagation will be prevented. */
+export function closeWelcomeMenu(evt?: MouseEvent | KeyboardEvent) {
+  if(!isWelcomeMenuOpen)
+    return;
+  isWelcomeMenuOpen = false;
+  evt?.bubbles && evt.stopPropagation();
+
+  document.body.classList.remove("bytm-disable-scroll");
+  document.querySelector("ytmusic-app")?.removeAttribute("inert");
+  const menuBg = document.querySelector<HTMLElement>("#bytm-welcome-menu-bg");
+
+  siteEvents.emit("welcomeMenuClosed");
+
+  if(!menuBg)
+    return warn("Couldn't find welcome menu background element");
+
+  menuBg.style.visibility = "hidden";
+  menuBg.style.display = "none";
+}
+
+//#MARKER open, show & close
+
+/** Opens the welcome menu if it is closed */
+export function openWelcomeMenu() {
+  if(isWelcomeMenuOpen)
+    return;
+  isWelcomeMenuOpen = true;
+
+  document.body.classList.add("bytm-disable-scroll");
+  document.querySelector("ytmusic-app")?.setAttribute("inert", "true");
+  const menuBg = document.querySelector<HTMLElement>("#bytm-welcome-menu-bg");
+
+  if(!menuBg)
+    return warn("Couldn't find welcome menu background element");
+
+  menuBg.style.visibility = "visible";
+  menuBg.style.display = "block";
+}
+
+/** Shows the welcome menu and returns a promise that resolves when the menu is closed */
+export function showWelcomeMenu() {
+  return new Promise<void>((resolve) => {
+    const unsub = siteEvents.on("welcomeMenuClosed", () => {
+      unsub();
+      resolve();
+    });
+
+    openWelcomeMenu();
+  });
+}

+ 68 - 0
src/observers.ts

@@ -0,0 +1,68 @@
+import { SelectorListenerOptions, SelectorObserver, SelectorObserverOptions } from "@sv443-network/userutils";
+import type { ObserverName } from "./types";
+import { emitInterface } from "./interface";
+import { error } from "./utils";
+
+/** Options that are applied to every SelectorObserver instance */
+const defaultObserverOptions: SelectorObserverOptions = {
+  defaultDebounce: 100,
+};
+
+export const observers = {} as Record<ObserverName, SelectorObserver>;
+
+/** Call after DOM load to initialize all SelectorObserver instances */
+export function initObservers() {
+  try {
+    // #SECTION body = the entire <body> element - use sparingly due to performance impacts!
+    observers.body = new SelectorObserver(document.body, {
+      ...defaultObserverOptions,
+      subtree: false,
+    });
+    observers.body.enable();
+
+    // #SECTION playerBar = media controls bar at the bottom of the page
+    const playerBarSelector = "ytmusic-app-layout ytmusic-player-bar.ytmusic-app";
+    observers.playerBar = new SelectorObserver(playerBarSelector, {
+      ...defaultObserverOptions,
+      defaultDebounce: 200,
+    });
+    observers.body.addListener(playerBarSelector, {
+      listener: () => {
+        console.log("#DBG-UU enabling playerBar observer");
+        observers.playerBar.enable();
+      },
+    });
+
+    // #SECTION playerBarInfo = song title, artist, album, etc. inside the player bar
+    const playerBarInfoSelector = `${playerBarSelector} .middle-controls .content-info-wrapper`;
+    observers.playerBarInfo = new SelectorObserver(playerBarInfoSelector, {
+      ...defaultObserverOptions,
+      attributes: true,
+      attributeFilter: ["title"],
+    });
+    observers.playerBarInfo.addListener(playerBarInfoSelector, {
+      listener: () => {
+        console.log("#DBG-UU enabling playerBarTitle observer");
+        observers.playerBarInfo.enable();
+      },
+    });
+
+    // #DEBUG example: listen for title change:
+    observers.playerBarInfo.addListener("yt-formatted-string.title", {
+      continuous: true,
+      listener: (titleElem) => {
+        console.log("#DBG-UU >>>>> title changed", titleElem.title);
+      },
+    });
+
+    emitInterface("bytm:observersReady");
+  }
+  catch(err) {
+    error("Failed to initialize observers:", err);
+  }
+}
+
+/** Interface function for adding listeners to the already present observers */
+export function addSelectorListener<TElem extends Element>(observerName: ObserverName, selector: string, options: SelectorListenerOptions<TElem>) {
+  observers[observerName].addListener(selector, options);
+}

+ 20 - 6
src/events.ts → src/siteEvents.ts

@@ -1,13 +1,23 @@
 import { createNanoEvents } from "nanoevents";
 import { error, info } from "./utils";
 import { FeatureConfig } from "./types";
+import { emitInterface } from "./interface";
 
 export interface SiteEventsMap {
   // misc:
   /** Emitted whenever the feature config is changed - initialization is not counted */
   configChanged: (config: FeatureConfig) => void;
+  // TODO: implement
+  /** Emitted whenever a config option is changed - contains the old and the new values */
+  configOptionChanged: <TKey extends keyof FeatureConfig>(key: TKey, oldValue: FeatureConfig[TKey], newValue: FeatureConfig[TKey]) => void;
   /** Emitted whenever the config menu should be rebuilt, like when a config was imported */
   rebuildCfgMenu: (config: FeatureConfig) => void;
+  /** Emitted whenever the config menu is closed */
+  cfgMenuClosed: () => void;
+  /** Emitted when the welcome menu is closed */
+  welcomeMenuClosed: () => void;
+  /** Emitted whenever the user interacts with a hotkey input, used so other keyboard input event listeners don't get called while mid-input */
+  hotkeyInputActive: (active: boolean) => void;
 
   // DOM:
   /** Emitted whenever child nodes are added to or removed from the song queue */
@@ -38,28 +48,26 @@ export async function initSiteEvents() {
     const queueObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
       if(addedNodes.length > 0 || removedNodes.length > 0) {
         info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
-        siteEvents.emit("queueChanged", target as HTMLElement);
+        emitSiteEvent("queueChanged", target as HTMLElement);
       }
     });
 
     // only observe added or removed elements
-    queueObs.observe(document.querySelector(".side-panel.modular #contents.ytmusic-player-queue")!, {
+    queueObs.observe(document.querySelector("#side-panel #contents.ytmusic-player-queue")!, {
       childList: true,
     });
 
     const autoplayObs = new MutationObserver(([ { addedNodes, removedNodes, target } ]) => {
       if(addedNodes.length > 0 || removedNodes.length > 0) {
         info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`);
-        siteEvents.emit("autoplayQueueChanged", target as HTMLElement);
+        emitSiteEvent("autoplayQueueChanged", target as HTMLElement);
       }
     });
 
-    autoplayObs.observe(document.querySelector(".side-panel.modular ytmusic-player-queue #automix-contents")!, {
+    autoplayObs.observe(document.querySelector("#side-panel ytmusic-player-queue #automix-contents")!, {
       childList: true,
     });
 
-    //#SECTION home page observers
-
     info("Successfully initialized SiteEvents observers");
 
     observers = observers.concat([
@@ -71,3 +79,9 @@ export async function initSiteEvents() {
     error("Couldn't initialize SiteEvents observers due to an error:\n", err);
   }
 }
+
+/** Emits a site event with the given key and arguments */
+export function emitSiteEvent<TKey extends keyof SiteEventsMap>(key: TKey, ...args: Parameters<SiteEventsMap[TKey]>) {
+  siteEvents.emit(key, ...args);
+  emitInterface(`bytm:siteEvent:${key}`, args as unknown as undefined);
+}

+ 3 - 0
src/tools/README.md

@@ -0,0 +1,3 @@
+## Tools
+This directory contains helper tools for building and developing the userscript.  
+They aren't used inside the actual code itself.

+ 139 - 45
src/tools/post-build.ts

@@ -1,29 +1,57 @@
-import { access, readFile, writeFile, constants as fsconstants } from "fs/promises";
-import { dirname, join, relative } from "path";
-import { fileURLToPath } from "url";
-import { exec } from "child_process";
+import { access, readFile, writeFile, constants as fsconstants } from "node:fs/promises";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import { randomUUID } from "node:crypto";
+import { exec } from "node:child_process";
 import dotenv from "dotenv";
+import { outputDir as rollupCfgOutputDir, outputFile as rollupCfgOutputFile } from "../../rollup.config.mjs";
+import locales from "../../assets/locales.json" assert { type: "json" };
 import pkg from "../../package.json" assert { type: "json" };
+import type { RollupArgs } from "../types";
 
 /** Any type that is either a string or can be implicitly converted to one by having a .toString() method */
 type Stringifiable = string | { toString(): string; };
 
+/** An entry in the file `assets/require.json` */
+type RequireObj = (
+  | { version: string; npm: string; }
+  | { url: string; }
+);
+
+const buildTs = Date.now();
+/** Used to force the browser and userscript extension to refresh resources */
+const buildUuid = randomUUID();
+
 const { env, exit } = process;
 dotenv.config();
 
-const mode = process.argv.find((v) => v.trim().match(/^(--)?mode=production$/)) ? "production" : "development";
-const branch = mode === "production" ? "main" : "develop";
-const outFileSuffix = env.OUTFILE_SUFFIX ?? "";
+type CliArg<TName extends keyof Required<RollupArgs>> = Required<RollupArgs>[TName];
+
+const mode = getCliArg<CliArg<"config-mode">>("mode", "development");
+const branch = getCliArg<CliArg<"config-branch">>("branch", (mode === "production" ? "main" : "develop"));
+const host = getCliArg<CliArg<"config-host">>("host", "github");
+const suffix = getCliArg<CliArg<"config-suffix">>("suffix", "");
 
 const envPort = Number(env.DEV_SERVER_PORT);
 /** HTTP port of the dev server */
 const devServerPort = isNaN(envPort) || envPort === 0 ? 8710 : envPort;
+const devServerUserscriptUrl = `http://localhost:${devServerPort}/${rollupCfgOutputFile}`;
 
 const repo = "Sv443/BetterYTM";
-const userscriptDistFile = `BetterYTM${outFileSuffix}.user.js`;
-const distFolderPath = "./dist/";
+const userscriptDistFile = `BetterYTM${suffix}.user.js`;
+const distFolderPath = `./${rollupCfgOutputDir}/`;
 const assetFolderPath = "./assets/";
-const scriptUrl = `https://raw.githubusercontent.com/${repo}/${branch}/dist/${userscriptDistFile}`;
+const scriptUrl = (() => {
+  switch(host) {
+  case "greasyfork":
+    return "https://update.greasyfork.org/scripts/475682/BetterYTM.user.js";
+  case "openuserjs":
+    return "https://openuserjs.org/install/Sv443/BetterYTM.user.js";
+  case "github":
+  default:
+    return `https://raw.githubusercontent.com/${repo}/${branch}/dist/${userscriptDistFile}`;
+  }
+})();
 
 /** Whether to trigger the bell sound in some terminals when the code has finished compiling */
 const ringBell = Boolean(env.RING_BELL && (env.RING_BELL.length > 0 && env.RING_BELL.trim().toLowerCase() === "true"));
@@ -35,42 +63,48 @@ type BuildStats = {
 };
 
 /** Directives that are only added in dev mode */
-const devDirectives = mode === "development" ? `
-// @grant          GM.deleteValue
-// @grant          GM.registerMenuCommand
-// @grant          GM.listValues\
+const devDirectives = mode === "development" ? `\
+// @grant             GM.registerMenuCommand
+// @grant             GM.listValues\
 ` : undefined;
 
 (async () => {
   const resourcesDirectives = await getResourceDirectives();
+  const requireDirectives = await getRequireDirectives();
+  const localizedDescriptions = getLocalizedDescriptions();
 
   const header = `\
 // ==UserScript==
-// @name           ${pkg.userscriptName}
-// @namespace      ${pkg.homepage}
-// @version        ${pkg.version}
-// @description    ${pkg.description}
-// @description:de ${pkg["description:de"]}
-// @homepageURL    ${pkg.homepage}#readme
-// @supportURL     ${pkg.bugs.url}
-// @license        ${pkg.license}
-// @author         ${pkg.author.name}
-// @copyright      ${pkg.author.name} (${pkg.author.url})
-// @icon           https://raw.githubusercontent.com/${repo}/${branch}/assets/icon/icon_48.png
-// @match          https://music.youtube.com/*
-// @match          https://www.youtube.com/*
-// @run-at         document-start
-// @downloadURL    ${scriptUrl}
-// @updateURL      ${scriptUrl}
-// @connect        api.sv443.net
-// @grant          GM.getValue
-// @grant          GM.setValue
-// @grant          GM.getResourceUrl
-// @grant          GM.setClipboard
-// @grant          unsafeWindow
+// @name              ${pkg.userscriptName}
+// @namespace         ${pkg.homepage}
+// @version           ${pkg.version}
+// @description       ${pkg.description}\
+${localizedDescriptions ? "\n" + localizedDescriptions : ""}\
+// @homepageURL       ${pkg.homepage}#readme
+// @supportURL        ${pkg.bugs.url}
+// @license           ${pkg.license}
+// @author            ${pkg.author.name}
+// @copyright         ${pkg.author.name} (${pkg.author.url})
+// @icon              ${getAssetUrl("logo/logo_48.png")}
+// @match             https://music.youtube.com/*
+// @match             https://www.youtube.com/*
+// @run-at            document-start
+// @downloadURL       ${scriptUrl}
+// @updateURL         ${scriptUrl}
+// @connect           api.sv443.net
+// @connect           github.com
+// @connect           raw.githubusercontent.com
+// @grant             GM.getValue
+// @grant             GM.setValue
+// @grant             GM.deleteValue
+// @grant             GM.getResourceUrl
+// @grant             GM.setClipboard
+// @grant             GM.xmlHttpRequest
+// @grant             unsafeWindow
 // @noframes\
 ${resourcesDirectives ? "\n" + resourcesDirectives : ""}\
-${devDirectives ?? ""}
+${requireDirectives ? "\n" + requireDirectives : ""}\
+${devDirectives ? "\n" + devDirectives : ""}
 // ==/UserScript==
 /*
 ▄▄▄                    ▄   ▄▄▄▄▄▄   ▄
@@ -103,17 +137,23 @@ I welcome every contribution on GitHub!
       {
         MODE: mode,
         BRANCH: branch,
+        HOST: host,
         BUILD_NUMBER: lastCommitSha,
       },
     )
       // needs special treatment because the double quotes need to be replaced with backticks
-      .replace(/"(\/\*)?{{GLOBAL_STYLE}}(\*\/)?"/gm, `\`${globalStyle}\``);
+      .replace(/"(\/\*)?#{{GLOBAL_STYLE}}(\*\/)?"/gm, `\`${globalStyle}\``);
 
     if(mode === "production")
       userscript = remSourcemapComments(userscript);
     else
       userscript = userscript.replace(/sourceMappingURL=/gm, `sourceMappingURL=http://localhost:${devServerPort}/`);
 
+    // replace with arrow IIFE
+    userscript = userscript.replace(/\(function\s?\(ReactDOM,\s?React\)\s?\{/m, "((ReactDOM, React) => {");
+    userscript = userscript.replace(/\(function\s?\(\s*\)\s?\{/m, "(() => {");
+    userscript = userscript.replace(/\(function \(reactDom, React\) \{/m, "((reactDOM, React) => {");
+
     // insert userscript header and final newline
     const finalUserscript = `${header}\n${userscript}${userscript.endsWith("\n") ? "" : "\n"}`;
 
@@ -136,15 +176,18 @@ I welcome every contribution on GitHub!
       sizeIndicator = " \x1b[2m[\x1b[0m\x1b[1m" + (sizeDiff > 0 ? "\x1b[33m↑↑↑" : (sizeDiff !== 0 ? "\x1b[32m↓↓↓" : "\x1b[32m===")) + "\x1b[0m\x1b[2m]\x1b[0m";
     }
 
+    console.info();
     console.info(`Successfully built for ${envText}\x1b[0m - build number (last commit SHA): ${lastCommitSha}`);
-    console.info(`Outputted file '${relative("./", scriptPath)}' with a size of \x1b[34m${sizeKiB} KiB\x1b[0m${sizeIndicator}\n`);
+    console.info(`Outputted file '${relative("./", scriptPath)}' with a size of \x1b[32m${sizeKiB} KiB\x1b[0m${sizeIndicator}`);
+    console.info(`Userscript URL: \x1b[34m\x1b[4m${devServerUserscriptUrl}\x1b[0m`);
+    console.info();
 
     ringBell && process.stdout.write("\u0007");
 
     const buildStatsNew: BuildStats = {
       sizeKiB,
       mode,
-      timestamp: Date.now(),
+      timestamp: buildTs,
     };
     await writeFile(".build.json", JSON.stringify(buildStatsNew));
 
@@ -160,17 +203,17 @@ I welcome every contribution on GitHub!
   }
 })();
 
-/** Replaces tokens in the format `{{key}}` or `/⋆{{key}}⋆/` of the `replacements` param with their respective value */
+/** Replaces tokens in the format `#{{key}}` or `/⋆#{{key}}⋆/` of the `replacements` param with their respective value */
 function insertValues(userscript: string, replacements: Record<string, Stringifiable>) {
   for(const key in replacements)
-    userscript = userscript.replace(new RegExp(`(\\/\\*)?{{${key}}}(\\*\\/)?`, "gm"), String(replacements[key]));
+    userscript = userscript.replace(new RegExp(`(\\/\\*)?#{{${key}}}(\\*\\/)?`, "gm"), String(replacements[key]));
   return userscript;
 }
 
 /** Removes sourcemapping comments */
 function remSourcemapComments(input: string) {
   return input
-    .replace(/\n\s*\/(\*|\/)\s?#.+(\*\/)?$/gm, "");
+    .replace(/\/\/#\s?sourceMappingURL\s?=\s?.+$/gm, "");
 }
 
 /**
@@ -206,16 +249,19 @@ async function getResourceDirectives() {
     const resourcesFile = String(await readFile(join(assetFolderPath, "resources.json")));
     const resources = JSON.parse(resourcesFile) as Record<string, string>;
 
+    for(const [locale] of Object.entries(locales))
+      resources[`trans-${locale}`] = `translations/${locale}.json`;
+
     let longestName = 0;
     for(const name of Object.keys(resources))
       longestName = Math.max(longestName, name.length);
 
     for(const [name, path] of Object.entries(resources)) {
       const bufferSpace = " ".repeat(longestName - name.length);
-      directives.push(`// @resource       ${name}${bufferSpace} ${
+      directives.push(`// @resource          ${name}${bufferSpace} ${
         path.match(/^https?:\/\//)
           ? path
-          : `https://raw.githubusercontent.com/Sv443/BetterYTM/${branch}/assets/${path}`
+          : getAssetUrl(path)
       }`);
     }
 
@@ -225,3 +271,51 @@ async function getResourceDirectives() {
     console.warn("No resource directives found:", err);
   }
 }
+
+export async function getRequireDirectives() {
+  const directives: string[] = [];
+  const requireFile = String(await readFile(join(assetFolderPath, "require.json")));
+  const require = JSON.parse(requireFile) as RequireObj[];
+
+  for(const entry of require) {
+    "npm" in entry && "version" in entry && directives.push(`// @require           https://cdn.jsdelivr.net/npm/${entry.npm}@${entry.version}`);
+    "url" in entry && directives.push(`// @require           ${entry.url}`);
+  }
+
+  return directives.length > 0 ? directives.join("\n") : undefined;
+}
+
+/** Returns the @description directive block for each defined locale in `assets/locales.json` */
+function getLocalizedDescriptions() {
+  try {
+    const descriptions: string[] = [];
+    for(const [locale, { userscriptDesc }] of Object.entries(locales)) {
+      let loc = locale.replace(/_/, "-");
+      if(loc.length < 5)
+        loc += " ".repeat(5 - loc.length);
+      descriptions.push(`// @description:${loc} ${userscriptDesc}`);
+    }
+    return descriptions.join("\n") + "\n";
+  }
+  catch(err) {
+    console.warn("\x1b[33mNo localized descriptions found:\x1b[0m", err);
+  }
+}
+
+/** Returns the full URL for a given relative asset path, based on the current mode */
+function getAssetUrl(relativePath: string) {
+  return mode === "development"
+    ? `http://localhost:${devServerPort}/assets/${relativePath}?t=${buildUuid}`
+    : `https://raw.githubusercontent.com/${repo}/${branch}/assets/${relativePath}`;
+}
+
+/** Returns the value of a CLI argument (in the format `--arg=<value>`) or the value of `defaultVal` if it doesn't exist */
+function getCliArg<TReturn extends string = string>(name: string, defaultVal: TReturn | (string & {})): TReturn
+/** Returns the value of a CLI argument (in the format `--arg=<value>`) or undefined if it doesn't exist */
+function getCliArg<TReturn extends string = string>(name: string, defaultVal?: TReturn | (string & {})): TReturn | undefined
+/** Returns the value of a CLI argument (in the format `--arg=<value>`) or the value of `defaultVal` if it doesn't exist */
+function getCliArg<TReturn extends string = string>(name: string, defaultVal?: TReturn | (string & {})): TReturn | undefined {
+  const arg = process.argv.find((v) => v.trim().match(new RegExp(`^(--)?${name}=.+$`)));
+  const val = arg?.split("=")?.[1];
+  return (val && val.length > 0 ? val : defaultVal)?.trim() as TReturn | undefined;
+}

+ 18 - 0
src/tools/run-invisible.mjs

@@ -0,0 +1,18 @@
+import { exec } from "node:child_process";
+
+function runDetached() {
+  const command = process.argv.slice(2).join(" ");
+  const child = exec(command, (error, _stdout, _stderr) => {
+    if(error)
+      console.error("\x1b[31m[run-silent error]\x1b[0m", error);
+    // console.log("[run-silent debug] out:", _stdout ?? "undefined", "err:", _stderr ?? "undefined");
+  });
+  child.on("exit", (code, signal) => {
+    if(code !== null)
+      setImmediate(() => process.exit(code));
+    if(signal !== null)
+      setImmediate(() => process.kill(process.pid, signal));
+  });
+}
+
+runDetached();

+ 15 - 11
src/tools/serve.ts

@@ -1,7 +1,7 @@
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
 import express, { NextFunction, Request, Response } from "express";
-import { resolve } from "path";
-import { fileURLToPath } from "url";
-import { output as webpackCfgOutput } from "../../webpack.config.js";
+import { outputDir } from "../../rollup.config.mjs";
 import "dotenv/config";
 
 const envPort = Number(process.env.DEV_SERVER_PORT);
@@ -24,20 +24,24 @@ app.use((err: unknown, _req: Request, _res: Response, _next: NextFunction) => {
     console.error("\x1b[31mError in dev server:\x1b[0m\n", err);
 });
 
-app.use((_req, res, next) => {
-  res.setHeader("Cache-Control", "no-store");
-  next();
-});
+// app.use((_req, res, next) => {
+//   res.setHeader("Cache-Control", "no-store");
+//   next();
+// });
+// serves everything from `rollupConfig.output.path` (`dist/` by default)
+app.use("/", express.static(
+  resolve(fileURLToPath(import.meta.url), `../../../${outputDir}`)
+));
 
-// serves everything from `webpackConfig.output.path` (`dist/` by default)
-app.use(express.static(
-  resolve(fileURLToPath(import.meta.url), "../../", webpackCfgOutput.path)
+app.use("/assets", express.static(
+  resolve(fileURLToPath(import.meta.url), "../../../assets/")
 ));
 
 app.listen(devServerPort, "0.0.0.0", () => {
-  console.log(`The dev server is running.\nUserscript is served at \x1b[34m\x1b[4mhttp://localhost:${devServerPort}/${webpackCfgOutput.filename}\x1b[0m`);
+  console.log(`Dev server is running on port ${devServerPort}`);
   if(enableLogging)
     process.stdout.write("\nRequests: ");
   else
     console.log("\x1b[2m(request logging is disabled)\x1b[0m");
+  console.log();
 });

+ 86 - 0
src/tools/tr-format.ts

@@ -0,0 +1,86 @@
+import { readFile, writeFile } from "node:fs/promises";
+import type { TrLocale } from "../translations";
+import locales from "../../assets/locales.json" assert { type: "json" };
+
+const prepTranslate = process.argv.find((v) => v.match(/--prep(are)?/) || v.toLowerCase() === "-p");
+
+const onlyLocalesRaw = process.argv.find((v) => v.startsWith("--only") || v.toLowerCase().startsWith("-o"));
+const onlyLocales = onlyLocalesRaw?.split("=")[1]?.replace(/"/g, "")
+  ?.replace(/\s/g, "")?.split(",") as TrLocale[] | undefined;
+
+const includeBased = Boolean(process.argv.find((v) => v.match(/--include-based/) || v.toLowerCase() === "-b"));
+
+async function run() {
+  console.log("\nReformatting translation files...");
+  const en_US = await readFile("./assets/translations/en_US.json", "utf-8");
+  const en_US_obj = JSON.parse(en_US);
+
+  const localeKeysRaw = Object.keys(locales) as TrLocale[];
+  const localeKeys = localeKeysRaw.filter((key) => key !== "en_US") as Exclude<TrLocale, "en_US">[];
+
+  let reformattedAmt = 0;
+
+  for(const locale of localeKeys) {
+    if(onlyLocales && !onlyLocales.includes(locale))
+      continue;
+
+    // use en_US as base, replace values with values from locale file
+
+    let localeFile = en_US;
+    const localeObj = JSON.parse(await readFile(`./assets/translations/${locale}.json`, "utf-8"));
+
+    if(!includeBased && localeObj.base)
+      continue;
+
+    for(const k of Object.keys(en_US_obj.translations)) {
+      const val = localeObj?.translations?.[k];
+      if(val)
+        localeFile = localeFile.replace(new RegExp(`"${k}":\\s+".*"`, "m"), `"${k}": "${escapeJsonVal(val).trim()}"`);
+      else {
+        if(prepTranslate)
+          localeFile = localeFile.replace(new RegExp(`\\n\\s+"${k}":\\s+".*",?`, "m"), `\n    "${k}": "",\n    "${k}": "${escapeJsonVal(en_US_obj.translations[k]).trim()}",`);
+        else
+          localeFile = localeFile.replace(new RegExp(`\\n\\s+"${k}":\\s+".*",?`, "m"), "");
+      }
+    }
+
+    // remove last trailing comma if present
+
+    const pattern = /^\s*".*":\s+".*",?$/gm;
+    const matchesAmt = localeFile.match(pattern)?.length ?? 0;
+    let match: RegExpExecArray | null = null;
+    let i = 0;
+    while(match = pattern.exec(localeFile)) {
+      const part = localeFile.substring(match.index, pattern.lastIndex);
+
+      if(i === matchesAmt - 1) {
+        if(part.endsWith(","))
+          localeFile = localeFile.replace(part, part.substring(0, part.length - 1));
+      }
+
+      i++;
+    }
+
+    // reinsert base if present in locale file
+
+    if(localeObj.base)
+      localeFile = localeFile.replace(/\s*\{\s*/, `{\n  "base": "${localeObj.base}",\n  `);
+
+    // overwrite original file
+    // (backup is available through git history so idc)
+
+    await writeFile(`./assets/translations/${locale}.json`, localeFile);
+
+    reformattedAmt++;
+  }
+  console.log(`\nDone reformatting \x1b[32m${reformattedAmt}\x1b[0m translation file${reformattedAmt === 1 ? "" : "s"}!\n`);
+}
+
+/** Escapes various characters for use as a JSON value */
+function escapeJsonVal(val: string) {
+  return val
+    .replace(/\n/gm, "\\n")
+    .replace(/"/gm, "\\\"");
+}
+
+run();

+ 19 - 0
src/tools/tr-progress-template.md

@@ -0,0 +1,19 @@
+## BetterYTM - Translations
+To submit or edit a translation, please follow [this guide](../../contributing.md#submitting-translations)
+
+<br>
+
+### Translation progress:
+| Locale | Translated keys | Based on |
+| ------ | --------------- | :------: |
+<!--#{{TR_PROGRESS_TABLE}}-->
+
+<br>
+
+If a translation is based on another translation, that means the keys from the base translation file are automatically applied if they are missing. This is used for locales that are very similar to each other, such as `en_UK` and `en_US`  
+This means you need to manually check against the base translations for missing keys if you want to improve translations.
+
+<br>
+
+### Missing keys:
+<!--#{{TR_MISSING_KEYS}}-->

+ 104 - 0
src/tools/tr-progress.ts

@@ -0,0 +1,104 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { join, relative, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { autoPlural, mapRange } from "@sv443-network/userutils";
+import locales from "../../assets/locales.json" assert { type: "json" };
+import type { TrLocale } from "../translations";
+
+const { exit } = process;
+
+const rootDir = resolve(fileURLToPath(import.meta.url), "../../../");
+const trDir = join(rootDir, "assets/translations/");
+
+interface TrFile {
+  base: string | undefined;
+  translations: Record<string, string>;
+}
+
+async function run() {
+  console.log("\n\x1b[34mUpdating translation progress...\x1b[0m\n");
+
+  //#SECTION parse
+
+  const translations = {} as Record<TrLocale, Record<string, string>>;
+  const trFiles = {} as Record<TrLocale, TrFile>;
+
+  for(const locale of Object.keys(locales) as TrLocale[]) {
+    const trFile = join(trDir, `${locale}.json`);
+    const tr = JSON.parse(await readFile(trFile, "utf-8")) as TrFile;
+
+    let baseTr = {} as Record<string, string>;
+    if(tr.base)
+      baseTr = (JSON.parse(await readFile(join(trDir, `${tr.base}.json`), "utf-8")) as TrFile).translations;
+
+    translations[locale] = { ...baseTr, ...tr.translations };
+    trFiles[locale] = tr;
+  }
+
+  const trs = Object.keys(translations);
+  console.log(`Found ${trs.length} ${autoPlural("locale", trs)}:`, trs.join(", "));
+
+  const { en_US, ...restLocs } = translations;
+  const progress = {} as Record<TrLocale, number>;
+
+  //#SECTION table
+
+  const tableLines: string[] = [];
+
+  for(const [locale, translations] of Object.entries({ en_US, ...restLocs })) {
+    for(const [k] of Object.entries(en_US)) {
+      if(translations[k]) {
+        if(!progress[locale as TrLocale])
+          progress[locale as TrLocale] = 0;
+        progress[locale as TrLocale] += 1;
+      }
+    }
+
+    const trKeys = progress[locale as TrLocale];
+    const origKeys = Object.keys(en_US).length;
+    const percent = mapRange(trKeys, 0, origKeys, 0, 100).toFixed(1);
+
+    const sym = trKeys === origKeys ? "✅" : "🚫";
+
+    const keysCol = locale === "en_US" ? `${origKeys} (default locale)` : `${sym} \`${trKeys}/${origKeys}\` (${percent}%)`;
+
+    const baseTr = trFiles[locale as TrLocale]?.base;
+
+    tableLines.push(`| [\`${locale}\`](./${locale}.json) | ${keysCol} | ${baseTr ? `\`${baseTr}\`` : (locale === "en_US" ? "" : "─")} |`);
+    console.log(`  ${sym} ${locale}: ${trKeys}/${origKeys} (${percent}%)${baseTr ? ` (base: ${baseTr})`: ""}`);
+  }
+
+  //#SECTION missing keys
+
+  const missingKeys = [] as string[];
+
+  for(const [locale, translations] of Object.entries({ en_US, ...restLocs })) {
+    const lines = [] as string[];
+    for(const [k] of Object.entries(en_US)) {
+      if(!translations[k])
+        lines.push(`| \`${k}\` | \`${en_US[k].replace(/\n/gm, "\\n")}\` |`);
+    }
+    if(lines.length > 0) {
+      missingKeys.push(`
+<details><summary><code>${locale}</code> - ${lines.length} missing ${autoPlural("key", lines)} <i>(click to show)</i></summary><br>\n
+| Key | English text |
+| --- | ------------ |
+${lines.join("\n")}\n
+<br></details>`);
+    }
+  }
+
+  //#SECTION finalize
+
+  let templateCont = String(await readFile(join(rootDir, "src/tools/tr-progress-template.md"), "utf-8"));
+  templateCont = templateCont
+    .replace(/<!--#{{TR_PROGRESS_TABLE}}-->/m, tableLines.join("\n"))
+    .replace(/<!--#{{TR_MISSING_KEYS}}-->/m, missingKeys.length > 0 ? missingKeys.join("\n") : "No missing keys");
+  await writeFile(join(trDir, "README.md"), templateCont);
+
+  console.log(`\n\x1b[32mFinished updating translation progress\x1b[0m - updated file at '${relative(rootDir, join(trDir, "README.md"))}'\n`);
+
+  setImmediate(() => exit(0));
+}
+
+run();

+ 92 - 0
src/translations.ts

@@ -0,0 +1,92 @@
+import { tr, Stringifiable, fetchAdvanced, FetchAdvancedOpts } from "@sv443-network/userutils";
+import { error, getResourceUrl, info } from "./utils";
+import langMapping from "../assets/locales.json" assert { type: "json" };
+import type tr_enUS from "../assets/translations/en_US.json";
+import { emitInterface, setGlobalProp } from "./interface";
+
+export type TrLocale = keyof typeof langMapping;
+export type TrKey = keyof (typeof tr_enUS["translations"]);
+export type TrInfo = (typeof langMapping)["en_US"];
+type TFuncKey = TrKey | (string & {});
+
+const fetchOpts: FetchAdvancedOpts = {
+  timeout: 10000,
+};
+
+/** Contains all translation keys of all initialized and loaded translations */
+const allTrKeys = new Map<TrLocale, Set<TFuncKey>>();
+/** Contains the identifiers of all initialized and loaded translation locales */
+const initializedLocales = new Set<TrLocale>();
+
+/** Initializes the translations */
+export async function initTranslations(locale: TrLocale) {
+  if(initializedLocales.has(locale))
+    return;
+
+  initializedLocales.add(locale);
+
+  try {
+    const transUrl = await getResourceUrl(`trans-${locale}` as "_");
+    const transFile = await (await fetchAdvanced(transUrl, fetchOpts)).json();
+
+    // merge with base translations if specified
+    const baseTransUrl = transFile.base ? await getResourceUrl(`trans-${transFile.base}` as "_") : undefined;
+    const baseTransFile = baseTransUrl ? await (await fetchAdvanced(baseTransUrl, fetchOpts)).json() : undefined;
+
+    const translations = { ...(baseTransFile?.translations ?? {}), ...transFile.translations };
+
+    tr.addLanguage(locale, translations);
+    allTrKeys.set(locale, new Set(Object.keys(translations)));
+
+    info(`Loaded translations for locale '${locale}'`);
+  }
+  catch(err) {
+    const errStr = `Couldn't load translations for locale '${locale}'`;
+    error(errStr, err);
+    throw new Error(errStr);
+  }
+}
+
+/** Sets the current language for translations */
+export function setLocale(locale: TrLocale) {
+  tr.setLanguage(locale);
+  setGlobalProp("locale", locale);
+  emitInterface("bytm:setLocale", { locale });
+}
+
+/** Returns the currently set language */
+export function getLocale() {
+  return tr.getLanguage() as TrLocale;
+}
+
+/** Returns whether the given translation key exists in the current locale */
+export function hasKey(key: TFuncKey) {
+  return hasKeyFor(getLocale(), key);
+}
+
+/** Returns whether the given translation key exists in the given locale */
+export function hasKeyFor(locale: TrLocale, key: TFuncKey) {
+  return allTrKeys.get(locale)?.has(key) ?? false;
+}
+
+/** Returns the translated string for the given key, after optionally inserting values */
+export function t(key: TFuncKey, ...values: Stringifiable[]) {
+  return tr(key, ...values);
+}
+
+/**
+ * Returns the translated string for the given key with an added pluralization identifier based on the passed `num`  
+ * Tries to fall back to the non-pluralized syntax if no translation was found
+ */
+export function tp(key: TFuncKey, num: number | unknown[] | NodeList, ...values: Stringifiable[]) {
+  if(typeof num !== "number")
+    num = num.length;
+  const plNum = num === 1 ? "1" : "n";
+
+  const trans = t(`${key}-${plNum}`, ...values);
+
+  if(trans === key)
+    return t(key, ...values);
+
+  return trans;
+}

+ 215 - 27
src/types.ts

@@ -1,31 +1,189 @@
-/** Env object passed to webpack.config.js */
-export type WebpackEnv = Partial<Record<"mode", "production" | "development">> & Record<"WEBPACK_BUNDLE" | "WEBPACK_BUILD", boolean>;
+import type { TrLocale, t, tp } from "./translations";
+import type * as consts from "./constants";
+import type { scriptInfo } from "./constants";
+import type { addSelectorListener } from "./observers";
+import type resources from "../assets/resources.json";
+import type langMapping from "../assets/locales.json";
+import type { getResourceUrl, getSessionId, getVideoTime } from "./utils";
+import type { getFeatures, saveFeatures } from "./config";
 
-/** 0 = Debug, 1 = Info */
-export type LogLevel = 0 | 1;
+/** Custom CLI args passed to rollup */
+export type RollupArgs = Partial<{
+  "config-mode": "development" | "production";
+  "config-branch": "main" | "develop";
+  "config-host": "greasyfork" | "github" | "openuserjs";
+  "config-suffix": string;
+}>;
+
+// I know TS enums are impure but it doesn't really matter here, plus they look cooler
+export enum LogLevel {
+  Debug,
+  Info,
+}
 
 /** Which domain this script is currently running on */
 export type Domain = "yt" | "ytm";
 
-/** Feature configuration */
-export interface FeatureConfig {
-  //#SECTION input
-  /** Arrow keys skip forwards and backwards by 10 seconds */
-  arrowKeySupport: boolean;
-  /** Add F9 as a hotkey to switch between the YT and YTM sites on a video / song */
-  switchBetweenSites: boolean;
-  /** The hotkey that needs to be pressed to initiate the site switch */
-  switchSitesHotkey: {
-    key: string;
-    shift: boolean;
-    ctrl: boolean;
-    meta: boolean;
+/** A URL string that starts with "http://" or "https://" */
+export type HttpUrlString = `http://${string}` | `https://${string}`;
+
+/** Key of a resource in `assets/resources.json` and extra keys defined by `tools/post-build.ts` */
+export type ResourceKey = keyof typeof resources | `trans-${keyof typeof langMapping}` | "changelog";
+
+/** Describes a single hotkey */
+export type HotkeyObj = {
+  code: string,
+  shift: boolean,
+  ctrl: boolean,
+  alt: boolean,
+};
+
+export type ObserverName = "body" | "playerBar" | "playerBarInfo";
+
+/** All functions exposed by the interface on the global `BYTM` object */
+export type InterfaceFunctions = {
+  /** Adds a listener to one of the already present SelectorObserver instances */
+  addSelectorListener: typeof addSelectorListener;
+  /**
+   * Returns the URL of a resource as defined in `assets/resources.json`  
+   * There are also some resources like translation files that get added by `tools/post-build.ts`  
+   *   
+   * The returned URL is a `blob:` URL served up by the userscript extension  
+   * This makes the resource fast to fetch and also prevents CORS issues
+   */
+  getResourceUrl: typeof getResourceUrl;
+  /** Returns the unique session ID for the current tab */
+  getSessionId: typeof getSessionId;
+  /**
+   * Returns the current video time (on both YT and YTM)  
+   * In case it can't be determined on YT, mouse movement is simulated to bring up the video time  
+   * In order for that edge case not to error out, the function would need to be called in response to a user interaction event (e.g. click) due to the strict autoplay policy in browsers
+   */
+  getVideoTime: typeof getVideoTime;
+  /** Returns the translation for the provided translation key and set locale (check the files in the folder `assets/translations`) */
+  t: typeof t;
+  /** Returns the translation for the provided translation key, including pluralization identifier and set locale (check the files in the folder `assets/translations`) */
+  tp: typeof tp;
+  /** Returns the current feature configuration */
+  getFeatures: typeof getFeatures;
+  /** Overwrites the feature configuration with the provided one */
+  saveFeatures: typeof saveFeatures;
+};
+
+// shim for the BYTM interface properties
+export type BytmObject =
+  // properties defined and modified by BYTM at runtime
+  {
+    [key: string]: unknown;
+    locale: TrLocale;
+    logLevel: LogLevel;
+  }
+  // information from the userscript header
+  & typeof scriptInfo
+  // certain variables from `src/constants.ts`
+  & Pick<typeof consts, "mode" | "branch">
+  // global functions exposed through the interface in `src/interface.ts`
+  & InterfaceFunctions
+  // others
+  & {
+    // the entire UserUtils library
+    UserUtils: typeof import("@sv443-network/userutils");
   };
-  /** Whether to completely disable the popup that sometimes appears before leaving the site */
-  disableBeforeUnloadPopup: boolean;
-  /** Make it so middle clicking a song to open it in a new tab (through thumbnail and song title) is easier */
-  anchorImprovements: boolean;
 
+declare global {
+  interface Window {
+    // to see the expanded type, install the VS Code extension "MylesMurphy.prettify-ts"
+    // and hover over the "BytmObject" just below:
+    BYTM: BytmObject;
+  }
+}
+
+export type FeatureKey = keyof FeatureConfig;
+
+export type FeatureCategory =
+  | "layout"
+  | "songLists"
+  | "behavior"
+  | "input"
+  | "lyrics"
+  | "general";
+
+type SelectOption = {
+  value: string | number,
+  label: string,
+};
+
+type FeatureTypeProps = 
+  | {
+    type: "toggle",
+    default: boolean,
+  }
+  | {
+    type: "number",
+    default: number,
+    min: number,
+    max: number,
+    step?: number,
+    unit?: string,
+  }
+  | {
+    type: "select",
+    default: string | number,
+    options: SelectOption[] | (() => SelectOption[]),
+  }
+  | {
+    type: "slider",
+    default: number,
+    min: number,
+    max: number,
+    step?: number,
+    unit?: string,
+  }
+  | {
+    type: "hotkey",
+    default: HotkeyObj,
+  };
+
+type FeatureFuncProps = {
+  /** Called to instantiate the feature on the page */
+  enable: () => void,
+} & (
+  {
+    /** Called to remove all traces of the feature from the page and memory (includes event listeners) */
+    disable?: () => void,
+  }
+  | {
+    /** Called to update the feature's behavior when the config changes */
+    change?: () => void,
+  }
+)
+
+/**
+ * The feature info object that contains all properties necessary to construct the config menu and the feature config object.  
+ * Values are loosely typed so try to only use this with the `satisfies` keyword.  
+ * Use `typeof featInfo` (from `src/features/index.ts`) instead for full type safety.
+ */
+export type FeatureInfo = Record<
+  keyof FeatureConfig,
+  {
+    category: FeatureCategory;
+    /**
+     * HTML string that will be the help text for this feature  
+     * Specifying a function is useful for pluralizing or inserting values into the translation at runtime
+     */
+    helpText?: string | (() => string);
+    /**
+     * HTML string that is appended to the end of a feature's text description
+     * @deprecated TODO:FIXME: To be removed or changed in the big menu rework
+     */
+    textAdornment?: () => (Promise<string> | string);
+  }
+    & FeatureTypeProps
+    & FeatureFuncProps
+>;
+
+/** Feature configuration */
+export interface FeatureConfig {
   //#SECTION layout
   /** Remove the \"Upgrade\" / YT Music Premium tab */
   removeUpgradeTab: boolean;
@@ -35,28 +193,58 @@ export interface FeatureConfig {
   volumeSliderSize: number;
   /** Volume slider sensitivity - the smaller this number, the finer the volume control */
   volumeSliderStep: number;
+  /** Volume slider scroll wheel sensitivity */
+  volumeSliderScrollStep: number;
   /** Show a BetterYTM watermark under the YTM logo */
   watermarkEnabled: boolean;
-  /** Add a button to each song in the queue to quickly remove it */
-  deleteFromQueueButton: boolean;
-  /** After how many milliseconds to close permanent toasts */
-  closeToastsTimeout: number;
   /** Remove the "si" tracking parameter from links in the share popup */
   removeShareTrackingParam: boolean;
   /** Enable skipping to a specific time in the video by pressing a number key (0-9) */
   numKeysSkipToTime: boolean;
   /** Fix spacing issues in the layout */
   fixSpacing: boolean;
+
+  //#SECTION song lists
+  /** Add a button to each song in the queue to quickly open its lyrics page */
+  lyricsQueueButton: boolean;
+  /** Add a button to each song in the queue to quickly remove it */
+  deleteFromQueueButton: boolean;
+  /** Where to place the buttons in the queue */
+  listButtonsPlacement: "queueOnly" | "everywhere";
   /** Add a button to the queue to scroll to the currently playing song */
   scrollToActiveSongBtn: boolean;
 
+  //#SECTION behavior
+  /** Whether to completely disable the popup that sometimes appears before leaving the site */
+  disableBeforeUnloadPopup: boolean;
+  /** After how many milliseconds to close permanent toasts */
+  closeToastsTimeout: number;
+  /** Remember the last song's time when reloading or restoring the tab */
+  rememberSongTime: boolean;
+  /** Where to remember the song time */
+  rememberSongTimeSites: Domain | "all";
+
+  //#SECTION input
+  /** Arrow keys skip forwards and backwards */
+  arrowKeySupport: boolean;
+  /** By how many seconds to skip when pressing the arrow keys */
+  arrowKeySkipBy: number;
+  /** Add a hotkey to switch between the YT and YTM sites on a video / song */
+  switchBetweenSites: boolean;
+  /** The hotkey that needs to be pressed to initiate the site switch */
+  switchSitesHotkey: HotkeyObj;
+  /** Make it so middle clicking a song to open it in a new tab (through thumbnail and song title) is easier */
+  anchorImprovements: boolean;
+
   //#SECTION lyrics
   /** Add a button to the media controls to open the current song's lyrics on genius.com in a new tab */
   geniusLyrics: boolean;
-  /** Add a button to each song in the queue to quickly open its lyrics page */
-  lyricsQueueButton: boolean;
 
   //#SECTION misc
+  /** The locale to use for translations */
+  locale: TrLocale;
+  /** Whether to check for updates to the script */
+  versionCheck: boolean;
   /** The console log level - 0 = Debug, 1 = Info */
   logLevel: LogLevel;
 }

Деякі файли не було показано, через те що забагато файлів було змінено