[
  {
    "path": ".editorconfig",
    "content": "# EditorConfig is awesome: https://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n# Unix-style newlines with a newline ending every file\n[*]\nend_of_line = lf\ninsert_final_newline = true\n\n[*.{js,json,ts,tsx}]\ncharset = utf-8\nindent_style = space\nindent_size = 4\n\n[package.json]\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n    \"env\": {\n        \"browser\": true,\n        \"es2021\": true,\n        \"node\": true,\n        \"jest\": true\n    },\n    \"extends\": [\n        \"eslint:recommended\",\n        \"plugin:react/recommended\",\n        \"plugin:@typescript-eslint/recommended\"\n    ],\n    \"parser\": \"@typescript-eslint/parser\",\n    \"parserOptions\": {\n        \"ecmaFeatures\": {\n            \"jsx\": true\n        },\n        \"ecmaVersion\": 12,\n        \"sourceType\": \"module\"\n    },\n    \"plugins\": [\"react\", \"@typescript-eslint\"],\n    \"rules\": {\n        \"@typescript-eslint/no-unused-vars\": \"error\",\n        \"no-self-assign\": \"off\",\n        \"@typescript-eslint/no-empty-interface\": \"off\",\n        \"react/prop-types\": [2, { \"ignore\": [\"children\"] }],\n        \"@typescript-eslint/member-delimiter-style\": \"warn\",\n        \"@typescript-eslint/no-non-null-assertion\": \"off\",\n        \"@typescript-eslint/ban-ts-comment\": \"off\",\n        \"@typescript-eslint/no-this-alias\": \"off\"\n    },\n    \"settings\": {\n        \"react\": {\n            \"version\": \"detect\"\n        }\n    }\n}\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: ajayyy-org\npatreon: ajayyy\ncustom: [sponsor.ajay.app/donate]\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "- [ ] I agree to license my contribution under GPL-3.0 and agree to allow distribution on app stores as outlined in [LICENSE-APPSTORE](https://github.com/ajayyy/SponsorBlock/blob/master/LICENSE-APPSTORE.txt)\n\nTo test this pull request, follow the [instructions in the wiki](https://github.com/ajayyy/SponsorBlock/wiki/Testing-a-Pull-Request).\n\n***\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non: [push, pull_request]\n\njobs:\n\n  build:\n    name: Create artifacts\n    runs-on: ubuntu-latest\n\n    steps:\n      # Initialization\n      - uses: actions/checkout@v4\n        with:\n          submodules: recursive\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n      - run: npm ci\n      - name: Copy configuration\n        run: cp config.json.example config.json\n\n      # Run linter\n      - name: Lint\n        run: npm run lint\n\n      # Create Chrome artifacts\n      - name: Create Chrome artifacts\n        run: npm run build:chrome\n      - uses: actions/upload-artifact@v4\n        with:\n          name: ChromeExtension\n          path: dist\n      - run: mkdir ./builds\n      - uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28\n        with:\n          args: zip -qq -r ./builds/ChromeExtension.zip ./dist\n\n      # Create Firefox artifacts\n      - name: Create Firefox artifacts\n        run: npm run build:firefox\n      - uses: actions/upload-artifact@v4\n        with:\n          name: FirefoxExtension\n          path: dist\n      - uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28\n        with:\n          args: zip -qq -r ./builds/FirefoxExtension.zip ./dist\n\n      # Create Beta artifacts (Builds with the name changed to beta)\n      - name: Create Chrome Beta artifacts\n        run: npm run build:chrome -- --env stream=beta\n      - uses: actions/upload-artifact@v4\n        with:\n          name: ChromeExtensionBeta\n          path: dist\n      - uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28\n        with:\n          args: zip -qq -r ./builds/ChromeExtensionBeta.zip ./dist\n\n      - name: Create Firefox Beta artifacts\n        run: npm run build:firefox -- --env stream=beta\n      - uses: actions/upload-artifact@v4\n        with:\n          name: FirefoxExtensionBeta\n          path: dist\n      - uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28\n        with:\n          args: zip -qq -r ./builds/FirefoxExtensionBeta.zip ./dist\n\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Upload Release Build\n\non: \n  release:\n    types: [published]\n\njobs:\n\n  build:\n    name: Upload Release\n    runs-on: ubuntu-latest\n\n    steps:\n      # Initialization\n      - name: Checkout release branch w/submodules\n        uses: actions/checkout@v5\n        with:\n          submodules: recursive\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n      - name: Copy configuration\n        run: cp config.json.example config.json\n\n      # Create source artifact with submodule\n      - name: Create directory\n        run: cd ..; mkdir ./builds\n      - name: Zip Source code\n        run: zip -r ../builds/SourceCodeUseThisOne.zip *\n      - name: Upload Source to release\n        uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467\n        with:\n          args: ../builds/SourceCodeUseThisOne.zip\n          name: SourceCodeUseThisOne.zip\n          path: ../builds/SourceCodeUseThisOne.zip\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Checkout source maps branch\n        uses: actions/checkout@v5\n        with:\n          path: source-maps\n          ref: source-maps\n      - name: Set up committer info\n        run: |\n          git config --global user.name \"github-actions[bot]\"\n          git config --global user.email \"41898282+github-actions[bot]@users.noreply.github.com\"\n      - run: npm ci\n\n      # Create Firefox artifacts\n      - name: Create Firefox artifacts\n        run: npm run build:firefox -- --env ghpSourceMaps\n      - run: mkdir ./builds\n      - name: Move Firefox source maps to source map repo\n        run: |\n          VERSION=`jq -r '.version' ./dist/manifest.json`\n          mkdir -p \"./source-maps/firefox/$VERSION/\"\n          mv -v ./dist/**/*.js.map \"./source-maps/firefox/$VERSION/\"\n          cp -v ./dist/**/*.js.LICENSE.txt \"./source-maps/firefox/$VERSION/\"\n      - name: Zip Artifacts\n        run: cd ./dist ; zip -r ../builds/FirefoxExtension.zip *\n      - name: Upload FirefoxExtension to release\n        uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467\n        with:\n          args: builds/FirefoxExtension.zip\n          name: FirefoxExtension.zip\n          path: ./builds/FirefoxExtension.zip\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n      \n      # Create Chrome artifacts\n      - name: Create Chrome artifacts\n        run: npm run build:chrome -- --env ghpSourceMaps\n      - name: Move Chrome source maps to source map repo\n        run: |\n          VERSION=`jq -r '.version' ./dist/manifest.json`\n          mkdir -p \"./source-maps/chrome/$VERSION/\"\n          mv -v ./dist/**/*.js.map \"./source-maps/chrome/$VERSION/\"\n          cp -v ./dist/**/*.js.LICENSE.txt \"./source-maps/chrome/$VERSION/\"\n      - name: Zip Artifacts\n        run: cd ./dist ; zip -r ../builds/ChromeExtension.zip *\n      - name: Upload ChromeExtension to release\n        uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467\n        with:\n          args: builds/ChromeExtension.zip\n          name: ChromeExtension.zip\n          path: ./builds/ChromeExtension.zip\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n      # Create Edge artifacts\n      - name: Clear dist for Edge\n        run: rm -rf ./dist\n      - name: Create Edge artifacts\n        run: npm run build:edge -- --env ghpSourceMaps\n      - name: Move Edge source maps to source map repo\n        run: |\n          VERSION=`jq -r '.version' ./dist/manifest.json`\n          mkdir -p \"./source-maps/edge/$VERSION/\"\n          mv -v ./dist/**/*.js.map \"./source-maps/edge/$VERSION/\"\n          cp -v ./dist/**/*.js.LICENSE.txt \"./source-maps/edge/$VERSION/\"\n      - name: Zip Artifacts\n        run: cd ./dist ; zip -r ../builds/EdgeExtension.zip *\n      - name: Upload EdgeExtension to release\n        uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467\n        with:\n          args: builds/EdgeExtension.zip\n          name: EdgeExtension.zip\n          path: ./builds/EdgeExtension.zip\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n      # Create Safari artifacts\n      - name: Create Safari artifacts\n        run: npm run build:safari -- --env ghpSourceMaps\n      - name: Move Safari source maps to source map repo\n        run: |\n          VERSION=`jq -r '.version' ./dist/manifest.json`\n          mkdir -p \"./source-maps/safari/$VERSION/\"\n          mv -v ./dist/**/*.js.map \"./source-maps/safari/$VERSION/\"\n          cp -v ./dist/**/*.js.LICENSE.txt \"./source-maps/safari/$VERSION/\"\n      - name: Zip Artifacts\n        run: cd ./dist ; zip -r ../builds/SafariExtension.zip *\n      - name: Upload SafariExtension to release\n        uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467\n        with:\n          args: builds/SafariExtension.zip\n          name: SafariExtension.zip\n          path: ./builds/SafariExtension.zip\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n      # Create Beta artifacts (Builds with the name changed to beta)\n      - name: Create Chrome Beta artifacts\n        run: npm run build:chrome -- --env stream=beta --env ghpSourceMaps\n      - name: Move Chrome Beta source maps to source map repo\n        run: |\n          VERSION=`jq -r '.version' ./dist/manifest.json`\n          mkdir -p \"./source-maps/chrome-beta/$VERSION/\"\n          mv -v ./dist/**/*.js.map \"./source-maps/chrome-beta/$VERSION/\"\n          cp -v ./dist/**/*.js.LICENSE.txt \"./source-maps/chrome-beta/$VERSION/\"\n      - name: Zip Artifacts\n        run: cd ./dist ; zip -r ../builds/ChromeExtensionBeta.zip *\n      - name: Upload ChromeExtensionBeta to release\n        uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467\n        with:\n          args: builds/ChromeExtensionBeta.zip\n          name: ChromeExtensionBeta.zip\n          path: ./builds/ChromeExtensionBeta.zip\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n      # Firefox Beta\n      - name: Create Firefox Beta artifacts\n        run: npm run build:firefox -- --env stream=beta --env autoupdate --env ghpSourceMaps\n      - uses: actions/upload-artifact@v4\n        with:\n          name: FirefoxExtensionBeta\n          path: dist\n      - name: Move Firefox Beta source maps to source map repo\n        run: |\n          VERSION=`jq -r '.version' ./dist/manifest.json`\n          mkdir -p \"./source-maps/firefox-beta/$VERSION/\"\n          mv -v ./dist/**/*.js.map \"./source-maps/firefox-beta/$VERSION/\"\n          cp -v ./dist/**/*.js.LICENSE.txt \"./source-maps/firefox-beta/$VERSION/\"\n      - name: Zip Artifacts\n        run: cd ./dist ; zip -r ../builds/FirefoxExtensionBeta.zip *\n\n      # Create Firefox Signed Beta version\n      - name: Create Firefox Signed Beta artifacts\n        run: npm run web-sign\n        env:\n          WEB_EXT_API_KEY: ${{ secrets.WEB_EXT_API_KEY }}\n          WEB_EXT_API_SECRET: ${{ secrets.WEB_EXT_API_SECRET }}\n      - name: Rename signed file\n        run: mv ./web-ext-artifacts/* ./web-ext-artifacts/FirefoxSignedInstaller.xpi\n      - uses: actions/upload-artifact@v4\n        with:\n          name: FirefoxExtensionSigned.xpi\n          path: ./web-ext-artifacts/FirefoxSignedInstaller.xpi\n\n      - name: Upload FirefoxSignedInstaller.xpi to release\n        uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467\n        with:\n          args: web-ext-artifacts/FirefoxSignedInstaller.xpi\n          name: FirefoxSignedInstaller.xpi\n          path: ./web-ext-artifacts/FirefoxSignedInstaller.xpi\n          repo-token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Commit & push new source maps\n        if: always()\n        run: |\n          VERSION=`jq -r '.version' ./dist/manifest.json`\n          cd ./source-maps\n          git add .\n          git commit -m \"Publish source maps for version $VERSION\"\n          git push\n\n      - name: Prepare new github pages deployment\n        shell: python\n        run: |\n          from pathlib import Path\n          import json\n          import shutil\n          import os\n\n          # config stuff\n          installer_name = \"FirefoxSignedInstaller.xpi\"\n          owner, repo_name = os.environ[\"GITHUB_REPOSITORY\"].split(\"/\")\n          owner = owner.lower()\n\n          # create the github paged dir\n          ghp_dir = Path(\"./github-pages\")\n          ghp_dir.mkdir(parents=True, exist_ok=True)\n\n          # move in the installer\n          Path(\"./web-ext-artifacts\", installer_name).rename(ghp_dir / installer_name)\n\n          # read manifest.json and extract parameters\n          with open(\"./dist/manifest.json\") as f:\n            manifest = json.load(f)\n          current_version = manifest[\"version\"]\n          ext_id = manifest[\"browser_specific_settings\"][\"gecko\"][\"id\"]\n\n          # generate updates file\n          updates = {\n            \"addons\": {\n              ext_id: {\n                \"updates\": [\n                  {\n                    \"version\": current_version,\n                    # param doesn't actually matter, it's just a cachebuster\n                    \"update_link\": f\"https://{owner}.github.io/{repo_name}/{installer_name}?v={current_version}\",\n                  },\n                ],\n              },\n            },\n          }\n          (ghp_dir / \"updates.json\").write_text(json.dumps(updates))\n\n          # copy in source maps\n          def only_sourcemaps(cur, ls):\n            if '/' in cur:\n              return []\n            return set(ls) - {\"chrome\", \"chrome-beta\", \"edge\", \"firefox\", \"firefox-beta\", \"safari\"}\n          shutil.copytree(\"source-maps\", ghp_dir, ignore=only_sourcemaps, dirs_exist_ok=True)\n\n      - name: Upload new github pages deployment\n        uses: actions/upload-pages-artifact@v3\n        with:\n          path: ./github-pages\n\n  deploy-ghp:\n    name: Deploy to github pages\n    needs: build\n    permissions:\n      id-token: write\n      pages: write\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    runs-on: ubuntu-latest\n    steps:\n      - name: Deploy\n        id: deployment\n        uses: actions/deploy-pages@v4\n"
  },
  {
    "path": ".github/workflows/take-action.yml",
    "content": "# .github/workflows/take.yml \nname: Assign issue to contributor\non: \n  issue_comment:\n\njobs:\n  assign:\n    name: Take an issue\n    runs-on: ubuntu-latest\n    steps:\n    - name: take the issue\n      uses: bdougie/take-action@28b86cd8d25593f037406ecbf96082db2836e928\n      env:\n        GITHUB_TOKEN: ${{ github.token }}\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Tests\n\non: [push, pull_request]\n\njobs:\n  test:\n    name: Run tests\n    runs-on: ubuntu-latest\n\n    steps:\n      # Initialization\n      - uses: actions/checkout@v4\n        with:\n          submodules: recursive\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n      - run: npm ci\n      \n      - name: Copy configuration\n        run: cp config.json.example config.json\n\n      - name: Set up WireGuard Connection\n        uses: niklaskeerl/easy-wireguard-action@50341d5f4b8245ff3a90e278aca67b2d283c78d0\n        with:\n          WG_CONFIG_FILE: ${{ secrets.WG_CONFIG_FILE }}\n\n      - name: Run tests\n        run: npm run test\n\n      - name: Upload results on fail\n        if: ${{ failure() }}\n        uses: actions/upload-artifact@v4\n        with:\n          name: Test Results\n          path: ./test-results"
  },
  {
    "path": ".github/workflows/update-oss-attribution.yml",
    "content": "name: update oss attributions\non:\n  push:\n    branches:\n      - master\n    paths:\n      - 'package.json'\n      - 'package-lock.json'\n  workflow_dispatch:\n\njobs:\n  update-oss:\n    runs-on: ubuntu-latest \n    steps:\n      - uses: actions/checkout@v4\n        with:\n          submodules: recursive\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '18'\n      - name: Install and generate attribution\n        run: |\n          npm ci\n          npm i -g oss-attribution-generator\n          generate-attribution\n          mv ./oss-attribution/attribution.txt ./public/oss-attribution/attribution.txt\n      - name: Prettify attributions\n        run: |\n          cd ci && npx ts-node prettify.ts\n\n      - name: Create pull request to update list\n        uses: peter-evans/create-pull-request@v7\n        # v4.2.3\n        with:\n          commit-message: Update OSS Attribution\n          author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>\n          branch: ci/oss_attribution\n          title: Update OSS Attribution\n          body: Automated OSS Attribution update\n"
  },
  {
    "path": ".github/workflows/updateInvidous.yml",
    "content": "name: update invidious\non:\n  workflow_dispatch:\n  schedule:\n    - cron: '0 0 1 * *' # check every month\n\njobs:\n  check-list:\n    runs-on: ubuntu-latest \n    steps:\n      - uses: actions/checkout@v4\n        with:\n          submodules: recursive\n      - name: Download instance lists\n        run: |\n          wget https://api.invidious.io/instances.json -O ci/invidious_instances.json\n          wget  https://github.com/TeamPiped/piped-uptime/raw/master/history/summary.json -O ci/piped_instances.json\n      - name: Install dependencies\n        run: npm ci\n      - name: \"Run CI\"\n        run: npm run ci:invidious\n\n      - name: Create pull request to update list\n        uses: peter-evans/create-pull-request@v7\n        # v4.2.3\n        with:\n          commit-message: Update Invidious List\n          author: github-actions[bot] <github-actions[bot]@users.noreply.github.com>\n          branch: ci/update_invidious_list\n          title: Update Invidious List\n          body: Automated Invidious list update"
  },
  {
    "path": ".gitignore",
    "content": "config.json\nignored\n.idea/\nnode_modules\nweb-ext-artifacts\n.vscode/\ndist/\ntmp/\n.DS_Store\nci/invidious_instances.json\nci/piped_instances.json\ntest-results"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"public/_locales\"]\n\tpath = public/_locales\n\turl = https://github.com/ajayyy/ExtensionTranslations\n[submodule \"maze-utils\"]\n\tpath = maze-utils\n\turl = https://github.com/ajayyy/maze-utils\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "If you make any contributions to SponsorBlock after this file was created, you are agreeing that any code you have contributed will be licensed under GPL-3.0 and agree to allow distribution on app stores as outlined in LICENSE-APPSTORE.\n\n# Translations\nhttps://crowdin.com/project/sponsorblock\n\n# Building\n## Building locally\n0. You must have [Node.js 22 or later](https://nodejs.org/) and npm installed. Works best on Linux\n1. Clone with submodules\n  ```bash\n  git clone --recursive https://github.com/ajayyy/SponsorBlock\n  ```\n  Or if you already cloned it, pull submodules with\n  ```bash\n  git submodule update --init --recursive\n  ```\n2. Copy the file `config.json.example` to `config.json` and adjust configuration as desired.\n  - Comments are invalid in JSON, make sure they are all removed.\n  - You will need to repeat this step in the future if you get build errors related to `CompileConfig` or `property does not exist on type ConfigClass`. This can happen for example when a new category is added.\n3. Run `npm ci` in the repository to install dependencies.\n4. Run `npm run build:dev` (for Chrome) or `npm run build:dev:firefox` (for Firefox) to generate a development version of the extension with source maps.\n    - You can also run `npm run build` (for Chrome) or `npm run build:firefox` (for Firefox) to generate a production build.\n5. The built extension is now in `dist/`. You can load this folder directly in Chrome as an [unpacked extension](https://developer.chrome.com/docs/extensions/mv3/getstarted/#manifest), or convert it to a zip file to load it as a [temporary extension](https://developer.mozilla.org/docs/Tools/about:debugging#loading_a_temporary_extension) in Firefox.\n\n## Developing with a clean profile and hot reloading\nRun `npm run dev` (for Chrome) or `npm run dev:firefox` (for Firefox) to run the extension using a clean browser profile with hot reloading. This uses [`web-ext run`](https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#commands).\n\nKnown chromium bug: Extension is not loaded properly on first start. Visit `chrome://extensions/` and reload the extension.\n\nFor Firefox for Android, use `npm run dev:firefox-android -- --adb-device <ip-address of the device>`. See the [Firefox documentation](https://extensionworkshop.com/documentation/develop/developing-extensions-for-firefox-for-android/#debug-your-extension) for more information. You may need to edit package.json and add the parameters directly there.\n\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<http://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<http://www.gnu.org/philosophy/why-not-lgpl.html>.\n"
  },
  {
    "path": "LICENSE-APPSTORE.txt",
    "content": "The developers are aware that the terms of service that\napply to apps distributed via Apple's App Store services and similar app stores may conflict\nwith rights granted under the SponsorBlock license, the GNU General\nPublic License, version 3. The copyright holders of the SponsorBlock \nproject do not wish this conflict to prevent the otherwise-compliant \ndistribution of derived apps via the App Store and similar app stores. \nTherefore, we have committed not to pursue any license\nviolation that results solely from the conflict between the GNU GPLv3\nand the Apple App Store terms of service or similar app stores. In\nother words, as long as you comply with the GPL in all other respects,\nincluding its requirements to provide users with source code and the\ntext of the license, we will not object to your distribution of the\nSponsorBlock project through the App Store."
  },
  {
    "path": "LICENSE-HISTORY.txt",
    "content": "Prior to commit 7338af3b384e2297eaf710443121ac840099a9f1, this project was licensed under LGPL 3.0.\n\nYou must follow LICENSE instead if you want to use any newer version.\n\n----\n\n                   GNU LESSER GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n\n  This version of the GNU Lesser General Public License incorporates\nthe terms and conditions of version 3 of the GNU General Public\nLicense, supplemented by the additional permissions listed below.\n\n  0. Additional Definitions.\n\n  As used herein, \"this License\" refers to version 3 of the GNU Lesser\nGeneral Public License, and the \"GNU GPL\" refers to version 3 of the GNU\nGeneral Public License.\n\n  \"The Library\" refers to a covered work governed by this License,\nother than an Application or a Combined Work as defined below.\n\n  An \"Application\" is any work that makes use of an interface provided\nby the Library, but which is not otherwise based on the Library.\nDefining a subclass of a class defined by the Library is deemed a mode\nof using an interface provided by the Library.\n\n  A \"Combined Work\" is a work produced by combining or linking an\nApplication with the Library.  The particular version of the Library\nwith which the Combined Work was made is also called the \"Linked\nVersion\".\n\n  The \"Minimal Corresponding Source\" for a Combined Work means the\nCorresponding Source for the Combined Work, excluding any source code\nfor portions of the Combined Work that, considered in isolation, are\nbased on the Application, and not on the Linked Version.\n\n  The \"Corresponding Application Code\" for a Combined Work means the\nobject code and/or source code for the Application, including any data\nand utility programs needed for reproducing the Combined Work from the\nApplication, but excluding the System Libraries of the Combined Work.\n\n  1. Exception to Section 3 of the GNU GPL.\n\n  You may convey a covered work under sections 3 and 4 of this License\nwithout being bound by section 3 of the GNU GPL.\n\n  2. Conveying Modified Versions.\n\n  If you modify a copy of the Library, and, in your modifications, a\nfacility refers to a function or data to be supplied by an Application\nthat uses the facility (other than as an argument passed when the\nfacility is invoked), then you may convey a copy of the modified\nversion:\n\n   a) under this License, provided that you make a good faith effort to\n   ensure that, in the event an Application does not supply the\n   function or data, the facility still operates, and performs\n   whatever part of its purpose remains meaningful, or\n\n   b) under the GNU GPL, with none of the additional permissions of\n   this License applicable to that copy.\n\n  3. Object Code Incorporating Material from Library Header Files.\n\n  The object code form of an Application may incorporate material from\na header file that is part of the Library.  You may convey such object\ncode under terms of your choice, provided that, if the incorporated\nmaterial is not limited to numerical parameters, data structure\nlayouts and accessors, or small macros, inline functions and templates\n(ten or fewer lines in length), you do both of the following:\n\n   a) Give prominent notice with each copy of the object code that the\n   Library is used in it and that the Library and its use are\n   covered by this License.\n\n   b) Accompany the object code with a copy of the GNU GPL and this license\n   document.\n\n  4. Combined Works.\n\n  You may convey a Combined Work under terms of your choice that,\ntaken together, effectively do not restrict modification of the\nportions of the Library contained in the Combined Work and reverse\nengineering for debugging such modifications, if you also do each of\nthe following:\n\n   a) Give prominent notice with each copy of the Combined Work that\n   the Library is used in it and that the Library and its use are\n   covered by this License.\n\n   b) Accompany the Combined Work with a copy of the GNU GPL and this license\n   document.\n\n   c) For a Combined Work that displays copyright notices during\n   execution, include the copyright notice for the Library among\n   these notices, as well as a reference directing the user to the\n   copies of the GNU GPL and this license document.\n\n   d) Do one of the following:\n\n       0) Convey the Minimal Corresponding Source under the terms of this\n       License, and the Corresponding Application Code in a form\n       suitable for, and under terms that permit, the user to\n       recombine or relink the Application with a modified version of\n       the Linked Version to produce a modified Combined Work, in the\n       manner specified by section 6 of the GNU GPL for conveying\n       Corresponding Source.\n\n       1) Use a suitable shared library mechanism for linking with the\n       Library.  A suitable mechanism is one that (a) uses at run time\n       a copy of the Library already present on the user's computer\n       system, and (b) will operate properly with a modified version\n       of the Library that is interface-compatible with the Linked\n       Version.\n\n   e) Provide Installation Information, but only if you would otherwise\n   be required to provide such information under section 6 of the\n   GNU GPL, and only to the extent that such information is\n   necessary to install and execute a modified version of the\n   Combined Work produced by recombining or relinking the\n   Application with a modified version of the Linked Version. (If\n   you use option 4d0, the Installation Information must accompany\n   the Minimal Corresponding Source and Corresponding Application\n   Code. If you use option 4d1, you must provide the Installation\n   Information in the manner specified by section 6 of the GNU GPL\n   for conveying Corresponding Source.)\n\n  5. Combined Libraries.\n\n  You may place library facilities that are a work based on the\nLibrary side by side in a single library together with other library\nfacilities that are not Applications and are not covered by this\nLicense, and convey such a combined library under terms of your\nchoice, if you do both of the following:\n\n   a) Accompany the combined library with a copy of the same work based\n   on the Library, uncombined with any other library facilities,\n   conveyed under the terms of this License.\n\n   b) Give prominent notice with the combined library that part of it\n   is a work based on the Library, and explaining where to find the\n   accompanying uncombined form of the same work.\n\n  6. Revised Versions of the GNU Lesser General Public License.\n\n  The Free Software Foundation may publish revised and/or new versions\nof the GNU Lesser General Public License from time to time. Such new\nversions will be similar in spirit to the present version, but may\ndiffer in detail to address new problems or concerns.\n\n  Each version is given a distinguishing version number. If the\nLibrary as you received it specifies that a certain numbered version\nof the GNU Lesser General Public License \"or any later version\"\napplies to it, you have the option of following the terms and\nconditions either of that published version or of any later version\npublished by the Free Software Foundation. If the Library as you\nreceived it does not specify a version number of the GNU Lesser\nGeneral Public License, you may choose any version of the GNU Lesser\nGeneral Public License ever published by the Free Software Foundation.\n\n  If the Library as you received it specifies that a proxy can decide\nwhether future versions of the GNU Lesser General Public License shall\napply, that proxy's public statement of acceptance of any version is\npermanent authorization for you to choose that version for the\nLibrary.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n  <a href=\"https://sponsor.ajay.app\"><img src=\"public/icons/LogoSponsorBlocker256px.png\" alt=\"Logo\"></img></a>\n\n  <br/>\n  <sub>Logo by <a href=\"https://github.com/munadikieh\">@munadikieh</a></sub>\n</p>\n\n<h1 align=\"center\">SponsorBlock</h1>\n\n<p align=\"center\">\n  <b>Download:</b>\n  <a href=\"https://chrome.google.com/webstore/detail/mnjggcdmjocbbbhaepdhchncahnbgone\">Chrome/Chromium</a> |\n  <a href=\"https://addons.mozilla.org/addon/sponsorblock/?src=external-github\">Firefox</a> |\n  <a href=\"https://github.com/ajayyy/SponsorBlock/wiki/Android\">Android</a> |\n  <a href=\"https://github.com/ajayyy/SponsorBlock/wiki/Edge\">Edge</a> |\n  <a href=\"https://github.com/ajayyy/SponsorBlock/wiki/Safari\">Safari for MacOS and iOS</a> |\n  <a href=\"https://sponsor.ajay.app\">Website</a> |\n  <a href=\"https://sponsor.ajay.app/stats\">Stats</a>\n</p>\n\n<p align=\"center\">\n  <b>3rd-Party Ports:</b>\n  <a href=\"https://github.com/ajayyy/SponsorBlock/wiki/3rd-Party-Ports#mpv-media-player\">MPV</a> |\n  <a href=\"https://github.com/ajayyy/SponsorBlock/wiki/3rd-Party-Ports#kodi\">Kodi</a> |\n  <a href=\"https://github.com/ajayyy/SponsorBlock/wiki/3rd-Party-Ports#Chromecast\">Chromecast</a> |\n  <a href=\"https://github.com/ajayyy/SponsorBlock/wiki/3rd-Party-Ports#ios\">iOS</a>\n</p>\n\n<p align=\"center\">\n    <a href=\"https://addons.mozilla.org/addon/sponsorblock/?src=external-github\"><img src=\"https://img.shields.io/amo/users/sponsorblock?label=Firefox%20Users\" alt=\"Badge\"></img></a>\n    <a href=\"https://chrome.google.com/webstore/detail/mnjggcdmjocbbbhaepdhchncahnbgone\"><img src=\"https://img.shields.io/chrome-web-store/users/mnjggcdmjocbbbhaepdhchncahnbgone?label=Chrome%20Users\" alt=\"Badge\"></img></a>\n    <a href=\"https://sponsor.ajay.app/stats\"><img src=\"https://img.shields.io/badge/dynamic/json?label=Submissions&query=totalSubmissions&suffix=%20segments&url=http%3A%2F%2Fsponsor.ajay.app%2Fapi%2FgetTotalStats&color=darkred\" alt=\"Badge\"></img></a>\n    <a href=\"https://sponsor.ajay.app/stats\"><img src=\"https://img.shields.io/badge/dynamic/json?label=Active%20Users&query=apiUsers&url=http%3A%2F%2Fsponsor.ajay.app%2Fapi%2FgetTotalStats&color=darkblue\" alt=\"Badge\"></img></a>\n    <a href=\"https://sponsor.ajay.app/stats\"><img src=\"https://img.shields.io/badge/dynamic/json?label=Time%20Saved%20From%20Skips&query=daysSaved&url=http%3A%2F%2Fsponsor.ajay.app%2Fapi%2FgetDaysSavedFormatted&color=darkgreen&suffix=%20days\" alt=\"Badge\"></img></a>\n</p>\n\n\n\nSponsorBlock is an open-source crowdsourced browser extension to skip sponsor segments in YouTube videos. Users submit when a sponsor happens from the extension, and the extension automatically skips sponsors it knows about. It also supports skipping other categories, such as intros, outros and reminders to subscribe.\n\nIt also supports Invidious.\n\n**Translate:** [![Crowdin](https://badges.crowdin.net/sponsorblock/localized.svg)](https://crowdin.com/project/sponsorblock)\n\n# Important Links\n\nSee the [Wiki](https://github.com/ajayyy/SponsorBlock/wiki) for important links.\n\n# Server\n\nThe backend server code is available here: https://github.com/ajayyy/SponsorBlockServer\n\nTo make sure that this project doesn't die, I have made the database publicly downloadable at https://sponsor.ajay.app/database ([License](https://github.com/ajayyy/SponsorBlock/wiki/Database-and-API-License)). If you are planning on using the database in another project, please read the [API Docs](https://wiki.sponsor.ajay.app/index.php/API_Docs) page for more information.\n\nThe dataset and API are now being used in some [ports](https://github.com/ajayyy/SponsorBlock/wiki/3rd-Party-Ports) as well as a [neural network](https://github.com/andrewzlee/NeuralBlock).\n\n# API\n\nYou can read the API docs [here](https://wiki.sponsor.ajay.app/w/API_Docs).\n\n# Building\nSee [CONTRIBUTING.md](CONTRIBUTING.md)\n\n# Credit\n\nThe awesome [Invidious API](https://docs.invidious.io/) was previously used, and the server is now using [NewLeaf](https://git.sr.ht/~cadence/NewLeaf) to get video info from YouTube.\n\nOriginally forked from [YTSponsorSkip](https://github.com/NDevTK/YTSponsorSkip), but very little code remains.\n\nIcons made by:\n* <a href=\"https://www.flaticon.com/authors/gregor-cresnar\" title=\"Gregor Cresnar\">Gregor Cresnar</a> from <a href=\"https://www.flaticon.com/\" title=\"Flaticon\">www.flaticon.com</a> and are licensed by <a href=\"https://creativecommons.org/licenses/by/3.0/\" title=\"Creative Commons BY 3.0\" target=\"_blank\">CC 3.0 BY</a>\n* <a href=\"https://www.flaticon.com/authors/freepik\" title=\"Freepik\">Freepik</a> from <a href=\"https://www.flaticon.com/\" title=\"Flaticon\">www.flaticon.com</a> and are licensed by <a href=\"https://creativecommons.org/licenses/by/3.0/\" title=\"Creative Commons BY 3.0\" target=\"_blank\">CC 3.0 BY</a>\n* <a href=\"https://iconmonstr.com/about/#creator\">Alexander Kahlkopf</a> from <a href=\"https://iconmonstr.com/\">iconmonstr.com</a> and are licensed by <a href=\"https://iconmonstr.com/license/\">iconmonstr License</a>\n\n\n### License\n\nThis project is licensed under GNU GPL v3 or any later version\n"
  },
  {
    "path": "ci/generateList.ts",
    "content": "/*\nThis file is only ran by GitHub Actions in order to populate the Invidious instances list\n\nThis file should not be shipped with the extension\n*/\n\n/*\nCriteria for inclusion:\nInvidious\n- uptime >= 80%\n- must have been up for at least 90 days\n- HTTPS only\n- url includes name (this is to avoid redirects)\n\nPiped\n- 30d uptime >= 90%\n- available for at least 80/90 days\n- must have been up for at least 90 days\n- must not be a wildcard redirect to piped.video\n- must be currently up\n- must have a functioning frontend\n- must have a functioning API\n*/\n\nimport { writeFile, existsSync } from \"fs\"\nimport { join } from \"path\"\nimport { getInvidiousList } from \"./invidiousCI\";\n// import { getPipedList } from \"./pipedCI\";\n\nconst checkPath = (path: string) => existsSync(path);\nconst fixArray = (arr: string[]) => [...new Set(arr)].sort()\n\nasync function generateList() {\n  // import file from https://api.invidious.io/instances.json\n  const invidiousPath = join(__dirname, \"invidious_instances.json\");\n  // import file from https://github.com/TeamPiped/piped-uptime/raw/master/history/summary.json\n  const pipedPath = join(__dirname, \"piped_instances.json\");\n\n  // check if files exist\n  if (!checkPath(invidiousPath) || !checkPath(pipedPath)) {\n    console.log(\"Missing files\")\n    process.exit(1);\n  }\n\n  // static non-invidious instances\n  const staticInstances = [\"www.youtubekids.com\"];\n  // invidious instances\n  const invidiousList = fixArray(getInvidiousList())\n  // piped instnaces\n  // const pipedList = fixArray(await getPipedList())\n\n  console.log([...staticInstances, ...invidiousList])\n\n  writeFile(\n    join(__dirname, \"./invidiouslist.json\"),\n    JSON.stringify([...staticInstances, ...invidiousList]),\n    (err) => {\n      if (err) return console.log(err);\n    }\n  );\n}\ngenerateList()\n"
  },
  {
    "path": "ci/invidiousCI.ts",
    "content": "import { InvidiousInstance, monitor } from \"./invidiousType\"\n\nimport * as data from \"../ci/invidious_instances.json\";\n\n// only https servers\nconst mapped = (data as InvidiousInstance[])\n  .filter((i) =>\n    i[1]?.type === \"https\"\n    && i[1]?.monitor?.enabled\n  )\n  .map((instance) => {\n    const monitor = instance[1].monitor as monitor;\n    return {\n      name: instance[0],\n      url: instance[1].uri,\n      uptime: monitor.uptime || 0,\n      down: monitor.down ?? false,\n      created_at: monitor.created_at,\n    }\n  });\n\n// reliability and sanity checks\nconst reliableCheck = mapped\n  .filter(instance => {\n    const uptime = instance.uptime > 80 && !instance.down;\n    const nameIncluded = instance.url.includes(instance.name);\n    const ninetyDays = 90 * 24 * 60 * 60 * 1000;\n    const ninetyDaysAgo = new Date(Date.now() - ninetyDays);\n    const createdAt = new Date(instance.created_at).getTime() < ninetyDaysAgo.getTime();\n    return uptime && nameIncluded && createdAt;\n  })\n\nexport const getInvidiousList = (): string[] =>\n  reliableCheck.map(instance => instance.name).sort()"
  },
  {
    "path": "ci/invidiousType.ts",
    "content": "export type InvidiousInstance = [\n  string,\n  {\n    flag: string;\n    region: string;\n    stats: null | ivStats;\n    cors: null | boolean;\n    api: null | boolean;\n    type: \"https\" | \"http\" | \"onion\" | \"i2p\";\n    uri: string;\n    monitor: null | monitor;\n  }\n]\n\nexport type monitor = {\n  token: string;\n  url: string;\n  alias: string;\n  last_status: number;\n  uptime: number;\n  down: boolean;\n  down_since: null | string;\n  up_since: null | string;\n  error: null | string;\n  period: number;\n  apdex_t: number;\n  string_match: string;\n  enabled: boolean;\n  published: boolean;\n  disabled_locations: string[];\n  recipients: string[];\n  last_check_at: string;\n  next_check_at: string;\n  created_at: string;\n  mute_until: null | string;\n  favicon_url: string;\n  custom_headers: Record<string, string>;\n  http_verb: string;\n  http_body: string;\n  ssl: {\n    tested_at: string;\n    expires_at: string;\n    valid: boolean;\n    error: null | string;\n  };\n}\n\nexport type ivStats = {\n  version: string;\n  software: {\n    name: \"invidious\" | string;\n    version: string;\n    branch: \"master\" | string;\n  };\n  openRegistrations: boolean;\n  usage: {\n    users: {\n      total: number;\n      activeHalfyear: number;\n      activeMonth: number;\n    };\n  };\n  metadata: {\n    updatedAt: number;\n    lastChannelRefreshedAt: number;\n  };\n  playback: {\n    totalRequests: number;\n    successfulRequests: number;\n    ratio: number;\n  };\n}"
  },
  {
    "path": "ci/invidiouslist.json",
    "content": "[\"www.youtubekids.com\",\"inv.nadeko.net\",\"inv.tux.pizza\",\"invidious.adminforge.de\",\"invidious.jing.rocks\",\"invidious.nerdvpn.de\",\"invidious.perennialte.ch\",\"invidious.privacyredirect.com\",\"invidious.reallyaweso.me\",\"invidious.yourdevice.ch\",\"iv.ggtyler.dev\",\"iv.nboeck.de\",\"yewtu.be\"]"
  },
  {
    "path": "ci/pipedCI.ts",
    "content": "import * as data from \"../ci/piped_instances.json\";\n\ntype percent = string\ntype dailyMinutesDown = Record<string, number>\n\ntype PipedInstance = {\n  name: string;\n  url: string;\n  icon: string;\n  slug: string;\n  status: string;\n  uptime: percent;\n  uptimeDay: percent;\n  uptimeWeek: percent;\n  uptimeMonth: percent;\n  uptimeYear: percent;\n  time: number;\n  timeDay: number;\n  timeWeek: number;\n  timeMonth: number;\n  timeYear: number;\n  dailyMinutesDown: dailyMinutesDown\n}\n\nconst percentNumber = (percent: percent) => Number(percent.replace(\"%\", \"\"))\nconst ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000)\n\nfunction dailyMinuteFilter (dailyMinutesDown: dailyMinutesDown) {\n  let daysDown = 0\n  for (const [date, minsDown] of Object.entries(dailyMinutesDown)) {\n    if (new Date(date) >= ninetyDaysAgo && minsDown > 1000) { // if within 90 days and down for more than 1000 minutes\n      daysDown++\n    }\n  }\n  // return true f less than 10 days down\n  return daysDown < 10\n}\n\nconst getHost = (url: string) => new URL(url).host\n\nconst getWatchPage = async (instance: PipedInstance) =>\n  fetch(`https://${getHost(instance.url)}`, { redirect: \"manual\" })\n    .then(res => res.headers.get(\"Location\"))\n    .catch(e => { console.log (e); return null })\n\nconst siteOK = async (instance) => {\n  // check if entire site is redirect\n  const notRedirect = await fetch(instance.url, { redirect: \"manual\" })\n    .then(res => res.status == 200)\n  // only allow kavin to return piped.video\n  // if (instance.url.startsWith(\"https://piped.video\") && instance.slug !== \"kavin-rocks-official\") return false\n  // check if frontend is OK\n  const watchPageStatus = await fetch(instance.frontendUrl)\n    .then(res => res.ok)\n  // test API - stream returns ok result\n  const streamStatus = await fetch(`${instance.apiUrl}/streams/BaW_jenozKc`)\n    .then(res => res.ok)\n  // get startTime of monitor\n  const age = await fetch(instance.historyUrl)\n    .then(res => res.text())\n    .then(text => { // startTime greater than 90 days ago\n      const date = text.match(/startTime: (.+)/)[1]\n      return Date.parse(date) < ninetyDaysAgo.valueOf()\n    })\n  // console.log(notRedirect, watchPageStatus, streamStatus, age, instance.frontendUrl, instance.apiUrl)\n  return notRedirect && watchPageStatus && streamStatus && age\n}\n\nconst staticFilters = (data as PipedInstance[])\n  .filter(instance => {\n    const isup = instance.status === \"up\"\n    const monthCheck = percentNumber(instance.uptimeMonth) >= 90\n    const dailyMinuteCheck = dailyMinuteFilter(instance.dailyMinutesDown)\n    return isup && monthCheck && dailyMinuteCheck\n  })\n  .map(async instance => {\n    // get frontend url\n    const frontendUrl = await getWatchPage(instance)\n    if (!frontendUrl) return null // return false if frontend doesn't resolve\n    // get api base\n    const apiUrl = instance.url.replace(\"/healthcheck\", \"\")\n    const historyUrl = `https://raw.githubusercontent.com/TeamPiped/piped-uptime/master/history/${instance.slug}.yml`\n    const pass = await siteOK({ apiUrl, historyUrl, frontendUrl, url: instance.url })\n    const frontendHost = getHost(frontendUrl)\n    return pass ? frontendHost : null\n  })\n\nexport async function getPipedList(): Promise<string[]> {\n  const instances = await Promise.all(staticFilters)\n    .then(arr => arr.filter(i => i !== null))\n  return instances\n}\n"
  },
  {
    "path": "ci/prettify.ts",
    "content": "import { writeFile } from 'fs';\n\nimport * as license from \"../oss-attribution/licenseInfos.json\";\n\nconst result = JSON.stringify(license, null, 2);\nwriteFile(\"../oss-attribution/licenseInfos.json\", result, err => { if (err) return console.log(err) } );"
  },
  {
    "path": "config.json.example",
    "content": "{\n    \"serverAddress\": \"https://sponsor.ajay.app\",\n    \"testingServerAddress\": \"https://sponsor.ajay.app/test\",\n    \"serverAddressComment\": \"This specifies the default SponsorBlock server to connect to\",\n    \"categoryList\": [\"sponsor\", \"selfpromo\", \"exclusive_access\", \"interaction\", \"poi_highlight\", \"intro\", \"outro\", \"preview\", \"hook\", \"filler\", \"chapter\", \"music_offtopic\"],\n    \"categorySupport\": {\n        \"sponsor\": [\"skip\", \"mute\", \"full\"],\n        \"selfpromo\": [\"skip\", \"mute\", \"full\"],\n        \"exclusive_access\": [\"full\"],\n        \"interaction\": [\"skip\", \"mute\"],\n        \"intro\": [\"skip\", \"mute\"],\n        \"outro\": [\"skip\", \"mute\"],\n        \"preview\": [\"skip\", \"mute\"],\n        \"hook\": [\"skip\", \"mute\"],\n        \"filler\": [\"skip\", \"mute\"],\n        \"music_offtopic\": [\"skip\"],\n        \"poi_highlight\": [\"poi\"],\n        \"chapter\": [\"chapter\"]\n    },\n    \"wikiLinks\": {\n        \"sponsor\": \"https://wiki.sponsor.ajay.app/w/Sponsor\",\n        \"selfpromo\": \"https://wiki.sponsor.ajay.app/w/Unpaid/Self_Promotion\",\n        \"exclusive_access\": \"https://wiki.sponsor.ajay.app/w/Exclusive_Access\",\n        \"interaction\": \"https://wiki.sponsor.ajay.app/w/Interaction_Reminder_(Subscribe)\",\n        \"intro\": \"https://wiki.sponsor.ajay.app/w/Intermission/Intro_Animation\",\n        \"outro\": \"https://wiki.sponsor.ajay.app/w/Endcards/Credits\",\n        \"preview\": \"https://wiki.sponsor.ajay.app/w/Preview/Recap\",\n        \"hook\": \"https://wiki.sponsor.ajay.app/w/Hook/Greetings\",\n        \"filler\": \"https://wiki.sponsor.ajay.app/w/Tangents/Jokes\",\n        \"music_offtopic\": \"https://wiki.sponsor.ajay.app/w/Music:_Non-Music_Section\",\n        \"poi_highlight\": \"https://wiki.sponsor.ajay.app/w/Highlight\",\n        \"guidelines\": \"https://wiki.sponsor.ajay.app/w/Guidelines\",\n        \"mute\": \"https://wiki.sponsor.ajay.app/w/Mute_Segment\",\n        \"chapter\": \"https://wiki.sponsor.ajay.app/w/Chapter\"\n    },\n    \"extensionImportList\": {\n        \"chromium\": [\n            \"enamippconapkdmgfgjchkhakpfinmaj\"\n        ],\n        \"firefox\": [\n            \"deArrow@ajay.app\",\n            \"deArrowBETA@ajay.app\"\n        ],\n        \"safari\": [\n            \"app.ajay.dearrow.extension\"\n        ]\n    }\n}\n"
  },
  {
    "path": "crowdin.yml",
    "content": "files:\n  - source: /public/_locales/en/*\n    translation: /public/_locales/%two_letters_code%/%original_file_name%\n"
  },
  {
    "path": "jest.config.js",
    "content": "module.exports = {\n    \"roots\": [\n        \"test\"\n    ],\n    \"transform\": {\n        \"^.+\\\\.ts$\": \"ts-jest\"\n    },\n    \"reporters\": [\"default\", \"github-actions\"]\n}; \n"
  },
  {
    "path": "manifest/beta-manifest-extra.json",
    "content": "{\n    \"name\": \"BETA - SponsorBlock\"\n}\n  "
  },
  {
    "path": "manifest/chrome-manifest-extra.json",
    "content": "{\n  \"host_permissions\": [\n    \"https://*.youtube.com/*\",\n    \"https://sponsor.ajay.app/*\"\n  ],\n  \"optional_host_permissions\": [\n    \"*://*/*\"\n  ],\n  \"web_accessible_resources\": [{\n    \"resources\": [\n      \"icons/LogoSponsorBlocker256px.png\",\n      \"icons/IconSponsorBlocker256px.png\",\n      \"icons/PlayerStartIconSponsorBlocker.svg\",\n      \"icons/PlayerStopIconSponsorBlocker.svg\",\n      \"icons/PlayerUploadIconSponsorBlocker.svg\",\n      \"icons/PlayerUploadFailedIconSponsorBlocker.svg\",\n      \"icons/PlayerCancelSegmentIconSponsorBlocker.svg\",\n      \"icons/clipboard.svg\",\n      \"icons/settings.svg\",\n      \"icons/pencil.svg\",\n      \"icons/check.svg\",\n      \"icons/check-smaller.svg\",\n      \"icons/upvote.png\",\n      \"icons/downvote.png\",\n      \"icons/thumbs_down.svg\",\n      \"icons/thumbs_down_locked.svg\",\n      \"icons/thumbs_up.svg\",\n      \"icons/help.svg\",\n      \"icons/report.png\",\n      \"icons/close.png\",\n      \"icons/skipIcon.svg\",\n      \"icons/refresh.svg\",\n      \"icons/beep.oga\",\n      \"icons/pause.svg\",\n      \"icons/stop.svg\",\n      \"icons/skip.svg\",\n      \"icons/heart.svg\",\n      \"icons/visible.svg\",\n      \"icons/not_visible.svg\",\n      \"icons/sort.svg\",\n      \"icons/money.svg\",\n      \"icons/segway.png\",\n      \"icons/close-smaller.svg\",\n      \"icons/right-arrow.svg\",\n      \"icons/campaign.svg\",\n      \"icons/star.svg\",\n      \"icons/lightbulb.svg\",\n      \"icons/bolt.svg\",\n      \"icons/stopwatch.svg\",\n      \"icons/music-note.svg\",\n      \"icons/import.svg\",\n      \"icons/export.svg\",\n      \"icons/PlayerInfoIconSponsorBlocker.svg\",\n      \"icons/PlayerDeleteIconSponsorBlocker.svg\",\n      \"icons/dearrow.svg\",\n      \"icons/sb-pride.png\",\n      \"icons/pride.svg\",\n      \"popup.html\",\n      \"popup.css\",\n      \"content.css\",\n      \"shared.css\",\n      \"js/document.js\",\n      \"libs/Source+Sans+Pro.css\",\n      \"libs/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdu.woff2\",\n      \"libs/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmRduz8A.woff2\",\n      \"libs/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmBduz8A.woff2\",\n      \"libs/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlBduz8A.woff2\"\n    ],\n    \"matches\": [\"<all_urls>\"]\n  }],\n  \"content_scripts\": [\n    {\n        \"world\": \"MAIN\",\n        \"js\": [\n            \"./js/document.js\"\n        ],\n        \"matches\": [\n            \"https://*.youtube.com/*\",\n            \"https://www.youtube-nocookie.com/embed/*\"\n        ],\n        \"exclude_matches\": [\n          \"https://accounts.youtube.com/RotateCookiesPage*\"\n      ],\n        \"all_frames\": true,\n        \"run_at\": \"document_start\"\n    },\n    {\n      \"world\": \"ISOLATED\",\n      \"js\": [\n          \"./js/content.js\"\n      ],\n      \"css\": [\n        \"content.css\",\n        \"shared.css\"\n      ],\n      \"matches\": [\n          \"https://*.youtube.com/*\",\n          \"https://www.youtube-nocookie.com/embed/*\"\n      ],\n      \"exclude_matches\": [\n        \"https://accounts.youtube.com/RotateCookiesPage*\"\n      ],\n      \"all_frames\": true,\n      \"run_at\": \"document_start\"\n  }\n  ],\n  \"action\": {\n    \"default_title\": \"SponsorBlock\",\n    \"default_popup\": \"popup.html\",\n    \"default_icon\": {\n        \"16\": \"icons/IconSponsorBlocker16px.png\",\n        \"32\": \"icons/IconSponsorBlocker32px.png\",\n        \"64\": \"icons/IconSponsorBlocker64px.png\",\n        \"128\": \"icons/IconSponsorBlocker128px.png\"\n    },\n    \"theme_icons\": [\n        {\n            \"light\": \"icons/IconSponsorBlocker16px.png\",\n            \"dark\": \"icons/IconSponsorBlocker16px.png\",\n            \"size\": 16\n        },\n        {\n            \"light\": \"icons/IconSponsorBlocker32px.png\",\n            \"dark\": \"icons/IconSponsorBlocker32px.png\",\n            \"size\": 32\n        },\n        {\n            \"light\": \"icons/IconSponsorBlocker64px.png\",\n            \"dark\": \"icons/IconSponsorBlocker64px.png\",\n            \"size\": 64\n        },\n        {\n            \"light\": \"icons/IconSponsorBlocker128px.png\",\n            \"dark\": \"icons/IconSponsorBlocker128px.png\",\n            \"size\": 128\n        },\n        {\n            \"light\": \"icons/IconSponsorBlocker256px.png\",\n            \"dark\": \"icons/IconSponsorBlocker256px.png\",\n            \"size\": 256\n        },\n        {\n            \"light\": \"icons/IconSponsorBlocker512px.png\",\n            \"dark\": \"icons/IconSponsorBlocker512px.png\",\n            \"size\": 512\n        },\n        {\n            \"light\": \"icons/IconSponsorBlocker1024px.png\",\n            \"dark\": \"icons/IconSponsorBlocker1024px.png\",\n            \"size\": 1024\n        }\n    ]\n  },\n  \"background\": {\n    \"service_worker\": \"./js/background.js\"\n  },\n  \"manifest_version\": 3\n}\n"
  },
  {
    "path": "manifest/firefox-beta-manifest-extra.json",
    "content": "{\n    \"browser_specific_settings\": {\n        \"gecko\": {\n            \"id\": \"sponsorBlockerBETA@ajay.app\"\n        }\n    }\n}\n  "
  },
  {
    "path": "manifest/firefox-manifest-extra.json",
    "content": "{\n  \"browser_specific_settings\": {\n    \"gecko\": {\n      \"id\": \"sponsorBlocker@ajay.app\",\n      \"strict_min_version\": \"102.0\"\n    },\n    \"gecko_android\": {\n      \"strict_min_version\": \"113.0\"\n    }\n  },\n  \"background\": {\n    \"persistent\": false\n  },\n  \"browser_action\": {\n    \"default_area\": \"navbar\"\n  }\n}\n"
  },
  {
    "path": "manifest/manifest-v2-extra.json",
    "content": "{\n    \"web_accessible_resources\": [\n        \"icons/LogoSponsorBlocker256px.png\",\n        \"icons/IconSponsorBlocker256px.png\",\n        \"icons/PlayerStartIconSponsorBlocker.svg\",\n        \"icons/PlayerStopIconSponsorBlocker.svg\",\n        \"icons/PlayerUploadIconSponsorBlocker.svg\",\n        \"icons/PlayerUploadFailedIconSponsorBlocker.svg\",\n        \"icons/PlayerCancelSegmentIconSponsorBlocker.svg\",\n        \"icons/clipboard.svg\",\n        \"icons/settings.svg\",\n        \"icons/pencil.svg\",\n        \"icons/check.svg\",\n        \"icons/check-smaller.svg\",\n        \"icons/upvote.png\",\n        \"icons/downvote.png\",\n        \"icons/thumbs_down.svg\",\n        \"icons/thumbs_down_locked.svg\",\n        \"icons/thumbs_up.svg\",\n        \"icons/help.svg\",\n        \"icons/report.png\",\n        \"icons/close.png\",\n        \"icons/skipIcon.svg\",\n        \"icons/refresh.svg\",\n        \"icons/beep.oga\",\n        \"icons/pause.svg\",\n        \"icons/stop.svg\",\n        \"icons/skip.svg\",\n        \"icons/heart.svg\",\n        \"icons/visible.svg\",\n        \"icons/not_visible.svg\",\n        \"icons/sort.svg\",\n        \"icons/money.svg\",\n        \"icons/segway.png\",\n        \"icons/close-smaller.svg\",\n        \"icons/right-arrow.svg\",\n        \"icons/campaign.svg\",\n        \"icons/star.svg\",\n        \"icons/lightbulb.svg\",\n        \"icons/bolt.svg\",\n        \"icons/stopwatch.svg\",\n        \"icons/music-note.svg\",\n        \"icons/import.svg\",\n        \"icons/export.svg\",\n        \"icons/PlayerInfoIconSponsorBlocker.svg\",\n        \"icons/PlayerDeleteIconSponsorBlocker.svg\",\n        \"icons/dearrow.svg\",\n        \"icons/sb-pride.png\",\n        \"icons/pride.svg\",\n        \"popup.html\",\n        \"popup.css\",\n        \"content.css\",\n        \"shared.css\",\n        \"js/document.js\",\n        \"libs/Source+Sans+Pro.css\",\n        \"libs/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdu.woff2\",\n        \"libs/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmRduz8A.woff2\",\n        \"libs/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmBduz8A.woff2\",\n        \"libs/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlBduz8A.woff2\"\n    ],\n    \"permissions\": [\n        \"https://sponsor.ajay.app/*\"\n    ],\n    \"optional_permissions\": [\n        \"*://*/*\"\n    ],\n    \"browser_action\": {\n        \"default_title\": \"SponsorBlock\",\n        \"default_popup\": \"popup.html\",\n        \"default_icon\": {\n            \"16\": \"icons/IconSponsorBlocker16px.png\",\n            \"32\": \"icons/IconSponsorBlocker32px.png\",\n            \"64\": \"icons/IconSponsorBlocker64px.png\",\n            \"128\": \"icons/IconSponsorBlocker128px.png\"\n        },\n        \"theme_icons\": [\n            {\n                \"light\": \"icons/IconSponsorBlocker16px.png\",\n                \"dark\": \"icons/IconSponsorBlocker16px.png\",\n                \"size\": 16\n            },\n            {\n                \"light\": \"icons/IconSponsorBlocker32px.png\",\n                \"dark\": \"icons/IconSponsorBlocker32px.png\",\n                \"size\": 32\n            },\n            {\n                \"light\": \"icons/IconSponsorBlocker64px.png\",\n                \"dark\": \"icons/IconSponsorBlocker64px.png\",\n                \"size\": 64\n            },\n            {\n                \"light\": \"icons/IconSponsorBlocker128px.png\",\n                \"dark\": \"icons/IconSponsorBlocker128px.png\",\n                \"size\": 128\n            },\n            {\n                \"light\": \"icons/IconSponsorBlocker256px.png\",\n                \"dark\": \"icons/IconSponsorBlocker256px.png\",\n                \"size\": 256\n            },\n            {\n                \"light\": \"icons/IconSponsorBlocker512px.png\",\n                \"dark\": \"icons/IconSponsorBlocker512px.png\",\n                \"size\": 512\n            },\n            {\n                \"light\": \"icons/IconSponsorBlocker1024px.png\",\n                \"dark\": \"icons/IconSponsorBlocker1024px.png\",\n                \"size\": 1024\n            }\n        ]\n    },\n    \"background\": {\n        \"scripts\":[\n            \"./js/background.js\"\n        ]\n    },\n    \"content_scripts\": [{\n        \"run_at\": \"document_start\",\n        \"matches\": [\n            \"https://*.youtube.com/*\",\n            \"https://www.youtube-nocookie.com/embed/*\"\n        ],\n        \"exclude_matches\": [\n            \"https://accounts.youtube.com/RotateCookiesPage*\"\n        ],\n        \"all_frames\": true,\n        \"js\": [\n            \"./js/content.js\"\n        ],\n        \"css\": [\n            \"content.css\",\n            \"shared.css\"\n        ]\n    }],\n    \"manifest_version\": 2\n}  \n"
  },
  {
    "path": "manifest/manifest.json",
    "content": "{\n    \"name\": \"__MSG_fullName__\",\n    \"short_name\": \"SponsorBlock\",\n    \"version\": \"6.1.2\",\n    \"default_locale\": \"en\",\n    \"description\": \"__MSG_Description__\",\n    \"homepage_url\": \"https://sponsor.ajay.app\",\n    \"icons\": {\n        \"16\": \"icons/IconSponsorBlocker16px.png\",\n        \"32\": \"icons/IconSponsorBlocker32px.png\",\n        \"64\": \"icons/IconSponsorBlocker64px.png\",\n        \"128\": \"icons/IconSponsorBlocker128px.png\",\n        \"256\": \"icons/IconSponsorBlocker256px.png\",\n        \"512\": \"icons/IconSponsorBlocker512px.png\",\n        \"1024\": \"icons/IconSponsorBlocker1024px.png\"\n    },\n    \"permissions\": [\n        \"storage\",\n        \"scripting\",\n        \"unlimitedStorage\"\n    ],\n    \"options_ui\": {\n        \"page\": \"options/options.html\",\n        \"open_in_tab\": true\n    }\n}  \n"
  },
  {
    "path": "manifest/safari-manifest-extra.json",
    "content": "{\n  \"background\": {\n    \"persistent\": false\n  },\n  \"optional_permissions\": [\n    \"webNavigation\"\n  ],\n  \"browser_action\": {\n    \"default_icon\": {\n      \"16\": \"icons/SafariIconSponsorBlocker16px.png\",\n      \"32\": \"icons/SafariIconSponsorBlocker32px.png\",\n      \"64\": \"icons/SafariIconSponsorBlocker64px.png\",\n      \"128\": \"icons/SafariIconSponsorBlocker128px.png\"\n    }\n  },\n  \"browser_specific_settings\": {\n    \"safari\": {\n      \"strict_min_version\": \"14.0\"\n    }\n  }\n}\n"
  },
  {
    "path": "oss-attribution/licenseInfos.json",
    "content": "{\n  \"content-scripts-register-polyfill\": {\n    \"ignore\": false,\n    \"name\": \"content-scripts-register-polyfill\",\n    \"version\": \"4.0.2\",\n    \"authors\": \"Federico Brigante <me@fregante.com> (https://fregante.com)\",\n    \"url\": \"https://github.com/fregante/content-scripts-register-polyfill\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"MIT License\\n\\nCopyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)\\n\\nPermission 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:\\n\\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\\n\\nTHE 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.\\n\"\n  },\n  \"escape-string-regexp\": {\n    \"ignore\": false,\n    \"name\": \"escape-string-regexp\",\n    \"version\": \"5.0.0\",\n    \"authors\": \"Sindre Sorhus <sindresorhus@gmail.com>\",\n    \"url\": \"https://github.com/sindresorhus/escape-string-regexp\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"MIT License\\n\\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)\\n\\nPermission 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:\\n\\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\\n\\nTHE 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.\\n\"\n  },\n  \"js-tokens\": {\n    \"ignore\": false,\n    \"name\": \"js-tokens\",\n    \"version\": \"4.0.0\",\n    \"authors\": \"Simon Lydell\",\n    \"url\": \"https://github.com/lydell/js-tokens\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"The MIT License (MIT)\\n\\nCopyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\nof this software and associated documentation files (the \\\"Software\\\"), to deal\\nin the Software without restriction, including without limitation the rights\\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\\ncopies of the Software, and to permit persons to whom the Software is\\nfurnished to do so, subject to the following conditions:\\n\\nThe above copyright notice and this permission notice shall be included in\\nall copies or substantial portions of the Software.\\n\\nTHE SOFTWARE IS PROVIDED \\\"AS IS\\\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\\nTHE SOFTWARE.\\n\"\n  },\n  \"loose-envify\": {\n    \"ignore\": false,\n    \"name\": \"loose-envify\",\n    \"version\": \"1.4.0\",\n    \"authors\": \"Andres Suarez <zertosh@gmail.com>\",\n    \"url\": \"https://github.com/zertosh/loose-envify\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"The MIT License (MIT)\\n\\nCopyright (c) 2015 Andres Suarez <zertosh@gmail.com>\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\nof this software and associated documentation files (the \\\"Software\\\"), to deal\\nin the Software without restriction, including without limitation the rights\\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\\ncopies of the Software, and to permit persons to whom the Software is\\nfurnished to do so, subject to the following conditions:\\n\\nThe above copyright notice and this permission notice shall be included in\\nall copies or substantial portions of the Software.\\n\\nTHE SOFTWARE IS PROVIDED \\\"AS IS\\\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\\nTHE SOFTWARE.\\n\"\n  },\n  \"react-dom\": {\n    \"ignore\": false,\n    \"name\": \"react-dom\",\n    \"version\": \"18.2.0\",\n    \"url\": \"https://github.com/facebook/react\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"MIT License\\n\\nCopyright (c) Facebook, Inc. and its affiliates.\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\nof this software and associated documentation files (the \\\"Software\\\"), to deal\\nin the Software without restriction, including without limitation the rights\\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\\ncopies of the Software, and to permit persons to whom the Software is\\nfurnished to do so, subject to the following conditions:\\n\\nThe above copyright notice and this permission notice shall be included in all\\ncopies or substantial portions of the Software.\\n\\nTHE SOFTWARE IS PROVIDED \\\"AS IS\\\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\\nSOFTWARE.\\n\"\n  },\n  \"react\": {\n    \"ignore\": false,\n    \"name\": \"react\",\n    \"version\": \"18.2.0\",\n    \"url\": \"https://github.com/facebook/react\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"MIT License\\n\\nCopyright (c) Facebook, Inc. and its affiliates.\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\nof this software and associated documentation files (the \\\"Software\\\"), to deal\\nin the Software without restriction, including without limitation the rights\\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\\ncopies of the Software, and to permit persons to whom the Software is\\nfurnished to do so, subject to the following conditions:\\n\\nThe above copyright notice and this permission notice shall be included in all\\ncopies or substantial portions of the Software.\\n\\nTHE SOFTWARE IS PROVIDED \\\"AS IS\\\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\\nSOFTWARE.\\n\"\n  },\n  \"scheduler\": {\n    \"ignore\": false,\n    \"name\": \"scheduler\",\n    \"version\": \"0.23.0\",\n    \"url\": \"https://github.com/facebook/react\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"MIT License\\n\\nCopyright (c) Facebook, Inc. and its affiliates.\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\nof this software and associated documentation files (the \\\"Software\\\"), to deal\\nin the Software without restriction, including without limitation the rights\\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\\ncopies of the Software, and to permit persons to whom the Software is\\nfurnished to do so, subject to the following conditions:\\n\\nThe above copyright notice and this permission notice shall be included in all\\ncopies or substantial portions of the Software.\\n\\nTHE SOFTWARE IS PROVIDED \\\"AS IS\\\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\\nSOFTWARE.\\n\"\n  },\n  \"webext-content-scripts\": {\n    \"ignore\": false,\n    \"name\": \"webext-content-scripts\",\n    \"version\": \"2.5.5\",\n    \"authors\": \"Federico Brigante <me@fregante.com> (https://fregante.com)\",\n    \"url\": \"https://github.com/fregante/webext-content-scripts\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"MIT License\\n\\nCopyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)\\n\\nPermission 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:\\n\\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\\n\\nTHE 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.\\n\"\n  },\n  \"webext-patterns\": {\n    \"ignore\": false,\n    \"name\": \"webext-patterns\",\n    \"version\": \"1.3.0\",\n    \"authors\": \"Federico Brigante <me@fregante.com> (https://fregante.com)\",\n    \"url\": \"https://github.com/fregante/webext-patterns\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"MIT License\\n\\nCopyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)\\n\\nPermission 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:\\n\\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\\n\\nTHE 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.\\n\"\n  },\n  \"webext-polyfill-kinda\": {\n    \"ignore\": false,\n    \"name\": \"webext-polyfill-kinda\",\n    \"version\": \"1.0.2\",\n    \"authors\": \"Federico Brigante <me@fregante.com> (https://fregante.com)\",\n    \"url\": \"https://github.com/fregante/webext-polyfill-kinda\",\n    \"license\": \"MIT\",\n    \"licenseText\": \"MIT License\\n\\nCopyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)\\n\\nPermission 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:\\n\\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\\n\\nTHE 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.\\n\"\n  }\n}"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"sponsorblock\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"main\": \"background.js\",\n  \"dependencies\": {\n    \"content-scripts-register-polyfill\": \"^4.0.2\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\"\n  },\n  \"overrides\": {\n    \"content-scripts-register-polyfill\": {\n      \"webext-content-scripts\": \"v2.5.5\"\n    }\n  },\n  \"devDependencies\": {\n    \"@types/chrome\": \"^0.0.220\",\n    \"@types/firefox-webext-browser\": \"^111.0.0\",\n    \"@types/jest\": \"^29.4.0\",\n    \"@types/react\": \"^18.0.28\",\n    \"@types/react-dom\": \"^18.0.11\",\n    \"@types/selenium-webdriver\": \"^4.1.13\",\n    \"@types/wicg-mediasession\": \"^1.1.4\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.54.1\",\n    \"@typescript-eslint/parser\": \"^5.54.1\",\n    \"chromedriver\": \"^140.0.0\",\n    \"concurrently\": \"^7.6.0\",\n    \"copy-webpack-plugin\": \"^11.0.0\",\n    \"eslint\": \"^8.35.0\",\n    \"eslint-plugin-react\": \"^7.32.2\",\n    \"fork-ts-checker-webpack-plugin\": \"^7.3.0\",\n    \"jest\": \"^29.5.0\",\n    \"jest-environment-jsdom\": \"^30.2.0\",\n    \"rimraf\": \"^4.3.1\",\n    \"schema-utils\": \"^4.0.0\",\n    \"selenium-webdriver\": \"^4.8.1\",\n    \"ts-jest\": \"^29.0.5\",\n    \"ts-loader\": \"^9.4.2\",\n    \"ts-node\": \"^10.9.1\",\n    \"typescript\": \"4.9\",\n    \"web-ext\": \"^8.10.0\",\n    \"webpack\": \"^5.105.0\",\n    \"webpack-cli\": \"^4.10.0\",\n    \"webpack-merge\": \"^5.8.0\"\n  },\n  \"scripts\": {\n    \"web-run\": \"npm run web-run:chrome\",\n    \"web-sign\": \"web-ext sign --channel unlisted -s dist\",\n    \"web-run:firefox\": \"cd dist && web-ext run --start-url https://addons.mozilla.org/firefox/addon/ublock-origin/\",\n    \"web-run:firefox-android\": \"cd dist && web-ext run -t firefox-android --firefox-apk org.mozilla.fenix\",\n    \"web-run:chrome\": \"cd dist && web-ext run --start-url https://chrome.google.com/webstore/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm -t chromium\",\n    \"build\": \"npm run build:chrome\",\n    \"build:chrome\": \"webpack --env browser=chrome --config webpack/webpack.prod.js\",\n    \"build:firefox\": \"webpack --env browser=firefox --config webpack/webpack.prod.js\",\n    \"build:safari\": \"webpack --env browser=safari --config webpack/webpack.prod.js\",\n    \"build:edge\": \"webpack --env browser=edge --config webpack/webpack.prod.js\",\n    \"build:dev\": \"npm run build:dev:chrome\",\n    \"build:dev:chrome\": \"webpack --env browser=chrome --config webpack/webpack.dev.js\",\n    \"build:dev:firefox\": \"webpack --env browser=firefox --config webpack/webpack.dev.js\",\n    \"build:watch\": \"npm run build:watch:chrome\",\n    \"build:watch:chrome\": \"webpack --env browser=chrome --config webpack/webpack.dev.js --watch\",\n    \"build:watch:firefox\": \"webpack --env browser=firefox --config webpack/webpack.dev.js --watch\",\n    \"ci:invidious\": \"ts-node ci/generateList.ts\",\n    \"dev\": \"npm run build:dev && concurrently \\\"npm run web-run\\\" \\\"npm run build:watch\\\"\",\n    \"dev:firefox\": \"npm run build:dev:firefox && concurrently \\\"npm run web-run:firefox\\\" \\\"npm run build:watch:firefox\\\"\",\n    \"dev:firefox-android\": \"npm run build:dev:firefox && concurrently \\\"npm run web-run:firefox-android\\\" \\\"npm run build:watch:firefox\\\"\",\n    \"clean\": \"rimraf dist\",\n    \"test\": \"npm run build:chrome && npx jest\",\n    \"test-without-building\": \"npx jest\",\n    \"lint\": \"eslint src\",\n    \"lint:fix\": \"eslint src --fix\"\n  },\n  \"engines\": {\n    \"node\": \">=16\"\n  },\n  \"funding\": [\n    {\n      \"type\": \"individual\",\n      \"url\": \"https://sponsor.ajay.app/donate\"\n    },\n    {\n      \"type\": \"github\",\n      \"url\": \"https://github.com/sponsors/ajayyy-org\"\n    },\n    {\n      \"type\": \"patreon\",\n      \"url\": \"https://www.patreon.com/ajayyy\"\n    },\n    {\n      \"type\": \"individual\",\n      \"url\": \"https://paypal.me/ajayyy\"\n    }\n  ],\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/ajayyy/SponsorBlock.git\"\n  },\n  \"author\": \"Ajay Ramachandran\",\n  \"license\": \"GPL-3.0\",\n  \"private\": true\n}\n"
  },
  {
    "path": "public/content.css",
    "content": ":root {\n\t--skip-notice-right: 10px;\n\t--skip-notice-padding: 5px;\n\t--skip-notice-margin: 5px;\n\t--skip-notice-border-horizontal: 5px;\n\t--skip-notice-border-vertical: 10px;\n\t--sb-dark-red-outline: rgb(130,0,0,0.9);\n}\n\n.sbhidden {\n\tdisplay: none;\n}\n\n/* Vorapi compatibility */\n#player-api_VORAPI_ELEMENT_ID #previewbar {\n\tz-index: 999;\n}\n\n#previewbar {\n    overflow: visible;\n    padding: 0;\n    margin: 0;\n    position: absolute;\n    width: 100%;\n\tpointer-events: none;\n\n\theight: 100%;\n\ttransform: scaleY(0.667) translateY(-30%) translateY(1.5px);\n\tz-index: 42;\n\n\ttransition: transform .1s cubic-bezier(0,0,0.2,1);\n}\n\n/* Prevent bar from covering highlights on YTTV */\n#previewbar.sponsorblock-yttv-container  {\n\tz-index: unset;\n}\n\nytu-time-bar.ytu-storyboard {\n\ttext-align: center;\n}\n\n/* May 2024 hover preview */\n.YtPlayerProgressBarProgressBar #previewbar {\n\ttransform: none;\n}\n\n.ytp-big-mode #previewbar {\n\ttransform: scaleY(0.625) translateY(-30%) translateY(1.5px);\n}\n\n.ytp-big-mode .sponsorTwoTooltips .sponsorCategoryTooltip {\n\ttop: 75px !important;\n}\n\n.progress-bar-line > #previewbar {\n\theight: 3px;\n}\n\ndiv:hover > #previewbar.sbNotInvidious {\n\ttransform: scaleY(1);\n}\n\n/* Vorapis */\n.v3 #previewbar.sbNotInvidious {\n\ttransform: scaleY(1);\n}\n.sponsorCategoryTooltipVisible.ytp-progress-tooltip {\n\twidth: 216px !important;\n  \t/* left: 264.308px !important; */\n}\n\n.previewbar {\n\tdisplay: inline-block;\n\theight: 100%;\n\tmin-width: 1px;\n}\n\n.previewbar-yttv {\n\theight: 10px;\n\ttop: 14px;\n}\n\n.previewbar.requiredSegment {\n\ttransform: scaleY(3);\n}\n\n.previewbar.selectedSegment {\n\topacity: 1 !important;\n\tz-index: 100;\n\ttransform: scaleY(1.5);\n}\n\n/* Make sure settings are upfront */\n.ytp-settings-menu {\n\tz-index: 6000 !important;\n}\n\n/* Preview Bar page hacks */\n\n.ytp-tooltip:not(.sponsorCategoryTooltipVisible) .sponsorCategoryTooltip {\n\tdisplay: none !important;\n}\n\n/* Pull up for precise seeking */\n.ytp-tooltip.sponsorCategoryTooltipVisible .ytp-tooltip-edu {\n\ttransform: translateY(-1em) !important;\n}\n\n.ytp-tooltip.sponsorCategoryTooltipVisible {\n\ttransform: translateY(-1em) !important;\n}\n\n.ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips {\n\ttransform: translateY(-2em) !important;\n}\n\n.ytp-tooltip.sponsorCategoryTooltipVisible.sponsorHasOriginalTooltip {\n\ttransform: translateY(-2em) !important;\n}\n\n.ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips.sponsorHasOriginalTooltip {\n\ttransform: translateY(-3em) !important;\n}\n\n.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible {\n\ttransform: translateY(-2em) !important;\n}\n\n.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips {\n\ttransform: translateY(-4em) !important;\n}\n\n#movie_player:not(.ytp-big-mode) .ytp-tooltip.sponsorCategoryTooltipVisible > .ytp-tooltip-text-wrapper {\n\ttransform: translateY(1em) !important;\n}\n\n#movie_player:not(.ytp-big-mode) .ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips > .ytp-tooltip-text-wrapper {\n\ttransform: translateY(2em) !important;\n}\n\n/* Pull up for precise seeking */\n.ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips .ytp-tooltip-edu {\n\ttransform: translateY(-2em) !important;\n}\n\n.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible > .ytp-tooltip-text-wrapper {\n\ttransform: translateY(0.5em) !important;\n}\n\n.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips > .ytp-tooltip-text-wrapper {\n\ttransform: translateY(1.75em) !important;\n}\n\n.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible > .ytp-tooltip-text-wrapper .ytp-tooltip-text {\n\tdisplay: inline-block !important;\n\ttransform: translateY(0.75em) !important;\n}\n\n.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible.sponsorTwoTooltips > .ytp-tooltip-text-wrapper .ytp-tooltip-text {\n\tdisplay: inline-block !important;\n\ttransform: translateY(0.75em) !important;\n}\n\ndiv:hover > .sponsorBlockChapterBar {\n\tz-index: 41 !important;\n}\n\n/*  */\n\n.popup {\n    z-index: 10;\n    width: 100%;\n    height: 500px;\n}\n\n.smallLink {\n\tfont-size: 10px;\n\ttext-decoration: underline;\n\tcursor: pointer;\n}\n\n.playerButtonImage {\n\theight: 60%;\n\ttop: 0;\n\tbottom: 0;\n\tdisplay: block;\n\tmargin: auto;\n}\n\n.sbChapterVoteButton {\n\tpadding: 0 !important;\n}\n\n.playerButton {\n\tvertical-align: top;\n}\n\n.playerButton.sbhidden:not(.autoHiding) {\n\tdisplay: none !important;\n}\n\n/* Removes auto width from being a ytp-player-button */\n.sbPlayerDownvote {\n\twidth: auto !important;\n}\n\n/* Adds back the padding */\n.sbPlayerDownvote svg {\n\tpadding-right: 3.6px;\n}\n\n.sbButtonYTTV {\n\tpadding-left: 5px !important;\n}\n\n/* YTTV only */\n.ytu-player-controls > .skipButtonControlBarContainer > div {\n\tpadding-left: 5px;\n\talign-content: center;\n}\n\n.autoHiding {\n\toverflow: visible !important;\n}\n\n.autoHiding:not(.sbhidden) {\n\ttransform: translateX(0%) scale(1);\n\t/* opacity is from YouTube page */\n\ttransition: transform 0.2s, width 0.2s, opacity .1s cubic-bezier(0.4,0.0,1,1) !important;\n}\n\n.autoHiding.sbhidden {\n\ttransform: translateX(100%) scale(0);\n\t/* opacity is from YouTube page */\n\ttransition: transform 0.2s, width 0.2s, opacity .1s cubic-bezier(0.4,0.0,1,1) !important;\n\n\twidth: 0px !important;\n}\n\n.autoHiding.sbhidden.autoHideLeft {\n\ttransform: translateX(-100%) scale(0);\n}\n\n.sponsorSkipObject {\n\tfont-family: Roboto, Arial, Helvetica, sans-serif;\n\n\tmargin-left: var(--skip-notice-margin);\n\tmargin-right: var(--skip-notice-margin);\n}\n\n.sponsorSkipObjectFirst {\n\tmargin-left: 0;\n}\n\n.sponsorSkipLogo {\n\theight: 18px;\n\n\tfloat: left;\n}\n\n#categoryPill .sbPillNoText .sponsorSkipLogo {\n\tmargin-top: calc(2.6rem - 18px);\n    margin-bottom: calc(2.6rem - 18px);\n}\n\n@keyframes fadeIn {\n\tfrom { opacity: 0; }\n}\n\n@keyframes fadeInToFaded {\n\tfrom { opacity: 0; }\n\tto { opacity: 0.5; }\n}\n\n@keyframes fadeOut {\n\tto { opacity: 0; }\n}\n\n.sponsorBlockSpacer {\n\tbackground-color: rgb(100, 100, 100);\n\tborder-color: rgb(100, 100, 100);\n\n\tmargin-left: 5px;\n}\n\n.sbChatNotice {\n\tmin-width: 350px;\n\theight: 70%;\n\n\tposition: absolute;\n\tright: 5px;\n\tbottom: 100px;\n\tright: var(--skip-notice-right);\n}\n\n.sponsorSkipNoticeParent {\n    position: absolute;\n\n\tbottom: 100px;\n\tright: 10px;\n}\n\n.sponsorSkipNoticeParent, .sponsorSkipNotice {\n\tborder-spacing: 5px 10px;\n\tpadding-left: 5px;\n\tpadding-right: 5px;\n\n\tborder-collapse: unset;\n}\n\n.sponsorSkipNotice {\n\twidth: 100%;\n}\n\n.sponsorSkipNoticeTableContainer {\n\tcolor: white;\n\tbackground-color: rgba(28, 28, 28, 0.9);\n\tborder-radius: 5px;\n\tmin-width: 100%;\n}\n\n.exportCopiedNotice .sponsorSkipNoticeTableContainer {\n\tbackground-color: transparent;\n}\n\n.sponsorSkipNotice {\n\ttransition: all 0.1s ease-out;\n}\n\n.sponsorSkipNoticeLimitWidth {\n\tmax-width: calc(100% - 50px);\n}\n\n.sponsorSkipNotice .sbhidden {\n\tdisplay: none;\n}\n\n/* For Cloudtube */\n.sponsorSkipNotice td, .sponsorSkipNotice table, .sponsorSkipNotice th {\n\tborder: none;\n}\n\n.sponsorSkipNoticeFadeIn {\n\tanimation: fadeIn 0.5s ease-out;\n}\n\n.sponsorSkipNoticeFadeIn.sponsorSkipNoticeFaded {\n\tanimation: fadeInToFaded 0.5s ease-out;\n}\n\n.exportCopiedNotice .sponsorSkipNoticeFadeIn {\n\tanimation: none;\n}\n\n.sponsorSkipNoticeFaded {\n\topacity: 0.5;\n}\n\n.sponsorSkipNoticeFadeOut {\n\ttransition: opacity 3s cubic-bezier(0.55, 0.055, 0.675, 0.19);\n\topacity: 0 !important;\n\tanimation: none !important;\n}\n\n.sponsorSkipNotice .sponsorSkipNoticeTimeLeft {\n\tcolor: #eeeeee;\n\n\tborder-radius: 4px;\n    padding: 2px 5px;\n    font-size: 12px;\n\n\tdisplay: flex;\n    align-items: center;\n\n\tborder: 1px solid #eeeeee;\n}\n\n.sponsorSkipNoticeTimeLeft img {\n\tvertical-align: middle;\n    height: 13px;\n\n\tpadding-top: 7.8%;\n    padding-bottom: 7.8%;\n}\n\n.noticeLeftIcon {\n\tdisplay: flex;\n  \talign-items: center;\n}\n\n.sponsorSkipNotice .sponsorSkipNoticeUnskipSection {\n\tfloat: left;\n\n\tborder-left: 1px solid rgb(150, 150, 150);\n}\n\n.sponsorSkipNoticeButton {\n\tbackground: none;\n\tcolor: rgb(235, 235, 235);\n\tborder: none;\n\tdisplay: inline-block;\n\tfont-size: 13.3333px !important;\n\n\tcursor: pointer;\n\n\tmargin-right: 10px;\n\n    padding: 2px 5px;\n}\n\n.sponsorSkipNoticeButton:hover {\n\tbackground-color: rgba(235, 235, 235,0.2);\n\tborder-radius: 4px;\n\n\ttransition: background-color 0.4s;\n}\n\n.sponsorTimesVoteButtonsContainer {\n\tfloat: left;\n\tvertical-align:middle;\n\tpadding: 2px 5px;\n\n\tmargin-right: 4px;\n}\n\n.sponsorTimesVoteButtonsContainer div{\n\tdisplay: inline-block;\n}\n\n.sponsorSkipNoticeRightSection {\n    right: 0;\n\tposition: absolute;\n\n\tfloat: right;\n\n\tmargin-right: 10px;\n\tdisplay: flex;\n\talign-items: center;\n}\n\n.sponsorSkipNoticeRightButton {\n\tmargin-right: 0;\n}\n\n.sponsorSkipNoticeCloseButton {\n\theight: 10px;\n\twidth: 10px;\n\tbox-sizing: unset;\n\n\tpadding: 2px 5px;\n\n\tmargin-left: 2px;\n    float: right;\n}\n\n.sponsorSkipNoticeCloseButton.biggerCloseButton {\n\tpadding: 20px;\n}\n\n.sponsorSkipMessage {\n\tfont-size: 14px;\n\tfont-weight: bold;\n\tcolor: rgb(235, 235, 235);\n\n\tmargin-top: auto;\n\tdisplay: inline-block;\n\tmargin-right: 10px;\n\tmargin-bottom: auto;\n}\n\n.sponsorSkipInfo {\n\tfont-size: 10px;\n    color: #000000;\n\ttext-align: center;\n\tmargin-top: 0px;\n}\n\n#sponsorTimesThanksForVotingText {\n\tfont-size: 20px;\n\tfont-weight: bold;\n    color: #000000;\n\ttext-align: center;\n\tmargin-top: 0px;\n\tmargin-bottom: 0px;\n}\n\n#sponsorTimesThanksForVotingInfoText {\n\tfont-size: 12px;\n\tfont-weight: bold;\n    color: #000000;\n\ttext-align: center;\n\tmargin-top: 0px;\n}\n\n.sponsorTimesVoteButtonMessage {\n\tfloat: left;\n}\n\n.sponsorTimesInfoMessage {\n\tfont-size: 13.3333px;\n    color: rgb(235, 235, 235);\n\toverflow-wrap: anywhere;\n}\n\n.sb-guidelines-notice .sponsorTimesInfoMessage td {\n\tpadding-left: 5px;\n\tpadding-top: 2px;\n\tpadding-bottom: 2px;\n    font-size: 15px;\n\n\tdisplay: flex;\n\talign-items: center;\n}\n\n.sponsorTimesInfoIcon {\n\twidth: 30px;\n\tpadding-right: 10px;\n    padding-left: 10px;\n}\n\n.segmentSummary {\n\toutline: none !important;\n}\n\n.submitButton {\n\tbackground-color:#ec1c1c;\n\t-moz-border-radius:28px;\n\t-webkit-border-radius:28px;\n\tborder-radius:28px;\n\tborder:1px solid #d31919;\n\tdisplay:inline-block;\n\tcursor:pointer;\n\tcolor:#ffffff;\n\tfont-size:14px;\n\tpadding:4px 15px;\n\ttext-decoration:none;\n    text-shadow:0px 0px 0px #662727;\n\n    margin-top: 5px;\n    margin-right: 15px;\n}\n.submitButton:hover {\n\tbackground-color:#bf2a2a;\n}\n\n.submitButton:focus {\n\toutline: none;\n\tbackground-color:#bf2a2a;\n}\n\n.submitButton:active {\n\tposition:relative;\n\ttop:1px;\n}\n\n@keyframes rotate {\n\tfrom { transform: rotate(0deg); }\n\t  to { transform: rotate(360deg); }\n}\n\n.sponsorSkipButton {\n\tbackground-color:#ec1c1c;\n\t-moz-border-radius:28px;\n\t-webkit-border-radius:28px;\n\tborder-radius:28px;\n\tborder:1px solid #d31919;\n\tdisplay:inline-block;\n\tcursor:pointer;\n\tcolor:#ffffff;\n\tfont-size:14px;\n\tpadding:4px 15px;\n\ttext-decoration:none;\n    text-shadow:0px 0px 0px #662727;\n\n    margin-top: 5px;\n    margin-right: 15px;\n}\n.sponsorSkipButton:hover {\n\tbackground-color:#bf2a2a;\n}\n\n.sponsorSkipButton:focus {\n\toutline: none;\n\tbackground-color:#bf2a2a;\n}\n\n.sponsorSkipButton:active {\n\tposition:relative;\n\ttop:1px;\n}\n\n.sponsorSkipDontShowButton {\n\t-moz-box-shadow:inset 0px 1px 0px 0px #cf866c;\n\t-webkit-box-shadow:inset 0px 1px 0px 0px #cf866c;\n\tbox-shadow:inset 0px 1px 0px 0px #cf866c;\n\tbackground-color:#d0451b;\n\t-moz-border-radius:3px;\n\t-webkit-border-radius:3px;\n\tborder-radius:3px;\n\tborder:1px solid #942911;\n\tdisplay:inline-block;\n\tcursor:pointer;\n\tcolor:#ffffff;\n\tfont-size:13px;\n\tpadding:6px 24px;\n\ttext-decoration:none;\n\ttext-shadow:0px 1px 0px #854629;\n}\n.sponsorSkipDontShowButton:hover {\n\tbackground-color:#bc3315;\n}\n\n.sponsorSkipDontShowButton:focus {\n\toutline: none;\n\tbackground-color:#bc3315;\n}\n\n.sponsorSkipDontShowButton:active {\n\tposition:relative;\n\ttop:1px;\n}\n\n/* Submission Notice */\n\n.sponsorTimeDisplay {\n\tfont-size: 15px;\n}\n\n.sponsorTimeEditButton {\n\ttext-decoration: underline;\n\n\tmargin-left: 13px;\n\tmargin-right: 13px;\n\n\tfont-size: 13px;\n\n\tcursor: pointer;\n}\n\n.sponsorTimeEdit > input::-webkit-outer-spin-button,\ninput::-webkit-inner-spin-button {\n  -webkit-appearance: none;\n  margin: 0;\n}\n\n.sponsorTimeMessagesRow {\n\tmax-height: 300px;\n\tdisplay: flex;\n\n\toverflow: auto;\n}\n\n.sponsorTimeEdit {\n\tfont-size: 14px;\n\n\t-moz-appearance: textfield;\n\tappearance: textfield;\n}\n\n.sponsorTimeEditInput {\n\twidth: 90px;\n\tborder: 3px solid var(--sb-dark-red-outline);\n}\n\n.sponsorTimeEditInput.sponsorChapterNameInput {\n\twidth: auto;\n\tpadding: 3px;\n}\n\n.sponsorNowButton {\n\tfont-size: 11px;\n\n\tcursor: pointer;\n\ttext-decoration: underline;\n}\n\n.sponsorTimeEditSelector {\n\tmargin-top: 5px;\n\tmargin-bottom: 5px;\n\n\tbackground-color: rgba(28, 28, 28, 0.9);\n    border-color: var(--sb-dark-red-outline);\n    color: white;\n    border-width: 3px;\n    padding: 3px;\n}\n\n.sponsorTimeEditSelector > option {\n\tbackground-color: rgba(28, 28, 28, 0.9);\n\tcolor: white;\n}\n\n.hideSegmentSubmitButton {\n\tcursor: pointer;\n\tmargin: auto;\n\ttop: 0;\n\tbottom: 0;\n\tposition: absolute;\n}\n\n/* Start SelectorComponent */\n\n.sbSelector {\n\tposition: absolute;\n\ttext-align: center;\n\twidth: calc(100% - var(--skip-notice-right) - var(--skip-notice-padding) * 2 - var(--skip-notice-margin) * 2 - var(--skip-notice-border-horizontal) * 2);\n\n\tz-index: 1000;\n}\n\n.sbSelectorBackground {\n\ttext-align: center;\n\n\tbackground-color: rgba(28, 28, 28, 0.9);\n\tborder-radius: 6px;\n\tpadding: 3px;\n\tmargin: auto;\n\twidth: 170px;\n}\n\n.sbSelectorOption {\n    cursor: pointer;\n    background-color: rgb(43, 43, 43);\n    padding: 5px;\n\tmargin: 5px;\n    color: white;\n    border-radius: 5px;\n    font-size: 14px;\n\n\tmargin-left: auto;\n    margin-right: auto;\n}\n\n.sbSelectorOption:hover {\n    background-color: #3a0000;\n}\n\n/* End SelectorComponent */\n\n.helpButton {\n\theight: 25px;\n\tcursor: pointer;\n\tpadding: 5px;\n\n\tmargin: auto;\n    top: 0;\n    bottom: 0;\n    position: absolute;\n}\n.helpButton:hover {\n\topacity: 0.8;\n}\n\n.skipButtonControlBarContainer {\n\tcursor: pointer;\n\tdisplay: flex;\n    color: white;\n\talign-items: center;\n}\n\n/* July 2025 test UI */\n.ytp-delhi-modern .skipButtonControlBarContainer {\n    height: 48px;\n    margin: auto 0;\n}\n\n.skipButtonControlBarContainer.sbhidden {\n\tdisplay: none !important;\n}\n\n.skipButtonControlBarContainer.mobile {\n\tbottom: 30%;\n    margin-left: 5px;\n\tposition: absolute;\n\theight: 20px;\n\n\tbackground-color: #00000030;\n\topacity: 0.5;\n\tborder-radius: 10px;\n\tpadding: 4px;\n}\n\n.skipButtonControlBarContainer.mobile.textDisabled {\n\tpadding: 0;\n\tbackground-color: transparent;\n}\n\n.skipButtonControlBarContainer.mobile > div {\n\tmargin: auto;\n\tmargin-left: 5px;\n}\n\n#sbSkipIconControlBarImage {\n\theight: 60%;\n    top: 0px;\n    bottom: 0px;\n    display: block;\n    margin: auto;\n}\n\n.mobile #sbSkipIconControlBarImage {\n\theight: 100%;\n\twidth: 20px;\n}\n\n.sponsorBlockTooltip {\n    position: absolute;\n    background-color: rgba(28, 28, 28, 0.7);\n    border-radius: 5px;\n    padding: 10px;\n    max-width: 300px;\n\twidth: max-content;\n    white-space: normal;\n    line-height: 1.5em;\n\tcolor: white;\n\tfont-size: 12px;\n\tz-index: 10000;\n\tfont-weight: normal;\n}\n\n.sponsorBlockTooltip a {\n\tcolor: white;\n}\n\n.sponsorBlockTooltip.sbTriangle::after {\n    content: \" \";\n    position: absolute;\n    top: 100%;\n    left: 15%;\n    margin-left: -15px;\n    border-width: 15px;\n    border-style: solid;\n    border-color: rgba(28, 28, 28, 0.7) transparent transparent transparent;\n}\n\n.sponsorBlockTooltip.sbTriangle.centeredSBTriangle::after {\n\tleft: 50%;\n\tright: 50%;\n}\n\n.sponsorBlockTooltip.sbTriangle.sbTopTriangle::after {\n\tbottom: 100%;\n\ttop: unset;\n\tborder-color: transparent transparent rgba(28, 28, 28, 0.7) transparent;\n}\n\n.sponsorBlockLockedColor {\n\tcolor: #ffc83d !important;\n}\n\n.sponsorBlockRectangleTooltip {\n    position: absolute;\n    border-radius: 5px;\n    padding: 10px;\n    min-width: 250px;\n    min-height: 75px;\n    white-space: normal;\n    line-height: 1.5em;\n}\n\n/* Description on right layout */\n#title > #categoryPillParent {\n\tfont-size: 2rem;\n\tfont-weight: bold;\n\tdisplay: flex;\n\tjustify-content: center;\n\tline-height: 2.8rem;\n}\n#title > #categoryPillParent > #categoryPill.cbPillOpen {\n\tmargin-bottom: 5px;\n}\n\n#categoryPillParent {\n\theight: fit-content;\n    margin-top: auto;\n\tmargin-bottom: auto;\n\n    position: relative;\n}\n\n.sponsorBlockCategoryPill {\n    border-radius: 25px;\n\tpadding-left: 8px;\n\tpadding-right: 8px;\n\tmargin-right: 3px;\n\tcursor: pointer;\n\tfont-size: 75%;\n\theight: 100%;\n\talign-items: center;\n\tinline-size: max-content;\n}\n\n.sponsorBlockCategoryPillTitleSection {\n\tdisplay: flex;\n\talign-items: center;\n}\n\n.sponsorBlockCategoryPillTitle {\n\twhite-space: nowrap;\n}\n\n/* Vorapis V3 support */\n#watch7-content .sponsorBlockCategoryPill {\n\tpadding-top: 5px;\n\tpadding-bottom: 5px;\n}\n#watch7-content .sponsorBlockCategoryPillTitle {\n\tfont-size: 15px;\n}\n\n.categoryPillClose {\n\tdisplay: none;\n\theight: 10px;\n\twidth: 10px;\n\tbox-sizing: unset;\n\n\tmargin: 0px 0px 0px 5px;\n}\n\n.sponsorBlockCategoryPill:hover .categoryPillClose {\n\tdisplay: inherit;\n}\n\n/* tweak for mobile duration */\n#sponsorBlockDurationAfterSkips.ytm-time-display {\n\tpadding-left: 4px;\n\tmargin: 0px;\n\tcolor: #fff;\n\topacity: .7;\n}\n\n/* full video labels on thumbnails */\n.sponsorThumbnailLabel {\n\tdisplay: none;\n\tposition: absolute;\n\ttop: 0;\n\tleft: 0;\n\tpadding: 0.5em;\n\tmargin: 0.5em;\n\tborder-radius: 2em;\n\tz-index: 1000;\n\tbackground-color: var(--category-color, #000);\n\topacity: 0.7;\n\tbox-shadow: 0 0 8px 2px #333;\n\tfont-size: 10px;\n}\n\n.sponsorThumbnailLabel.sponsorThumbnailLabelVisible {\n\tdisplay: flex;\n}\n\n.sponsorThumbnailLabel svg {\n\theight: 2em;\n\tfill: var(--category-text-color, #fff);\n}\n\n.sponsorThumbnailLabel span {\n\tdisplay: none;\n\tpadding-left: 0.25em;\n\tfont-size: 1.5em;\n\tcolor: var(--category-text-color, #fff);\n}\n\n.sponsorThumbnailLabel:hover {\n\tborder-radius: 0.25em;\n\topacity: 1;\n}\n\n.sponsorThumbnailLabel:hover span {\n\tdisplay: inline;\n}\n\n.sponsorblock-chapter-visible {\n\tdisplay: block !important;\n}\n\n/* Pride theme */\n\n.playerButton.prideTheme:nth-of-type(1) {\n\tfilter: brightness(50%) sepia(100) saturate(100);\n}\n\n.playerButton.prideTheme:nth-of-type(2) {\n\tfilter: sepia(100) saturate(100) hue-rotate(0deg);\n}\n\n.playerButton.prideTheme:nth-of-type(3) {\n\tfilter: sepia(100) saturate(100) hue-rotate(45deg);\n}\n\n.playerButton.prideTheme:nth-of-type(4) {\n\tfilter: sepia(100) saturate(100) invert() hue-rotate(5deg);\n}\n\n.playerButton.prideTheme:nth-of-type(5) {\n\tfilter: sepia(100) saturate(100) invert() hue-rotate(35deg);\n}"
  },
  {
    "path": "public/help/index.html",
    "content": "<!DOCTYPE html>\n\n<head>\n  <title> SponsorBlock </title>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /> \n  <link rel=\"icon\" href=\"../icons/IconSponsorBlocker32px.png\" type=\"image/png\">\n\n  <link href=\"styles.css\" rel=\"stylesheet\"/>\n\n  <script src=\"../js/help.js\"></script>\n</head>\n\n<body>\n\n  <div id=\"title\">\n    <img src=\"../icons/LogoSponsorBlocker256px.png\" height=\"80\" class=\"profilepic\"/>\n    SponsorBlock\n  </div>\n\n  <div class=\"container sponsorBlockPageBody\">\n\n    <p class=\"createdBy\">\n      <img src=\"../icons/newprofilepic.jpg\" height=\"30\" class=\"profilepiccircle\"/>\n      Created By <a href=\"https://ajay.app\">Ajay Ramachandran</a> \n    </p>\n\n    <span class=\"help-page-flex-container\">\n      <div class=\"left-sidebar\">\n        <div class=\"box1\">\n          <p>\n              __MSG_helpPageThanksForInstalling__ By using this extension, you agree to the <a href=\"https://gist.github.com/ajayyy/aa9f8ded2b573d4f73a3ffa0ef74f796\">Privacy Policy</a> and <a href=\"https://gist.github.com/ajayyy/9e8100f069348e0bc062641f34d6af12\">Terms of Use</a>.\n          </p>\n      \n          <p>\n              Come contribute, make some suggestions and help out on <a href=\"https://discord.gg/SponsorBlock\">Discord</a> or on <a href=\"https://matrix.to/#/#sponsor:ajay.app?via=ajay.app&via=matrix.org&via=mozilla.org\">Matrix</a>.\n          </p>\n      \n          <a href=\"https://dearrow.ajay.app\"\n              target=\"_blank\"\n              id=\"dearrow-link\"\n              class=\"dearrow-link hidden\"\n              rel=\"noreferrer\">\n              <img src=\"/icons/dearrow.svg\"/>\n      \n              <span id=\"dearrow-link-text\">\n                \n              </span>\n      \n              <img src=\"/icons/close.png\" class=\"close-button\"/>\n          </a>\n      \n          <div id=\"donate-component\" class=\"donate-ask\">\n              <div class=\"donate-text\">\n                  <img\n                      src=\"../icons/newprofilepic.jpg\"\n                      alt=\"Ajay's avatar\"\n                  ></img>\n                  __MSG_supportSponsorBlock__\n              </div>\n      \n              <a href=\"https://sponsor.ajay.app/donate\" class=\"donate-button\" target=\"_blank\" rel=\"noopener\">\n                  __MSG_Donate__\n              </a>\n          </div>\n        </div>\n        <div class=\"box3\">\n          <h1>__MSG_helpPageHowSkippingWorks__</h1>\n    \n          <p class=\"projectPreview\">\n            __MSG_helpPageHowSkippingWorks2__\n          </p>\n\n          <div class=\"center\"><img src=\"images/notice.png\"></div>\n            \n          <p class=\"projectPreview\">\n            __MSG_helpPageHowSkippingWorks1__\n          </p>\n          \n          <div class=\"center\"><img style=\"height: 400px;\" src=\"images/popup.png\"></div>\n\n          <h1>__MSG_Submitting__</h1>\n    \n          <p class=\"projectPreview\">\n              __MSG_helpPageSubmitting1__\n          </p>\n\n          <div class=\"center\"><img src=\"images/votebuttons.gif\"></div>\n\n          <p class=\"projectPreview\">\n              __MSG_helpPageSubmitting2__\n          </p>\n\n          <div class=\"center\"><img src=\"images/submission menu.png\"></div>\n\n          <p class=\"projectPreview center\">\n            <a href=\"https://wiki.sponsor.ajay.app/w/Guidelines\" target=\"_blank\">__MSG_guidelines__</a>\n            <br/>\n            <a href=\"https://wiki.sponsor.ajay.app/w/Advice_for_submitting\" target=\"_blank\">__MSG_AdviceForSubmitting__</a>\n          </p>\n\n          <h1>__MSG_helpPageCopyOfDatabase__</h1>\n    \n          <p>\n            __MSG_helpPageCopyOfDatabase1__ <a href=\"https://sponsor.ajay.app/database\">https://sponsor.ajay.app/database</a>. __MSG_helpPageCopyOfDatabase2__\n          </p>\n    \n          <h4 style=\"display: inline\">Client:</h4>\n          <!-- Github logo -->\n          <a href=\"https://github.com/ajayyy/SponsorBlock\"><svg aria-hidden=\"true\" version=\"1.1\" viewBox=\"0 0 16 16\" height=\"58px\" style=\"padding-left: 15px\"><path fill-rule=\"evenodd\" d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z\"></path></svg></a>\n    \n          <h4 style=\"display: inline; padding-left: 20px\">Server:</h4>\n          <!-- Github logo -->\n          <a href=\"https://github.com/ajayyy/SponsorBlockServer\"><svg aria-hidden=\"true\" version=\"1.1\" viewBox=\"0 0 16 16\" height=\"58px\" style=\"padding-left: 15px\"><path fill-rule=\"evenodd\" d=\"M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z\"></path></svg></a>\n    \n          <h1>__MSG_Credits__</h1>\n    \n          <p>\n            Thanks to all <a href=\"https://github.com/ajayyy/SponsorBlock/graphs/contributors\">SponsorBlock contributors</a>,\n            <a href=\"https://github.com/ajayyy/SponsorBlockServer/graphs/contributors\">SponsorBlockServer contributors</a> and\n            <a href=\"https://github.com/ajayyy/SponsorBlockSite/graphs/contributors\">SponsorBlockSite contributors</a> such \n            as <a href=\"https://github.com/NDevTK\">NDev</a>, <a href=\"https://github.com/Joe-Dowd\">Joe Dowd</a>,\n            <a href=\"https://mchang.name/\">Michael Chang</a> and more.\n          </p>\n\n          <p>\n            Logo by <a href=\"https://github.com/munadikieh\">Munadi Kiehl</a>\n          </p>\n    \n          <p>Some icons made by <a href=\"https://www.flaticon.com/authors/gregor-cresnar\" title=\"Gregor Cresnar\">Gregor Cresnar</a> from <a href=\"https://www.flaticon.com/\" title=\"Flaticon\">www.flaticon.com</a> and are licensed by <a href=\"https://creativecommons.org/licenses/by/3.0/\" title=\"Creative Commons BY 3.0\" target=\"_blank\">CC 3.0 BY</a></p>\n          \n          <p>Some icons made by <a href=\"https://www.flaticon.com/authors/freepik\" title=\"Freepik\">Freepik</a> from <a href=\"https://www.flaticon.com/\" title=\"Flaticon\">www.flaticon.com</a> and are licensed by <a href=\"https://creativecommons.org/licenses/by/3.0/\" title=\"Creative Commons BY 3.0\" target=\"_blank\">CC 3.0 BY</a></p>\n    \n          <p style=\"text-align: center;\"><a href=\"/oss-attribution/attribution.txt\">Open Source Licenses</a></p>\n        </div>\n      </div>\n    \n      <div class=\"box2\">\n        <p style=\"margin-bottom: 0; margin-top: 0\" class=\"bigText center\">__MSG_helpPageReviewOptions__</p>\n    \n        <p class=\"smallText\" style=\"margin-bottom: 0; margin-top: 0\">\n          __MSG_helpPageFeatureDisclaimer__\n        </p>\n    \n        <iframe class=\"optionsFrame\" src=\"../options/options.html#embed\" style=\"border: none\"></iframe>\n      </div>\n    </span>\n</body>\n"
  },
  {
    "path": "public/help/styles.css",
    "content": ":root {\n  --color-scheme: dark;\n  --background: #333333;\n  --header-color: #212121;\n  --dialog-background: #181818;\n  --dialog-border: white;\n  --text: #c4c4c4;\n  --title: #dad8d8;\n  --disabled: #520000;\n  --black: black;\n  --white: white;\n}\n\n[data-theme=\"light\"] {\n  --color-scheme: light;\n  --background: #f9f9f9;\n  --header-color: white;\n  --dialog-background: #f9f9f9;\n  --dialog-border: #282828;\n  --text: #262626;\n  --title: #707070;\n  --disabled: #ffcaca;\n  --black: white;\n  --white: black;\n}\n\nhtml {\n  color-scheme: var(--color-scheme);\n}\n\n.bigText {\n  font-size: 30px;\n}\n\n.smallText {\n  font-size: 14px;\n}\n\nbody {\n  background-color: var(--background);\n  font-family: sans-serif;\n}\n\n.center {\n  text-align: center;\n}\n\n.inline {\n  display: inline-block;\n}\n\n.container {\n  margin: auto;\n}\n\n.projectPreview {\n  position: relative;\n}\n\n.projectPreviewImage {\n  position: absolute;\n  left: -90px;\n  width: 80px;\n  top: 50%;\n  transform: translateY(-50%);\n}\n\n.projectPreviewImageLarge {\n  position: absolute;\n  left: -210px;\n  width: 200px;\n  top: 50%;\n  transform: translateY(-20%);\n}\n\n.createdBy {\n  font-size: 14px;\n  text-align: center;\n  padding-top: 0px;\n  padding-bottom: 0px;\n}\n\n#title {\n  background-color: #636363;\n\n  text-align: center;\n  vertical-align: middle;\n\n  font-size: 50px;\n  color: var(--header-color);\n\n  padding: 20px;\n  \n  text-decoration: none;\n\n  border-radius: 15px;\n\n  transition: font-size 1s;\n}\n\n.subtitle {\n  font-size: 40px;\n  color: #dad8d8;\n\n  padding-top: 10px;\n\n  transition: font-size 0.4s;\n}\n\n.subtitle:hover {\n  font-size: 45px;\n\n  transition: font-size 0.4s;\n}\n\n.profilepic {\n  background-color: #636363 !important;\n  vertical-align: middle;\n}\n\n.profilepiccircle {\n  vertical-align: middle;\n  overflow: hidden;\n  border-radius: 50%;\n}\n\na {\n  text-decoration: underline;\n  color: inherit;\n}\n\n.link {\n  padding: 20px;\n\n  height: 80px;\n\n  transition: height 0.2s;\n}\n\n.link:hover {\n  height: 95px;\n\n  transition: height 0.2s;\n}\n\n#contact,.smalllink {\n  font-size: 25px;\n  color: #e8e8e8;\n\n  text-align: center;\n  \n  padding: 10px;\n}\n\n#contact {\n  text-decoration: none;\n}\n\np,li {\n  font-size: 16px;\n}\n\np,li,a,span,div {\n  color: var(--text);\n}\n\np,li,code,a {\n\ttext-align: left;\n\toverflow-wrap: break-word;\n}\n\n.optionsFrame {\n  width: 100%;\n  height: 100%;\n}\n\n.previewImage {\n\tmax-height: 200px;\n}\n\nimg {\n\tmax-width: 100%;\n\n\ttext-align: center;\n}\n\n#recentPostTitle {\n  font-size: 30px;\n  color: #dad8d8;\n}\n\n#recentPostDate {\n  font-size: 15px;\n  color: #dad8d8;\n}\n\nh1,h2,h3,h4,h5,h6 {\n  color: var(--title);\n  text-align: center;\n\n  font-size: 25px;\n  margin: 5px 0px;\n}\n\nsvg {\n text-decoration: none; \n}\n\n.donate-ask {\n  background-color: rgb(26, 26, 26, 0.95);\n  border-radius: 15px;\n\n  text-align: center;\n  padding: 10px;\n\n  margin: 0.7em 0px;\n}\n\n.donate-ask .donate-text {\n  margin-top: 10px;\n  margin-bottom: 10px;\n\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.donate-ask .donate-text img {\n  height: 2rem;\n  border-radius: 100%;\n\n  margin-right: 15px;\n}\n\n.donate-ask a {\n  text-decoration: none;\n  color: #eee;\n  border-radius: 15px;\n  background-color: rgb(58, 58, 58, 0.9);\n  padding: 10px;\n\n  transition: background-color 0.3s ease;\n\n  display: block;\n  width: fit-content;\n  margin: auto;\n  margin-top: 10px;\n  margin-bottom: 10px;\n}\n\n.donate-ask a:hover {\n  background-color: rgba(70, 70, 70, 0.9);\n}\n\n@media screen and (orientation:portrait) {\n  .projectPreviewImage {\n    position: unset;\n    width: 50%;\n    display: block;\n    margin: auto;\n    transform: none;\n  }\n\n  .projectPreviewImageLarge {\n    position: unset;\n    left: 0;\n    width: 50%;\n    display: block;\n    margin: auto;\n    transform: unset;\n  }\n\n  .container {\n    max-width: 100%;\n    margin: 5px;\n    text-align: center;\n  }\n\n  p,li,code,a {\n    text-align: center;\n  }\n}\n\n/* keybind dialog */\n.key {\n  border-width: 1px;\n  border-style: solid;\n  border-radius: 5px;\n  display: inline-block;\n  min-width: 33px;\n  text-align: center;\n  font-weight: bold;\n  border-color: var(--white);\n  box-sizing: border-box;\n}\n\n.unbound, .key {\n  padding: 8px;\n}\n\n#keybind-dialog .dialog {\n  position: fixed;\n  border-width: 3px;\n  border-style: solid;\n  border-radius: 15px;\n  max-height: 100vh;\n  width: 400px;\n  overflow-x: auto;\n  z-index: 100;\n  padding: 15px;\n  left: 50%;\n  top: 50%;\n  transform: translate(-50%, -50%);\n  font-size: 14px;\n  background-color: var(--dialog-background);\n  border-color: var(--dialog-border);\n}\n\n#change-keybind-buttons {\n  float: right;\n}\n\n#change-keybind-buttons > .option-button {\n  margin: 0 2px;\n}\n\n#change-keybind-settings {\n  margin: 15px 15px 30px;\n}\n\n#change-keybind-settings .key {\n  vertical-align: top;\n  margin: 15px 0 0 40px;\n  height: 34px;\n}\n\n#change-keybind-error {\n  margin-bottom: 15px;\n  color: red;\n  font-weight: bold;\n}\n\n.blocker {\n  position: fixed;\n  left: 0;\n  right: 0;\n  top: 0;\n  bottom: 0;\n  z-index: 90;\n  background-color: #00000080;\n}\n\n.option-button {\n  cursor: pointer;\n\n  background-color: #c00000;\n  padding: 10px;\n  color: white;\n  border-radius: 5px;\n  font-size: 14px;\n\n  width: max-content;\n}\n\n.option-button:hover:not(.disabled) {\n  background-color: #fc0303;\n}\n\n.option-button.disabled {\n  cursor: default;\n  background-color: var(--disabled);\n  color: grey;\n}\n\n.dearrow-link {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  text-decoration: none;\n\n  font-size: 16px;\n}\n\n.dearrow-link img {\n  width: 35px;\n  padding: 10px\n}\n\n.dearrow-link .close-button {\n  opacity: 0;\n  width: 15px;\n  filter: invert(0.3);\n  transition: opacity 0.2s;\n}\n\n.dearrow-link:hover .close-button {\n  opacity: 1;\n}\n\n.hidden {\n  display: none;\n}\n\n.help-page-flex-container {\n  display: flex;\n  flex-direction: row;\n  gap: 20px;\n  margin-left: 20px;\n  margin-right: 20px;\n}\n\n.left-sidebar {\n  display: flex;\n  flex-direction: column;\n\n  flex: 1 1 50%;\n}\n\n.box2 {\n  flex: 1 1 50%;\n}\n\n/* Mobile */\n@media only screen and (max-width: 600px) {\n  .box1 {\n    order: 1;\n  }\n\n  .box2 {\n    order: 2;\n  }\n\n  .box3 {\n    order: 3;\n  }\n\n  .left-sidebar {\n    display: contents;\n  }\n\n  .help-page-flex-container {\n    flex-direction: column;\n  }\n\n  .optionsFrame {\n    height: 500px;\n  }\n}"
  },
  {
    "path": "public/libs/Source+Sans+Pro.css",
    "content": "/* cyrillic-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(6xK3dSBYKcSV-LCoeQqfX1RYOo3qNa7lqDY.woff2) format('woff2');\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n/* cyrillic */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(6xK3dSBYKcSV-LCoeQqfX1RYOo3qPK7lqDY.woff2) format('woff2');\n  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n/* greek-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(6xK3dSBYKcSV-LCoeQqfX1RYOo3qNK7lqDY.woff2) format('woff2');\n  unicode-range: U+1F00-1FFF;\n}\n/* greek */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(6xK3dSBYKcSV-LCoeQqfX1RYOo3qO67lqDY.woff2) format('woff2');\n  unicode-range: U+0370-03FF;\n}\n/* vietnamese */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(6xK3dSBYKcSV-LCoeQqfX1RYOo3qN67lqDY.woff2) format('woff2');\n  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(6xK3dSBYKcSV-LCoeQqfX1RYOo3qNq7lqDY.woff2) format('woff2');\n  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 400;\n  src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7l.woff2) format('woff2');\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n/* cyrillic-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 700;\n  src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmhduz8A.woff2) format('woff2');\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n/* cyrillic */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 700;\n  src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwkxduz8A.woff2) format('woff2');\n  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n/* greek-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 700;\n  src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmxduz8A.woff2) format('woff2');\n  unicode-range: U+1F00-1FFF;\n}\n/* greek */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 700;\n  src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlBduz8A.woff2) format('woff2');\n  unicode-range: U+0370-03FF;\n}\n/* vietnamese */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 700;\n  src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmBduz8A.woff2) format('woff2');\n  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 700;\n  src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmRduz8A.woff2) format('woff2');\n  unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n  font-family: 'Source Sans Pro';\n  font-style: normal;\n  font-weight: 700;\n  src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdu.woff2) format('woff2');\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n"
  },
  {
    "path": "public/options/options.css",
    "content": "/* Options page CSS */\n\n:root {\n    --color-scheme: dark;\n    --background: #333333;\n    --menu-background: #181818;\n    --menu-foreground: white;\n    --dialog-background: #181818;\n    --dialog-border: white;\n    --tab-color: #242424;\n    --tab-button-hover: #4d0000;\n    --tab-hover: white;\n    --description: #dfdfdf;\n    --disabled: #520000;\n    --slider: #707070;\n    --title: #dad8d8;\n    --border-color: #484848;\n    --black: black;\n    --white: white;\n\n    --selector-red: #c00000;\n    --selector-red-hover: #fc0303;\n    --tab-selected: #950000;\n}\n\n[data-theme=\"light\"] {\n    --color-scheme: light;\n    --background: #f9f9f9;\n    --menu-background: #dbdbdb;\n    --menu-foreground: #212121;\n    --dialog-background: #f9f9f9;\n    --dialog-border: #282828;\n    --tab-color: #ababab;\n    --tab-button-hover: #750000;\n    --tab-hover: #2e2e2e;\n    --description: #262626;\n    --disabled: #ffcaca;\n    --slider: #bfbebe;\n    --title: #707070;\n    --border-color: #d9d9d9;\n    --black: white;\n    --white: black;\n}\n\n[data-theme=\"pride\"] {\n    --menu-background: #181818d0;\n}\n\n.medium-description, .switch-container, .optionLabel, .categoryTableElement, .promotion-description {\n    color: var(--white);\n}\n\n.small-description, p, li, span, div {\n    color: var(--description);\n}\n\nh1,h2,h3,h4,h5,h6 {\n    color: var(--title);\n}\n\nhtml, body {\n    color-scheme: var(--color-scheme);\n    font-family: sans-serif;\n    margin: 0;\n    font-size: 14px;\n    background-color: var(--background);\n}\n\n[data-theme=\"pride\"] body {\n    background: url(\"../icons/pride.svg\");\n    background-size: contain;\n}\n\n\n* {\n    box-sizing: border-box;\n}\n\n#options-container {\n    display: flex;\n}\n\n#menubar {\n    display: flex;\n    flex-direction: column;\n    gap: 20px;\n    flex-basis: 20%;\n    min-width: 300px;\n    max-width: 600px;\n    border-radius: 15px;\n    margin: 15px;\n    z-index: 10;\n    background-color: var(--menu-background);\n    color: var(--menu-foreground);\n}\n\n#navigation {\n    display: flex;\n    flex-direction: column;\n    gap: 30px;\n}\n\n.tab-heading {\n    font-size: 18px;\n    height: 55px;\n    line-height: 55px;\n    width: 80%;\n    margin: 0 auto;\n    border-radius: 15px;\n    cursor: pointer;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n    overflow: hidden;\n    background-color: var(--tab-color);\n    color: var(--white);\n}\n\n.tab-heading:hover {\n    background-color: var(--tab-button-hover);\n    color: white;\n}\n\n.tab-heading.selected {\n    background-color: var(--selector-red);\n    color: white;\n}\n\n.tab-heading:active {\n    background-color: var(--tab-selected);\n    color: white;\n}\n\n[data-theme=\"pride\"] .tab-heading:nth-of-type(1) {\n    background-color: #2f0000;\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(2) {\n    background-color: #3a2000;\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(3) {\n    background-color: #3e3a00;\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(4) {\n    background-color: #003e13;\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(5) {\n    background-color: #00164a;\n}\n\n[data-theme=\"pride\"] .tab-heading:hover:nth-of-type(1) {\n    background-color: #550000;\n}\n[data-theme=\"pride\"] .tab-heading:hover:nth-of-type(2),\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n) .slider,\n[data-theme=\"pride\"] [data-type=\"toggle\"]:nth-of-type(5n) .slider {\n    background-color: #824700;\n}\n[data-theme=\"pride\"] .tab-heading:hover:nth-of-type(3),\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n + 1) .slider,\n[data-theme=\"pride\"] [data-type=\"toggle\"]:nth-of-type(5n + 1) .slider {\n    background-color: #867d00;\n}\n[data-theme=\"pride\"] .tab-heading:hover:nth-of-type(4),\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n + 2) .slider,\n[data-theme=\"pride\"] [data-type=\"toggle\"]:nth-of-type(5n + 2) .slider {\n    background-color: #00691f;\n}\n[data-theme=\"pride\"] .tab-heading:hover:nth-of-type(5),\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n + 3) .slider,\n[data-theme=\"pride\"] [data-type=\"toggle\"]:nth-of-type(5n + 3) .slider {\n    background-color: #002374;\n}\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n + 4) .slider,\n[data-theme=\"pride\"] [data-type=\"toggle\"]:nth-of-type(5n + 4) .slider {\n    background-color: #400449;\n}\n\n[data-theme=\"pride\"] #category-type tr .optionsSelector {\n    color: var(--white);\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(1).selected {\n    background-color: #E40303;\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(2).selected,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(10n + 2) .optionsSelector,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n) input:checked + .slider,\n[data-theme=\"pride\"] [data-type]:nth-of-type(5n) :is(input:checked + .slider, .option-button, .optionsSelector) {\n    background-color: #dd7a00;\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(3).selected,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(10n + 4) .optionsSelector,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(2n + 1) .optionsSelector,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n + 1) input:checked + .slider,\n[data-theme=\"pride\"] [data-type]:nth-of-type(5n + 1) :is(input:checked + .slider, .option-button, .optionsSelector) {\n    background-color: #FFED00;\n    color: rgb(23, 23, 23);\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(4).selected,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(10n + 6) .optionsSelector,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n + 2) input:checked + .slider,\n[data-theme=\"pride\"] [data-type]:nth-of-type(5n + 2) :is(input:checked + .slider, .option-button, .optionsSelector) {\n    background-color: #008026;\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(5).selected,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(10n + 8) .optionsSelector,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n + 3) input:checked + .slider,\n[data-theme=\"pride\"] [data-type]:nth-of-type(5n + 3) :is(input:checked + .slider, .option-button, .optionsSelector) {\n    background-color: #004DFF;\n}\n[data-theme=\"pride\"] .tab-heading:nth-of-type(5).selected,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(10n + 10) .optionsSelector,\n[data-theme=\"pride\"] #category-type tr:nth-of-type(5n + 4) input:checked + .slider,\n[data-theme=\"pride\"] [data-type]:nth-of-type(5n + 4) :is(input:checked + .slider, .option-button, .optionsSelector) {\n    background-color: #750787;\n}\n\n\n.option-group > div, .extraOptionGroup {\n    min-height: 50px;\n    padding: 15px 0;\n    border-image: linear-gradient(to right, var(--border-color), #00000000 80%)  1;\n}\n.option-group > div {\n    border-bottom: 1px solid var(--border-color);\n}\n.extraOptionGroup {\n    border-top: 1px solid var(--border-color);\n}\n.extraOptionGroup tr:not(:last-child) {\n    padding-bottom: 15px;\n    display: block;\n}\n#category-type {\n    padding: 0;\n}\n\n#category-type .categoryExtraOptions {\n    padding-bottom: 15px;\n}\n\n#music_offtopic_autoSkipOnMusicVideos {\n    padding-bottom: 0;\n}\n\n.option-group > div:last-child, .option-group > #keybind-dialog {\n    border-bottom: inherit;\n}\n\n.optionLabel, #version {\n    font-size: 14px;\n    height: 15px;\n}\n\ndiv[data-type=\"keybind-change\"] .optionLabel {\n    display: inline-block;\n    min-width: 150px;\n    margin-right: 20px;\n}\n\ninput[type='number'] {\n    width: 50px;\n}\n\n.key {\n    border-width: 1px;\n    border-style: solid;\n    border-radius: 5px;\n    display: inline-block;\n    min-width: 33px;\n    text-align: center;\n    font-weight: bold;\n    border-color: var(--white);\n}\n\n.unbound, .key {\n    padding: 8px;\n}\n\n.keybind-buttons {\n    border-radius: 5px;\n    padding: 5px 3px;\n    cursor: pointer;\n    margin-right: 10px;\n}\n\n.keybind-buttons:hover {\n    background-color: #00000030;\n}\n\n.keybind-buttons > div, .keybind-buttons > span {\n    margin: 0 2px;\n}\n\n#keybind-dialog .dialog {\n    position: fixed;\n    border-width: 3px;\n    border-style: solid;\n    border-radius: 15px;\n    max-height: 100vh;\n    width: 400px;\n    overflow-x: auto;\n    z-index: 100;\n    padding: 15px;\n    left: 50%;\n    top: 50%;\n    transform: translate(-50%, -50%);\n    background-color: var(--dialog-background);\n    border-color: var(--dialog-border);\n}\n\n#change-keybind-buttons {\n    float: right;\n}\n\n#change-keybind-buttons > .option-button {\n    margin: 0 2px;\n}\n\n#change-keybind-settings {\n    margin: 15px 15px 30px;\n}\n\n#change-keybind-settings .key {\n    vertical-align: top;\n    margin: 15px 0 0 40px;\n    height: 34px;\n}\n\n#change-keybind-error {\n    margin-bottom: 15px;\n    color: red;\n    font-weight: bold;\n}\n\n.blocker {\n    position: fixed;\n    left: 0;\n    right: 0;\n    top: 0;\n    bottom: 0;\n    z-index: 90;\n    background-color: #00000080;\n}\n\n.low-profile {\n    height: 23px;\n    line-height: 5px;\n    vertical-align: middle;\n}\n\n.center {\n    text-align: center;\n}\n\n.inline {\n    display: inline-block;\n}\n\n.next-line {\n    padding: 15px 0 0 0;\n}\n\n.bold {\n    font-weight: bold;\n}\n\n.hiding {\n    opacity: 0;\n}\n\n.hidden, .sbhidden {\n    display: none !important;\n}\n\n.spacing {\n    margin-top: 15px;\n}\n\n.keybind-status {\n    display: inline;\n}\n\n.small-description {\n    font-size: 13px;\n    padding: 5px 0 0 20px;\n}\n\n.small-description td {\n    padding: 2.5px 0 10px 20px;\n}\n\n.indent {\n    padding-left: 20px;\n}\n\n.categoryTableElement td {\n    padding-top: 5px;\n    border-top: 1px solid var(--border-color);\n}\n\n.medium-description {\n    font-size: 15px;\n}\n\n.option-text-box {\n    width: 300px;\n}\n\n.option-button {\n    cursor: pointer;\n\n    background-color: var(--selector-red);\n    padding: 10px;\n    color: white;\n    border-radius: 5px;\n    font-size: 14px;\n\n    width: max-content;\n}\n\n.option-button:hover:not(.disabled) {\n    background-color: var(--selector-red-hover);\n}\n\n.option-button.disabled {\n    cursor: default;\n    background-color: var(--disabled);\n    color: grey;\n}\n\n.sb-toggle-option.disabled .slider {\n    cursor: default;\n}\n\n/* To hide everything except upsell button */\n.disabled td:not(.skipOption, .categoryExtraOptions), .disabled td.skipOption > :not(.upsellButton) {\n    opacity: 0.3;\n}\n\n#options {\n    height: 100vh;\n    flex-basis: 80%;\n    overflow: auto;\n    text-align: left;\n    padding: 80px 15% 0 3%;\n    box-sizing: border-box;\n    display: flex;\n    justify-content: center;\n\n    transition: padding 0.3s;\n}\n\n#options.embed > div {\n    max-width: 100%;\n}\n\n#title .profilepic {\n    height: 60px;\n}\n\n.switch-container {\n\tcontent: attr(label-name);\n    width: max-content;\n\n    font-size: 14px;\n\n    display: flex;\n    align-items: center;\n}\n\n.switch-container .switch-label {\n    display: table-cell;\n    vertical-align: middle;\n\n    padding: 4px;\n}\n\n.sb-number-input {\n    margin-left: 4px;\n    margin-right: 4px;\n}\n\n.switch-label {\n    width: inherit;\n}\n\n.switch {\n    position: relative;\n    display: inline-block;\n    width: 40px;\n    height: 24px;\n    min-width: 40px;\n}\n\n.switch input { \n    opacity: 0;\n    width: 0;\n    height: 0;\n}\n\n.slider {\n    position: absolute;\n    cursor: pointer;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background-color: var(--slider);\n}\n\n.animated * {\n    -webkit-transition: .4s;\n    transition: .4s;\n}\n\n.slider:before {\n    position: absolute;\n    content: \"\";\n    height: 16px;\n    width: 16px;\n    left: 4px;\n    bottom: 4px;\n    background-color: white;\n}\n\n.animated .slider:before {\n    -webkit-transition: .4s;\n    transition: .4s;\n}\n\ninput:checked + .slider {\n    background-color: var(--selector-red-hover);\n}\n\ninput:checked + .slider:before {\n    -webkit-transform: translateX(16px);\n    -ms-transform: translateX(16px);\n    transform: translateX(16px);\n}\n\n/* Rounded sliders */\n.slider.round {\n    border-radius: 34px;\n}\n\n.slider.round:before {\n    border-radius: 50%;\n}\n\n\n\n/* Boilerplate CSS from https://ajay.app (edited) */\n\n.projectPreview {\n    position: relative;\n}\n\n.projectPreviewImage {\n    position: absolute;\n    left: -90px;\n    width: 80px;\n    top: 50%;\n    transform: translateY(-50%);\n}\n\n.projectPreviewImageLarge {\n    position: absolute;\n    left: -210px;\n    width: 200px;\n    top: 50%;\n    transform: translateY(-20%);\n}\n\n.projectPreviewImageLargeRight {\n    position: absolute;\n    right: -210px;\n    width: 200px;\n    top: 50%;\n    transform: translateY(-50%);\n}\n\n#createdBy {\n    text-align: center;\n    margin: auto 0 10px 0;\n    height: 50px;\n}\n\n#createdBy > * {\n    font-size: 14px;\n}\n\n#title {\n    text-align: center;\n    vertical-align: middle;\n  \n    font-size: 40px;\n  \n    padding: 40px 20px;\n  \n    text-decoration: none;\n}\n\n.subtitle {\n    font-size: 40px;\n    color: #dad8d8;\n  \n    padding-top: 10px;\n  \n    transition: font-size 0.4s;\n}\n\n.subtitle:hover {\n    font-size: 45px;\n  \n    transition: font-size 0.4s;\n}\n\n.profilepic {\n    vertical-align: middle;\n}\n\n.profilepiccircle {\n    vertical-align: middle;\n    overflow: hidden;\n    border-radius: 50%;\n}\n\na {\n    text-decoration: underline;\n    color: inherit;\n}\n\n.link {\n    padding: 20px;\n  \n    height: 80px;\n  \n    transition: height 0.2s;\n}\n\n.link:hover {\n    height: 95px;\n  \n    transition: height 0.2s;\n}\n\n#contact,.smalllink {\n    font-size: 25px;\n    color: #e8e8e8;\n  \n    text-align: center;\n  \n    padding: 10px;\n}\n\n#contact {\n    text-decoration: none;\n}\n\np,li {\n    font-size: 20px;\n    padding: 10px;\n}\n\n.previewImage {\n    max-height: 200px;\n}\n\nimg {\n    max-width: 100%;\n  \n    text-align: center;\n}\n\n#recentPostTitle {\n    font-size: 30px;\n    color: #dad8d8;\n}\n\n#recentPostDate {\n    font-size: 15px;\n    color: #dad8d8;\n}\n\nsvg {\n    text-decoration: none; \n}\n\n.number-container:before {\n    content: attr(label-name);\n    padding-right: 4px;\n    width: max-content;\n\n    font-size: 14px;\n    color: white;\n}\n\n/* React styles */\n\n.categoryTableElement {\n    font-size: 16px;\n}\n\n.categoryTableElement > * {\n    padding-right: 15px;\n}\n\n.categoryTableDescription > * {\n    padding-bottom: 15px;\n}\n\n.optionsSelector {\n    background-color: var(--selector-red);\n    color: white;\n    \n    border: none;\n    font-size: 14px;\n    padding: 5px;\n    border-radius: 5px;\n}\n\n.categoryColorTextBox {\n    width: 60px;\n\n    background: none;\n    border: none;\n}\n\n#sbDonate {\n    font-size: 10px;\n}\n\n\n/* Top bar navigation for smaller screens */\n@media only screen and (max-height: 725px), only screen and (max-width: 1200px) {\n    #options-container {\n        flex-direction: column;\n    }\n    #menubar {\n        gap: 8px;\n        min-width: unset;\n        max-width: unset;\n        padding: 8px;\n    }\n    #navigation {\n        gap: 8px;\n        flex-direction: row;\n        flex-wrap: wrap;\n        justify-content: center;\n    }\n    #options {\n        padding: 0 50px;\n    }\n\n    #options > div {\n        max-width: 70%;\n    }\n\n    .tab-heading {\n        width: unset;\n        min-width: unset;\n        height: 35px;\n        line-height: 35px;\n        font-size: 16px;\n        padding: 0 10px;\n        margin: 0;\n    }\n    #title {\n        width: 100%;\n        font-size: 30px;\n        padding: 10px;\n    }\n    #title .profilepic {\n        height: 40px;\n    }\n    #createdBy {\n        margin: 10px 0 0 0;\n        height: unset;\n        width: 100%;\n    }\n    #createdBy > div {\n        display: inline-block;\n    }\n    #sbDonate {\n        position: absolute;\n        right: 30px;\n        margin-top: 10px;\n    }\n    #version {\n        font-size: 10px;\n        height: 10px;\n        transform: translate(-50px, -5px);\n    }\n    .sticky #menubar {\n        position: fixed;\n        left: 0;\n        right: 0;\n        margin: 0 15px;\n    }\n    .sticky #title, .sticky #createdBy {\n        display: none;\n    }\n}\n\n@media only screen and (max-width: 800px) {\n    #options {\n        padding: 0 15px;\n    }\n    #options > div {\n        max-width: 100%;\n    }\n}\n\n.upsellButton {\n    cursor: pointer;\n    vertical-align: middle;\n}\n\n.no-bottom-border {\n    border: none !important;\n    padding: 20px 0px 0px 0px !important;\n}\n\n.promotion-container {\n    width: fit-content;\n}\n\n.dearrow-link {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    text-decoration: none;\n}\n\n.dearrow-link > img {\n    width: 40px;\n    margin-right: 4px;\n}\n\n.dearrow-link .close-button {\n    opacity: 0;\n    width: 15px;\n    filter: invert(0.3);\n    transition: opacity 0.2s;\n    margin-left: 10px;\n}\n  \n.dearrow-link:hover .close-button {\n    opacity: 1;\n}\n\n.invalid-advanced-config {\n    color: red;\n}\n\n.advanced-skip-options-menu {\n    margin-top: 10px;\n}\n\n.advanced-config-help-message {\n    margin-bottom: 10px;\n    transition: none;\n}\n\n.categoryChooserTopRow {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n\n    margin-bottom: 10px;\n}\n\n.partiallyHidden {\n    opacity: 0.5;\n}\n\n.partiallyHidden:hover {\n    opacity: 1;\n}\n\n.reset-button svg {\n    margin-left: 5px;\n    width: 10px;\n    fill: var(--white);\n\n    cursor: pointer;\n}\n\n.skipProfileMenu {\n    position: absolute;\n}\n\n.configurationInfo > *:not(:last-child) {\n    margin-bottom: 10px;\n}\n\n.configurationInfo .option-text-box {\n    width: 100%;\n}"
  },
  {
    "path": "public/options/options.html",
    "content": "<!DOCTYPE html>\n<!-- Link to specific tabs by using their ID in the URL like: options.html#keybinds -->\n\n<head>\n  <title>__MSG_Options__ - SponsorBlock</title>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /> \n  <link rel=\"icon\" href=\"../icons/IconSponsorBlocker32px.png\" type=\"image/png\">\n  \n  <link href=\"options.css\" rel=\"stylesheet\"/>\n\n  <script src=\"../js/options.js\"></script>\n</head>\n\n<body class=\"sponsorBlockPageBody\">\n\n\t<div id=\"options-container\">\n\n\t\t<div id=\"menubar\" class=\"center\">\n\n\t\t\t<div id=\"title\" class=\"titleBar\">\n\t\t\t\t<img id=\"title-bar-logo\" src=\"../icons/LogoSponsorBlocker256px.png\" class=\"profilepic\" alt=\"SponsorBlock logo\"/>\n\t\t\t\tSponsorBlock\n\t\t\t\t<div id=\"version\"></div>\n\t\t\t</div>\n\n\t\t\t<div id=\"navigation\">\n\t\t\t\t<div class=\"tab-heading\" data-for=\"behavior\">\n\t\t\t\t\t__MSG_optionsTabBehavior__\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"tab-heading\" data-for=\"interface\">\n\t\t\t\t\t__MSG_optionsTabInterface__\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"tab-heading\" data-for=\"keybinds\">\n\t\t\t\t\t__MSG_optionsTabKeyBinds__\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"tab-heading\" data-for=\"import\">\n\t\t\t\t\t__MSG_optionsTabBackup__\n\t\t\t\t</div>\n\n\t\t\t\t<div class=\"tab-heading\" data-for=\"advanced\">\n\t\t\t\t\t__MSG_optionsTabAdvanced__\n\t\t\t\t</div>\n\t\t</div>\n\n\t\t\t<div id=\"createdBy\" class=\"titleBar\">\n\t\t\t\t<div>\n\t\t\t\t\t<img src=\"../icons/newprofilepic.jpg\" height=\"30\" class=\"profilepiccircle\" alt=\"profile picture of creator\"/>\n\t\t\t\t\t__MSG_createdBy__ \n\t\t\t\t\t<a href=\"https://ajay.app\">Ajay Ramachandran</a> \n\t\t\t\t</div>\n\t\t\t\t<a href=\"https://sponsor.ajay.app/donate\" target=\"_blank\" rel=\"noopener\" id=\"sbDonate\">(__MSG_Donate__)</a>\n\t\t\t</div>\n\n\t\t</div>\n\n\t\t<div id=\"options\">\n\n\t\t\t<div id=\"behavior\" class=\"option-group hidden\">\n\n\t\t\t\t<div id=\"category-type\" data-type=\"react-CategoryChooserComponent\">\n\n\t\t\t\t</div>\n\n\t\t\t\t<div id=\"deArrowPromotion\" class=\"promotion-container hidden\">\n\t\t\t\t\t<a class=\"dearrow-link\"\n                        href=\"https://dearrow.ajay.app\"\n                        target=\"_blank\"\n                        rel=\"noreferrer\">\n                        <img src=\"/icons/dearrow.svg\"/>\n\n                        <span class=\"promotion-description\">\n                            __MSG_DeArrowPromotionMessage__\n                        </span>\n\n\t\t\t\t\t\t<img src=\"/icons/close.png\" class=\"close-button\"/>\n                    </a>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"react-AdvancedSkipOptionsComponent\" class=\"hide-when-skip-profile\"></div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"forceChannelCheck\" class=\"hide-when-skip-profile\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"forceChannelCheck\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"forceChannelCheck\">\n\t\t\t\t\t\t\t__MSG_forceChannelCheck__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_whatForceChannelCheck__</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"showCategoryWithoutPermission\" class=\"hide-when-skip-profile\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"showCategoryWithoutPermission\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"showCategoryWithoutPermission\">\n\t\t\t\t\t\t\t__MSG_enableShowCategoryWithoutPermission__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_whatShowCategoryWithoutPermission__</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div id=\"ytNeuterPromotion\" class=\"promotion-container\">\n\t\t\t\t\t<a class=\"dearrow-link\"\n                        href=\"https://github.com/mchangrh/yt-neuter/blob/main/docs/filters/sponsorblock.md#install\"\n                        target=\"_blank\"\n                        rel=\"noreferrer\">\n\n                        <span class=\"promotion-description\">\n                            __MSG_YtNeuterMessage__\n                        </span>\n                    </a>\n\n\t\t\t\t\t<div class=\"small-description\">__MSG_requiresUblock__</div>\n\t\t\t\t</div>\n\n\t\t\t</div>\n\n\t\t\t<div id=\"interface\" class=\"option-group hidden\">\n\n\t\t\t\t<div data-type=\"number-change\" data-sync=\"skipNoticeDuration\">\n\t\t\t\t\t<label class=\"number-container\">\n\t\t\t\t\t\t<span class=\"optionLabel\">__MSG_skipNoticeDuration__</span>\n\t\t\t\t\t\t<input type=\"number\" step=\"1\" min=\"1\">\n\t\t\t\t\t</label>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_skipNoticeDurationDescription__</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div>\n\t\t\t\t\t<div data-type=\"toggle\" data-sync=\"showUpcomingNotice\">\n\t\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t\t<input id=\"showUpcomingNotice\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<label class=\"switch-label\" for=\"showUpcomingNotice\">\n\t\t\t\t\t\t\t\t__MSG_showUpcomingNotice__\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<br/>\n\n\t\t\t\t\t<div data-type=\"toggle\" data-toggle-type=\"reverse\" data-sync=\"dontShowNotice\">\n\t\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t\t<input id=\"dontShowNotice\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<label class=\"switch-label\" for=\"dontShowNotice\">\n\t\t\t\t\t\t\t\t__MSG_showSkipNotice__\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div data-type=\"selector\" data-sync=\"noticeVisibilityMode\" data-dependent-on=\"dontShowNotice\">\n\t\t\t\t\t\t<br/>\n\t\t\t\t\t\t\n\t\t\t\t\t\t<label class=\"optionLabel\" for=\"noticeVisibilityMode\">__MSG_noticeVisibilityLabel__:</label>\n\t\n\t\t\t\t\t\t<select id=\"noticeVisibilityMode\" class=\"selector-element optionsSelector\" >\n\t\t\t\t\t\t\t<option value=\"0\">__MSG_noticeVisibilityMode0__</option>\n\t\t\t\t\t\t\t<option value=\"1\">__MSG_noticeVisibilityMode1__</option>\n\t\t\t\t\t\t\t<option value=\"2\">__MSG_noticeVisibilityMode2__</option>\n\t\t\t\t\t\t\t<option value=\"3\">__MSG_noticeVisibilityMode3__</option>\n\t\t\t\t\t\t\t<option value=\"4\">__MSG_noticeVisibilityMode4__</option>\n\t\t\t\t\t\t</select>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"showCategoryGuidelines\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"showCategoryGuidelines\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"showCategoryGuidelines\">\n\t\t\t\t\t\t\t__MSG_showCategoryGuidelines__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"toggle\" data-toggle-type=\"reverse\" data-sync=\"hideVideoPlayerControls\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"hideVideoPlayerControls\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"hideVideoPlayerControls\">\n\t\t\t\t\t\t\t__MSG_showButtons__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_hideButtonsDescription__</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-toggle-type=\"reverse\" data-sync=\"hideDeleteButtonPlayerControls\" data-dependent-on=\"hideVideoPlayerControls\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"hideDeleteButtonPlayerControls\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"hideDeleteButtonPlayerControls\">\n\t\t\t\t\t\t\t__MSG_showDeleteButton__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"toggle\" data-toggle-type=\"reverse\" data-sync=\"hideUploadButtonPlayerControls\" data-dependent-on=\"hideVideoPlayerControls\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"hideUploadButtonPlayerControls\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"hideUploadButtonPlayerControls\">\n\t\t\t\t\t\t\t__MSG_showUploadButton__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"toggle\" data-toggle-type=\"reverse\" data-sync=\"hideSkipButtonPlayerControls\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"hideSkipButtonPlayerControls\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"hideSkipButtonPlayerControls\">\n\t\t\t\t\t\t\t__MSG_showSkipButton__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"toggle\" data-toggle-type=\"reverse\" data-sync=\"hideInfoButtonPlayerControls\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"hideInfoButtonPlayerControls\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"hideInfoButtonPlayerControls\">\n\t\t\t\t\t\t\t__MSG_showInfoButton__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"autoHideInfoButton\" data-dependent-on=\"hideInfoButtonPlayerControls\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"autoHideInfoButton\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"autoHideInfoButton\">\n\t\t\t\t\t\t\t__MSG_autoHideInfoButton__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"allowScrollingToEdit\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"allowScrollingToEdit\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"allowScrollingToEdit\">\n\t\t\t\t\t\t\t__MSG_allowScrollingToEdit__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"audioNotificationOnSkip\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"audioNotificationOnSkip\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"audioNotificationOnSkip\">\n\t\t\t\t\t\t\t__MSG_audioNotification__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_audioNotificationDescription__</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"showTimeWithSkips\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"showTimeWithSkips\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"showTimeWithSkips\">\n\t\t\t\t\t\t\t__MSG_showTimeWithSkips__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_showTimeWithSkipsDescription__</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div>\n\t\t\t\t\t<div data-type=\"toggle\" data-sync=\"cleanPopup\">\n\t\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t\t<input id=\"cleanPopup\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<label class=\"switch-label\" for=\"cleanPopup\">\n\t\t\t\t\t\t\t\t__MSG_cleanPopup__\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<br/>\n\n\t\t\t\t\t<div data-type=\"toggle\" data-sync=\"hideSegmentCreationInPopup\">\n\t\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t\t<input id=\"hideSegmentCreationInPopup\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t\t<label class=\"switch-label\" for=\"hideSegmentCreationInPopup\">\n\t\t\t\t\t\t\t\t__MSG_hideSegmentCreationInPopup__\n\t\t\t\t\t\t\t</label>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<br />\n\n\t\t\t\t\t<div data-type=\"selector\" data-sync=\"segmentListDefaultTab\">\n\t\t\t\t\t\t<label class=\"optionLabel\" for=\"segmentListDefaultTab\">__MSG_segmentListDefaultTab__:</label>\n\n\t\t\t\t\t\t<select id=\"segmentListDefaultTab\" class=\"selector-element optionsSelector\">\n\t\t\t\t\t\t\t<option value=\"0\">__MSG_SegmentsCap__</option>\n\t\t\t\t\t\t\t<option value=\"1\">__MSG_Chapters__</option>\n\t\t\t\t\t\t</select>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"darkMode\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"darkMode\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"darkMode\">\n\t\t\t\t\t\t\t__MSG_darkModeOptionsPage__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"prideTheme\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"prideTheme\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"prideTheme\">\n\t\t\t\t\t\t\t__MSG_prideTheme__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-toggle-type=\"reverse\" data-sync=\"showNewFeaturePopups\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"showNewFeaturePopups\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"showNewFeaturePopups\">\n\t\t\t\t\t\t\t__MSG_hideNewFeatureUpdates__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-toggle-type=\"reverse\" data-sync=\"showDonationLink\" data-no-safari=\"true\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"showDonationLink\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"showDonationLink\">\n\t\t\t\t\t\t\t__MSG_hideDonationLink__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-toggle-type=\"reverse\" data-sync=\"showUpsells\" data-no-safari=\"true\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"showUpsell\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"showUpsells\">\n\t\t\t\t\t\t\t__MSG_hideUpsells__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t</div>\n\n\t\t\t<div id=\"keybinds\" class=\"option-group hidden\">\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"skipKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_setSkipShortcut__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"skipToHighlightKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_skip_to_category__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"closeSkipNoticeKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_setCloseSkipNoticeKeybind__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"startSponsorKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_setStartSponsorShortcut__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"submitKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_setOpenSubmissionMenuKeybind__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"previewKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_setPreviewKeybind__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"actuallySubmitKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_setSubmitKeybind__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"nextChapterKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_nextChapterKeybind__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"previousChapterKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_previousChapterKeybind__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"upvoteKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_setUpvoteKeybind__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"keybind-change\" data-sync=\"downvoteKeybind\">\t\n\t\t\t\t\t<label class=\"optionLabel\">__MSG_setDownvoteKeybind__:</label>\n\t\t\t\t\t<div class=\"inline\"></div>\n\t\t\t\t</div>\n\t\t\t\t\n\t\t\t</div>\n\n\t\t\t<div id=\"import\" class=\"option-group hidden\">\n\n\t\t\t\t<div data-type=\"private-text-change\" data-sync=\"userID\" data-confirm-message=\"userIDChangeWarning\">\n\t\t\t\t\t<div class=\"option-button trigger-button\">\n\t\t\t\t\t\t__MSG_changeUserID__\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_whatChangeUserID__</div>\n\t\n\t\t\t\t\t<div class=\"option-hidden-section hidden spacing indent\">\n\t\t\t\t\t\t<input class=\"option-text-box\" type=\"text\">\n\t\n\t\t\t\t\t\t<div class=\"option-button text-change-set inline low-profile\">\n\t\t\t\t\t\t\t__MSG_setUserID__\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"react-UnsubmittedVideosComponent\"></div>\n\t\n\t\t\t\t<div data-type=\"private-text-change\" data-sync=\"*\" data-confirm-message=\"exportOptionsWarning\">\n\t\t\t\t\t<h2>__MSG_exportOptions__</h2>\n\t\t\t\t\t\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div class=\"option-button trigger-button inline\">\n\t\t\t\t\t\t\t__MSG_exportOptionsCopy__\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"option-button download-button inline\">\n\t\t\t\t\t\t\t__MSG_exportOptionsDownload__\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<label for=\"importOptions\" class=\"option-button inline\">\n\t\t\t\t\t\t\t__MSG_exportOptionsUpload__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input id=\"importOptions\" type=\"file\" class=\"upload-button hidden\" />\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_whatExportOptions__</div>\n\t\n\t\t\t\t\t<div class=\"option-hidden-section hidden spacing indent\">\t\n\t\t\t\t\t\t<textarea class=\"option-text-box\" rows=\"10\" style=\"width:80%\"></textarea>\n\t\n\t\t\t\t\t\t<div class=\"option-button text-change-set\">\n\t\t\t\t\t\t\t__MSG_setOptions__\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"private-text-change\" data-sync-type=\"local\" data-sync=\"*\" data-confirm-message=\"exportOptionsWarning\">\n\t\t\t\t\t<h2>__MSG_exportOtherData__</h2>\n\t\t\t\t\t\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<div class=\"option-button trigger-button inline\">\n\t\t\t\t\t\t\t__MSG_exportOptionsCopy__\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"option-button download-button inline\">\n\t\t\t\t\t\t\t__MSG_exportOptionsDownload__\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<label for=\"importLocalOptions\" class=\"option-button inline\">\n\t\t\t\t\t\t\t__MSG_exportOptionsUpload__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<input id=\"importLocalOptions\" type=\"file\" class=\"upload-button hidden\" />\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"option-hidden-section hidden spacing indent\">\t\n\t\t\t\t\t\t<textarea class=\"option-text-box\" rows=\"10\" style=\"width:80%\"></textarea>\n\t\n\t\t\t\t\t\t<div class=\"option-button text-change-set\">\n\t\t\t\t\t\t\t__MSG_setOptions__\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"button-press\" data-sync=\"resetToDefault\" data-confirm-message=\"confirmResetToDefault\">\n\t\t\t\t\t<div class=\"option-button trigger-button\">\n\t\t\t\t\t\t__MSG_resetToDefault__\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t</div>\n\n\t\t\t<div id=\"advanced\" class=\"option-group hidden\">\n\n\t\t\t\t<div id=\"support-invidious\" data-type=\"toggle\" data-sync=\"supportInvidious\" data-no-safari=\"true\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"supportInvidious\" type=\"checkbox\">\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"supportInvidious\">\n\t\t\t\t\t\t\t__MSG_supportOtherSites__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">(__MSG_supportedSites__ Invidious, CloudTube, Piped, YouTube Kids)</div>\n\t\t\t\t\t<div class=\"small-description\">__MSG_supportOtherSitesDescription__ </div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"private-text-change\" data-sync=\"invidiousInstances\" data-dependent-on=\"supportInvidious\" data-no-safari=\"true\">\n\t\t\t\t\t<div class=\"option-button trigger-button\">\n\t\t\t\t\t\t__MSG_addInvidiousInstance__\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_addInvidiousInstanceDescription__</div>\n\t\n\t\t\t\t\t<div class=\"indent option-hidden-section hidden spacing\">\n\t\t\t\t\t\t<input class=\"option-text-box\" type=\"text\">\n\t\t\t\t\t\t<div class=\"inline\">\n\t\t\t\t\t\t\t<div class=\"option-button text-change-set inline low-profile\">\n\t\t\t\t\t\t\t\t__MSG_add__\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div class=\"option-button text-change-reset inline low-profile\">\n\t\t\t\t\t\t\t\t__MSG_cancel__\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div style=\"margin-top:15px\">\n\t\t\t\t\t\t<span>__MSG_currentInstances__</span>\n\t\t\t\t\t\t<span data-type=\"display\" data-sync=\"invidiousInstances\"></span>\n\t\t\t\t\t\t<div class=\"option-button invidious-instance-reset spacing hidden\">\n\t\t\t\t\t\t\t__MSG_resetInvidiousInstance__\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"trackViewCount\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"trackViewCount\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"trackViewCount\">\n\t\t\t\t\t\t\t__MSG_enableViewTracking__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_whatViewTracking__</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"trackViewCountInPrivate\" data-dependent-on=\"trackViewCount\" data-private-only=\"true\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"trackViewCountInPrivate\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"trackViewCountInPrivate\">\n\t\t\t\t\t\t\t__MSG_enableViewTrackingInPrivate__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"trackDownvotes\" data-confirm-on=\"false\" data-confirm-message=\"trackDownvotesWarning\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"trackDownvotes\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"trackDownvotes\">\n\t\t\t\t\t\t\t__MSG_enableTrackDownvotes__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_whatTrackDownvotes__</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"trackDownvotesInPrivate\" data-confirm-on=\"false\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"trackDownvotesInPrivate\" type=\"checkbox\" checked>\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"trackDownvotesInPrivate\">\n\t\t\t\t\t\t\t__MSG_enableTrackDownvotesInPrivate__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"button-press\" data-sync=\"copyDebugInformation\" data-confirm-message=\"copyDebugInformation\">\n\t\t\t\t\t<div class=\"option-button trigger-button\">\n\t\t\t\t\t\t__MSG_copyDebugInformation__\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_copyDebugInformationOptions__</div>\n\t\t\t\t</div>\n\n\t\t\t\t<div data-type=\"toggle\" data-sync=\"testingServer\" data-confirm-message=\"testingServerWarning\" data-no-safari=\"true\">\n\t\t\t\t\t<div class=\"switch-container\">\n\t\t\t\t\t\t<label class=\"switch\">\n\t\t\t\t\t\t\t<input id=\"testingServer\" type=\"checkbox\">\n\t\t\t\t\t\t\t<span class=\"slider round\"></span>\n\t\t\t\t\t\t</label>\n\t\t\t\t\t\t<label class=\"switch-label\" for=\"testingServer\">\n\t\t\t\t\t\t\t__MSG_enableTestingServer__\n\t\t\t\t\t\t</label>\n\t\t\t\t\t</div>\n\t\n\t\t\t\t\t<div class=\"small-description\">__MSG_whatEnableTestingServer__</div>\n\t\t\t\t</div>\n\t\n\t\t\t\t<div data-type=\"text-change\" data-sync=\"serverAddress\" data-dependent-on=\"testingServer\" data-dependent-on-inverted=\"true\">\n\t\t\t\t\t<label class=\"optionLabel inline\">\n\t\t\t\t\t\t<span class=\"optionLabel\">__MSG_customServerAddress__:</span>\n\t\n\t\t\t\t\t\t<input class=\"option-text-box\" type=\"text\" style=\"margin-right:10px\">\n\t\t\t\t\t</label>\n\n\t\t\t\t\t<div class=\"small-description\">__MSG_customServerAddressDescription__</div>\n\t\n\t\t\t\t\t<div class=\"next-line\">\n\t\t\t\t\t\t<div class=\"option-button text-change-set inline\">\n\t\t\t\t\t\t\t__MSG_save__\n\t\t\t\t\t\t</div>\n\t\t\n\t\t\t\t\t\t<div class=\"option-button text-change-reset inline\">\n\t\t\t\t\t\t\t__MSG_reset__\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t</div>\n\n\t\t</div>\n\n\t</div>\n\n</body>\n"
  },
  {
    "path": "public/oss-attribution/attribution.txt",
    "content": "content-scripts-register-polyfill\n4.0.2 <https://github.com/fregante/content-scripts-register-polyfill>\nMIT License\n\nCopyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n\n\n******************************\n\nescape-string-regexp\n5.0.0 <https://github.com/sindresorhus/escape-string-regexp>\nMIT License\n\nCopyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n\n\n******************************\n\njs-tokens\n4.0.0 <https://github.com/lydell/js-tokens>\nThe MIT License (MIT)\n\nCopyright (c) 2014, 2015, 2016, 2017, 2018 Simon Lydell\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n\n******************************\n\nloose-envify\n1.4.0 <https://github.com/zertosh/loose-envify>\nThe MIT License (MIT)\n\nCopyright (c) 2015 Andres Suarez <zertosh@gmail.com>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n\n******************************\n\nreact\n18.2.0 <https://github.com/facebook/react>\nMIT License\n\nCopyright (c) Facebook, Inc. and its affiliates.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n\n******************************\n\nreact-dom\n18.2.0 <https://github.com/facebook/react>\nMIT License\n\nCopyright (c) Facebook, Inc. and its affiliates.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n\n******************************\n\nscheduler\n0.23.0 <https://github.com/facebook/react>\nMIT License\n\nCopyright (c) Facebook, Inc. and its affiliates.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n\n******************************\n\nwebext-content-scripts\n2.5.5 <https://github.com/fregante/webext-content-scripts>\nMIT License\n\nCopyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n\n\n******************************\n\nwebext-patterns\n1.3.0 <https://github.com/fregante/webext-patterns>\nMIT License\n\nCopyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n\n\n******************************\n\nwebext-polyfill-kinda\n1.0.2 <https://github.com/fregante/webext-polyfill-kinda>\nMIT License\n\nCopyright (c) Federico Brigante <me@fregante.com> (https://fregante.com)\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n"
  },
  {
    "path": "public/permissions/index.html",
    "content": "<!DOCTYPE html>\n\n<head>\n  <title>Permissions - SponsorBlock</title>\n  <meta charset=\"utf-8\">\n\n  <link href=\"styles.css\" rel=\"stylesheet\"/>\n\n  <script src=\"../js/permissions.js\"></script>\n</head>\n\n<body class=\"sponsorBlockPageBody\">\n\n\t<div id=\"title\" class=\"titleBar\">\n\t\t<img src=\"../icons/LogoSponsorBlocker256px.png\" height=\"80\" class=\"profilepic\"/>\n\t\tSponsorBlock\n\t</div>\n\n\t<br/>\n\n\t<div class=\"center\">\n\t\t__MSG_invidiousPermissionRefresh__\n\t</div>\n\n\t<br/>\n\n\t<div class=\"center\">\n\t\t<div id=\"acceptPermissionButton\" class=\"option-button inline\">\n\t\t\t__MSG_acceptPermission__\n\t\t</div>\n\t</div>\n\n</body>\n"
  },
  {
    "path": "public/permissions/styles.css",
    "content": "/* Options page CSS */\nhtml {\n    color-scheme: dark;\n}\n\nbody {\n    font-family: sans-serif;\n}\n\n.center {\n    text-align: center;\n}\n\n.inline {\n    display: inline-block;\n}\n\n.bold {\n    font-weight: bold;\n}\n\n.hidden, .sbhidden {\n    display: none !important;\n}\n\n.keybind-status {\n    display: inline;\n}\n\n.small-description {\n    color: white;\n    font-size: 13px;\n}\n\n.medium-description {\n    color: white;\n    font-size: 15px;\n}\n\n.option-text-box {\n    width: 300px;\n}\n\n.option-button {\n    cursor: pointer;\n\n    background-color: #c00000;\n    padding: 10px;\n    color: white;\n    border-radius: 5px;\n    font-size: 14px;\n\n    width: max-content;\n}\n\n.option-button:hover {\n    background-color: #fc0303;\n}\n\n.option-button.disabled {\n    cursor: default;\n\n    background-color: #520000;\n    color: grey;\n}\n\n#options {\n    max-width: 60%;\n    text-align: left;\n    display: inline-block;\n}\n\n.switch-container:after {\n\tcontent: attr(label-name);\n    position: absolute;\n    padding: 4px;\n    width: max-content;\n\n    font-size: 14px;\n    color: white;\n}\n\n.text-label-container {\n    font-size: 14px;\n    color: white;\n}\n\n.switch {\n    position: relative;\n    display: inline-block;\n    width: 40px;\n    height: 24px;\n}\n\n.switch input { \n    opacity: 0;\n    width: 0;\n    height: 0;\n}\n\n.slider {\n    position: absolute;\n    cursor: pointer;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background-color: #707070;\n}\n\n.animated * {\n    -webkit-transition: .4s;\n    transition: .4s;\n}\n\n.slider:before {\n    position: absolute;\n    content: \"\";\n    height: 16px;\n    width: 16px;\n    left: 4px;\n    bottom: 4px;\n    background-color: white;\n}\n\n.animated .slider:before {\n    -webkit-transition: .4s;\n    transition: .4s;\n}\n\ninput:checked + .slider {\n    background-color: #fc0303;\n}\n\ninput:checked + .slider:before {\n    -webkit-transform: translateX(16px);\n    -ms-transform: translateX(16px);\n    transform: translateX(16px);\n}\n\n/* Rounded sliders */\n.slider.round {\n    border-radius: 34px;\n}\n\n.slider.round:before {\n    border-radius: 50%;\n}\n\n\n/* Boilerplate CSS from https://ajay.app */\n\nbody {\n    background-color: #333333;\n}\n\n.projectPreview {\n    position: relative;\n}\n\n.projectPreviewImage {\n    position: absolute;\n    left: -90px;\n    width: 80px;\n    top: 50%;\n    transform: translateY(-50%);\n}\n\n.projectPreviewImageLarge {\n    position: absolute;\n    left: -210px;\n    width: 200px;\n    top: 50%;\n    transform: translateY(-20%);\n}\n\n.projectPreviewImageLargeRight {\n    position: absolute;\n    right: -210px;\n    width: 200px;\n    top: 50%;\n    transform: translateY(-50%);\n}\n\n.createdBy {\n    font-size: 14px;\n    text-align: center;\n    padding-top: 0px;\n    padding-bottom: 0px;\n\n    display: inline-block;\n}\n\n#title {\n    background-color: #636363;\n  \n    text-align: center;\n    vertical-align: middle;\n  \n    font-size: 50px;\n    color: #212121;\n  \n    padding: 20px;\n  \n    text-decoration: none;\n  \n    transition: font-size 1s;\n}\n\n.subtitle {\n    font-size: 40px;\n    color: #dad8d8;\n  \n    padding-top: 10px;\n  \n    transition: font-size 0.4s;\n}\n\n.subtitle:hover {\n    font-size: 45px;\n  \n    transition: font-size 0.4s;\n}\n\n.profilepic {\n    background-color: #636363 !important;\n    vertical-align: middle;\n}\n\n.profilepiccircle {\n    vertical-align: middle;\n    overflow: hidden;\n    border-radius: 50%;\n}\n\na {\n    text-decoration: underline;\n    color: inherit;\n}\n\n.link {\n    padding: 20px;\n  \n    height: 80px;\n  \n    transition: height 0.2s;\n}\n\n.link:hover {\n    height: 95px;\n  \n    transition: height 0.2s;\n}\n\n#contact,.smalllink {\n    font-size: 25px;\n    color: #e8e8e8;\n  \n    text-align: center;\n  \n    padding: 10px;\n}\n\n#contact {\n    text-decoration: none;\n}\n\np,li {\n    font-size: 20px;\n    color: #c4c4c4;\n  \n    padding: 10px;\n}\n\np,li,code,a {\n    max-width: 60%;\n    text-align: left;\n    overflow-wrap: break-word;\n}\n\n@media screen and (orientation:portrait) {\n    p,li,code,a {\n        max-width: 100%;\n    }\n  \n    .projectPreviewImage {\n        position: unset;\n        width: 130px;\n        display: block;\n        margin: auto;\n        transform: none;\n    }\n}\n\n.previewImage {\n    max-height: 200px;\n}\n\nimg {\n    max-width: 100%;\n  \n    text-align: center;\n}\n\n#recentPostTitle {\n    font-size: 30px;\n    color: #dad8d8;\n}\n\n#recentPostDate {\n    font-size: 15px;\n    color: #dad8d8;\n}\n\nh1,h2,h3,h4,h5,h6 {\n    color: #dad8d8;\n}\n\nsvg {\n    text-decoration: none; \n}\n\n.number-container:before {\n    content: attr(label-name);\n    padding-right: 4px;\n    width: max-content;\n\n    font-size: 14px;\n    color: white;\n}\n\n/* React styles */\n\n.categoryTableElement {\n    font-size: 16px;\n\n    color: white;\n}\n\n.categoryTableElement > * {\n    padding-right: 15px;\n    padding-bottom: 15px;\n}\n\n.categoryOptionsSelector {\n    background-color: #c00000;\n    color: white;\n    \n    border: none;\n    font-size: 14px;\n    padding: 5px;\n    border-radius: 5px;\n}\n\n.categoryColorTextBox {\n    width: 60px;\n\n    background: none;\n    border: none;\n}"
  },
  {
    "path": "public/popup.css",
    "content": ":root {\n  --sb-main-font-family: \"Source Sans Pro\", sans-serif;\n  --sb-main-bg-color: #222;\n  --sb-main-fg-color: #fff;\n  --sb-grey-bg-color: #333;\n  --sb-grey-bg-active-color: #444;\n  --sb-grey-fg-color: #999;\n  --sb-red-bg-color: #cc1717;\n  --sb-red-bg-active-color: #ec1c1c;\n  --sb-skip-profile-bg: #292828;\n  --sb-skip-profile-disabled: #808080;\n  --sb-skip-profile-option-bg: #222;\n  --sb-skip-profile-highlighted: rgb(127, 0, 0);\n}\n\n.prideTheme {\n  --sb-main-fg-color: #fff;\n  --sb-grey-bg-color: #008026;\n  --sb-grey-bg-active-color: #FF8C00;\n  --sb-grey-fg-color: #FFED00;\n  --sb-red-bg-color: #732982;\n  --sb-red-bg-active-color: #004CFF;\n  --sb-skip-profile-bg: #FFAFC8;\n  --sb-skip-profile-disabled: #74D7EE;\n  --sb-skip-profile-option-bg: #f687aa;\n  --sb-skip-profile-highlighted: #38153f;\n\n  background: url(\"icons/pride.svg\");\n}\n\n/*\n * Generic utilities\n */\n.grey-text {\n  color: var(--sb-grey-fg-color);\n}\n.white-text {\n  color: var(--sb-main-fg-color);\n}\n.sbHeader {\n  font-size: 20px;\n  font-weight: bold;\n  text-align: left;\n  margin: 0;\n}\n#sponsorBlockPopupBody .u-mZ {\n  margin: 0 !important;\n  position: relative;\n}\n\n#sponsorBlockPopupBody .hidden, #sponsorBlockPopupBody .sbhidden {\n  display: none !important;\n}\n\n/*\n * <button> elements that have icons\n */\n #setUsernameButton,\n #copyUserID,\n #submitUsername {\n   color: var(--sb-main-fg-color);\n   background: transparent;\n   width: fit-content;\n   padding: 0;\n   border: none;\n }\n\n/*\n * Main containers\n */\n#sponsorBlockPopupHTML {\n  color-scheme: dark;\n  max-height: 600px;\n  overflow-y: auto;\n}\n\n#sponsorBlockPopupBody {\n  margin: 0;\n  width: 374px;\n  max-width: 100%; /* NOTE: Ensures content doesn't exceed restricted popup widths in Firefox */\n  font-size: 14px;\n  font-family: var(--sb-main-font-family);\n  background-color: var(--sb-main-bg-color);\n  color: var(--sb-main-fg-color);\n  color-scheme: dark;\n}\n\n#sponsorblockPopup {\n  text-align: center;\n}\n\n#sponsorblockPopup a,\n#sponsorblockPopup button {\n  cursor: pointer;\n}\n\n/*\n * Disable transition on all elements until the extension has loaded\n */\n.sb-preload * {\n  transition: none !important;\n}\n\n/*\n * Alert indicating that Beta server is enabled\n */\n#sbBetaServerWarning {\n  padding: 8px;\n  font-size: 1em;\n  font-weight: 700;\n  color: var(--sb-main-fg-color);\n  background-color: var(--sb-red-bg-color);\n  cursor: pointer;\n}\n\n/*\n * Container when popup displayed in-page (content.ts)\n */\n#sponsorBlockPopupContainer {\n  position: relative;\n  margin-bottom: 16px;\n}\n\n#sponsorBlockPopupContainer iframe {\n  width: 100%;\n}\n\n/*\n * Disable popup max height when displayed in-page (content.ts)\n */\n#sponsorBlockPopupContainer #sponsorBlockPopupHTML {\n  max-height: none;\n}\n\n/*\n * Disable fixed popup width when displayed in-page (content.ts)\n */\n#sponsorBlockPopupBody.is-embedded {\n  width: auto;\n}\n\n/*\n * Close popup button when displayed in-page (top-right corner)\n */\n.sbCloseButton {\n  background: transparent;\n  border: 0;\n  padding: 8px;\n  cursor: pointer;\n  position: absolute;\n  top: 5px;\n  right: 5px;\n  opacity: 0.5;\n  z-index: 1;\n}\n\n.sbCloseButton:hover {\n  opacity: 1;\n}\n\n/*\n * Header logo\n */\n.sbPopupLogo {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-weight: bold;\n  user-select: none;\n  padding: 10px 0px 0px;\n  font-size: 32px;\n}\n.sbPopupLogo img {\n  margin: 8px;\n}\n\n#refreshSegmentsButton {\n  display: flex;\n  align-items: center;\n  padding: 5px;\n  margin: 5px auto;\n}\n\n#issueReporterImportExport {\n  position: relative;\n}\n\n#refreshSegmentsButton, #issueReporterImportExport button {\n  background: transparent;\n  border-radius: 50%;\n  border: none;\n}\n\n#refreshSegmentsButton:hover, #issueReporterImportExport button:hover {\n  background-color: var(--sb-grey-bg-color);\n}\n\n#issueReporterImportExport button {\n  padding: 5px;\n  margin-right: 15px;\n  margin-left: 15px;\n}\n\n#issueReporterImportExport img {\n  width: 24px;\n  display: block;\n}\n\n#importSegmentsText {\n  margin-top: 7px;\n}\n\n#importSegmentsMenu button {\n  padding: 10px;\n}\n\n/*\n * <details> wrapper around each segment\n */\n.votingButtons {\n  border-radius: 8px;\n}\n.votingButtons[open] {\n  padding-bottom: 5px;\n}\n.votingButtons:hover {\n  background-color: var(--sb-grey-bg-color);\n}\n\n/*\n * Nested chapters\n */ \n.innerChapterList {\n  border-radius: 8px;\n}\n\n.innerChapterList > summary {\n  font-weight: bold;\n  padding: 4px;\n  cursor: pointer;\n}\n\n.segmentWrapper:has(> .innerChapterList > summary:hover) {\n  background-color: var(--sb-grey-bg-color);\n}\n\n.segmentWrapper{\n  font-family: Arial, Helvetica, sans-serif;\n  border-radius: 8px;\n  margin: 4px 16px;\n}\n\n/*\n * Individual segments summaries (clickable <summary>)\n */\n.segmentSummary {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  white-space: nowrap;\n  font-weight: bold;\n  list-style: none;\n  cursor: pointer;\n  padding: 4px 8px;\n}\n.segmentSummary > div {\n  text-align: left;\n}\n\n.segmentSummary::-webkit-details-marker {\n  display: none !important;\n}\n\n.segmentActive {\n  color: #bdfffb;\n}\n\n.segmentPassed {\n  color: #adadad;\n}\n\n/*\n * Category dot in segment\n */\n.sponsorTimesCategoryColorCircle {\n  margin-right: 8px;\n}\n.dot {\n  width: 10px;\n  height: 10px;\n  border-radius: 50%;\n  display: inline-block;\n}\n/*\n * Category name in segment\n */\n.summaryLabel {\n  overflow-wrap: break-word;\n  white-space: normal;\n}\n\n.sbVoteButtonsContainer {\n  text-align: right;\n}\n\n/*\n * \"Voted!\" text that appears after voting on a segment\n */\n.sponsorTimesThanksForVotingText {\n  font-size: large;\n}\n\n/*\n * Main controls menu\n */\n.sbControlsMenu {\n  margin: 16px;\n  margin-top: 6px;\n  border-radius: 8px;\n  background-color: var(--sb-grey-bg-color);\n  justify-content: space-evenly;\n  display: flex;\n  position: relative;\n\n  z-index: 1;\n}\n.sbControlsMenu-item {\n  display: flex;\n  align-items: center;\n  flex-direction: column;\n  justify-content: center;\n  background: transparent;\n  user-select: none;\n  cursor: pointer;\n  border: none;\n  flex: 1;\n  padding: 10px 15px;\n  transition: background-color 0.2s ease-in-out;\n}\n.sbControlsMenu-item:first-child {\n  border-radius: 8px 0px 0px 8px;\n}\n.sbControlsMenu-item:last-child {\n  border-radius: 0px 8px 8px 0px;\n}\n.sbControlsMenu-item:hover, .sbControlsMenu-item:focus {\n  background-color: var(--sb-grey-bg-active-color) !important;\n}\n.sbControlsMenu-itemIcon {\n  margin-bottom: 6px;\n}\n\n.prideTheme .sbControlsMenu-item:nth-child(1) {\n  background-color: #750787;\n}\n.prideTheme .sbControlsMenu-item:nth-child(3) {\n  background-color: #0035b1;\n}\n.prideTheme .sbControlsMenu-item:nth-child(4) {\n  background-color: #008026;\n}\n\n/*\n * Whitelist add/remove icon\n */\n.SBWhitelistIcon > path {\n  fill: var(--sb-main-fg-color);\n}\n.SBWhitelistIcon.rotated {\n  transform: rotate(45deg);\n}\n@keyframes rotate {\n  from {\n    transform: rotate(0deg);\n  }\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n/*\n * \"Skipping is enabled\" toggle\n */\n.toggleSwitchContainer {\n  display: flex;\n  align-items: center;\n  flex-direction: column;\n}\n.toggleSwitchContainer-switch {\n  display: flex;\n  margin-bottom: 6px;\n}\n.switchBg {\n  width: 50px;\n  height: 23px;\n  display: block;\n  border-radius: 18.5px;\n}\n.switchBg.shadow {\n  box-shadow: 0.75px 0.75px 10px 0px rgba(50, 50, 50, 0.5);\n  opacity: 1;\n}\n.switchBg.white {\n  opacity: 1;\n  position: absolute;\n  background-color: #ccc;\n}\n.switchBg.green {\n  opacity: 0;\n  position: absolute;\n  background-color: #00a205;\n  transition: opacity 0.2s ease-out;\n}\n.switchDot {\n  width: 15px;\n  margin: 4px;\n  height: 15px;\n  border-radius: 50%;\n  position: absolute;\n  transition: transform 0.2s ease-out;\n  background-color: var(--sb-main-fg-color);\n  box-shadow: 0.75px 0.75px 3.8px 0px rgba(50, 50, 50, 0.45);\n}\n#toggleSwitch:checked ~ .switchDot {\n  transform: translateX(27px);\n}\n#toggleSwitch:checked ~ .switchBg.green {\n  opacity: 1;\n}\n#toggleSwitch:checked ~ .switchBg.white {\n  transition: opacity 0.2s step-end;\n  opacity: 0;\n}\n\n/*\n * Notice that appears when whitelisting a channel, that recommends\n * enabling the \"Force Channel Check Before Skipping\" option\n */\n#whitelistForceCheck {\n  background-color: #fff3cd;\n  padding: 10px 15px;\n  display: block;\n  color: #664d03;\n}\n#whitelistForceCheck:hover {\n  background-color: #f2e4b7;\n}\n\n/*\n * Submit box\n */\n#mainControls {\n  margin: 16px;\n  padding: 8px 14px;\n  text-align: left;\n  border-radius: 8px;\n  border: 2px solid var(--sb-grey-bg-color);\n}\n.sponsorStartHint {\n  display: block;\n  text-align: left;\n  padding-top: 3px;\n}\n\n/*\n * Generic red buttons used for \"Start Segment Now\", \"Submit Times\" etc.\n */\n.sbMediumButton {\n  border: none;\n  font-size: 16px;\n  padding: 8px 16px;\n  border-radius: 28px;\n  display: inline-block;\n  -moz-border-radius: 28px;\n  -webkit-border-radius: 28px;\n  color: var(--sb-main-fg-color);\n  transition: 0.01s background-color;\n  font-family: var(--sb-main-font-family);\n  background-color: var(--sb-red-bg-color);\n}\n.sbMediumButton:hover,\n.sbMediumButton:focus {\n  background-color: var(--sb-red-bg-active-color);\n  outline: none;\n}\n.sbMediumButton:active {\n  position: relative;\n  top: 1px;\n}\n/*\n * \"Submit Times\" button\n */\n#submitTimes {\n  margin-top: 12px;\n}\n\n/*\n * Your Work box\n */\n.sbYourWorkBox {\n  margin: 16px;\n  margin-bottom: 8px;\n  border-radius: 8px;\n  border: 2px solid var(--sb-grey-bg-color);\n}\n.sbYourWorkCols {\n  display: flex;\n  border-top: 2px solid var(--sb-grey-bg-color);\n  border-bottom: 2px solid var(--sb-grey-bg-color);\n}\n\n.sbStatsSentence {\n  padding: 6px 14px;\n}\n\n.sbExtraInfo {\n  display: inline-block;\n}\n\n/*\n * Increase font size of username input and display\n */\n#usernameValue,\n#usernameInput {\n  font-size: 16px;\n  flex: 1 0;\n}\n#sponsorTimesContributionsDisplay {\n  font-size: 16px;\n}\n /*\n * Improve alignment of username and submissions\n */\n#usernameElement > p,\n#sponsorTimesContributionsContainer {\n  text-align: left;\n}\n\n/*\n * Username\n */\n#usernameElement {\n  padding: 8px 14px;\n  min-width: 50%;\n  width: 100%;\n}\n#setUsernameContainer {\n  display: flex;\n  align-items: center;\n  width: fit-content;\n}\n#setUsernameContainer > button {\n  display: flex;\n}\n#setUsernameButton {\n  margin-right: 5px;\n  flex: 0 1;\n}\n#submitUsername {\n  padding-left: 16px;\n}\n#copyUserID {\n  width: 100%;\n  flex: 0 1;\n}\n/*\n * Truncate username display\n */\n#usernameValue {\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  overflow: hidden;\n  margin: 0 8px 0 0;\n  max-width: 165px;\n}\n/*\n * Set username form container with \"expanded\" state\n */\n#setUsername.SBExpanded {\n  text-align: left;\n}\n/*\n * Set username input\n */\n#usernameInput {\n  border: none;\n  padding: 4px 8px;\n  border-radius: 4px;\n  width: calc(100% - 68px);\n  text-overflow: ellipsis;\n  color: var(--sb-main-fg-color);\n  background-color: var(--sb-grey-bg-color);\n}\n\n/*\n * Submissions\n*/\n#sponsorTimesContributionsContainer {\n  padding: 8px 14px;\n  border-left: 2px solid var(--sb-grey-bg-color);\n}\n\n/*\n * Footer\n */\n#sbFooter {\n  padding: 8px 0;\n}\n#sbFooter a {\n  transition: background 0.3s ease !important;\n  color: var(--sb-main-fg-color);\n  display: inline-block;\n  text-decoration: none;\n  border-radius: 4px;\n  background-color: #333;\n  padding: 4px 8px;\n  font-weight: 500;\n  margin: 2px 1px;\n}\n#sbFooter a:hover {\n  background-color: var(--sb-grey-bg-active-color) !important;\n}\n\n.prideTheme #sbFooter a:nth-of-type(1) {\n  background-color: #E40303;\n}\n.prideTheme #sbFooter a:nth-of-type(2) {\n  background-color: #FF8C00;\n}\n.prideTheme #sbFooter a:nth-of-type(3) {\n  background-color: #b9ad00;\n}\n.prideTheme #sbFooter a:nth-of-type(4) {\n  background-color: #008026;\n}\n.prideTheme #sbFooter a:nth-of-type(5) {\n  background-color: #004DFF;\n}\n.prideTheme #sbFooter a:nth-of-type(6) {\n  background-color: #750787;\n}\n.prideTheme #sbFooter a:nth-of-type(7) {\n  background-color: #c7899d;\n}\n.prideTheme #sbFooter a:nth-of-type(8) {\n  background-color: #65b8cb;\n}\n.prideTheme #sbFooter a:nth-of-type(9) {\n  background-color: #613915;\n}\n\n#sponsorTimesDonateContainer a {\n  color: var(--sb-main-fg-color);\n  text-decoration: none;\n\n  padding-left: 5px;\n}\n\n/*\n * \"Show Notice Again\" button\n */\n#showNoticeAgain {\n  background: transparent;\n  border: 1px solid #fff;\n  border-radius: 5px;\n  color: var(--sb-main-fg-color);\n  margin-bottom: 20px;\n  padding: 5px;\n}\n\n#sponsorBlockPopupBody .u-mZ {\n  margin: 0 !important;\n}\n\n#sponsorBlockPopupBody .u-mZ.cleanPopupMargin {\n  margin-top: 10px !important;\n}\n\n#sponsorBlockPopupBody .hidden {\n  display: none !important;\n}\n\n#issueReporterTabs {\n  margin: 5px;\n}\n\n#issueReporterTabs > span {\n  padding: 2px 4px;\n  margin: 0 3px;\n  cursor: pointer;\n  background-color: #444848;\n  border-radius: 10px;\n}\n\n#issueReporterTabs > span > span {\n  position: relative;\n  padding: 0.2em 0;\n}\n\n#issueReporterTabs > span > span::after {\n  content: '';\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  width: 100%;\n  height: 0.1em;\n  background-color: rgb(145, 0, 0);\n  transition: transform 300ms;\n  transform: scaleX(0);\n  transform-origin: center;\n}\n\n#issueReporterTabs > span.sbSelected > span::after {\n  transform: scaleX(0.8);\n}\n\n.sbPopupButton {\n  width: 16px;\n  fill: var(--sb-main-fg-color);\n}\n\n#skipProfileMenu {\n  position: absolute;\n  top: 80px;\n  left: 50%;\n\n  background-color: var(--sb-skip-profile-bg);\n  border-radius: 10px;\n  padding: 10px;\n\n  transform: translateX(-50%);\n}\n\n#skipProfileActions {\n  padding-top: 10px;\n}\n\n.skipOptionAction {\n  transition: border-color 0.2s ease-in-out, background-color 0.2s ease-in-out;\n  font-size: 14px;\n  padding: 5px;\n  margin: 5px;\n\n  background-color: var(--sb-skip-profile-option-bg);\n  border-radius: 5px;\n\n  cursor: help;\n  user-select: none;\n\n  border-color: transparent;\n  border-width: 2px;\n  border-style: solid;\n}\n.skipOptionAction:not(.highlighted, .disabled):hover {\n  background-color: var(--sb-grey-bg-color);\n}\n.skipOptionAction.selected {\n  border-color: var(--sb-red-bg-color);\n}\n.skipOptionAction.highlighted {\n  border-color: var(--sb-skip-profile-highlighted);\n}\n.skipOptionAction:not(.highlighted, .disabled) {\n  cursor: pointer;\n}\n.skipOptionAction.disabled {\n  color: var(--sb-skip-profile-disabled);\n}\n\n.optionsSelector {\n    background-color: #c00000;\n    color: white;\n    \n    border: none;\n    font-size: 14px;\n    padding: 5px;\n    border-radius: 5px;\n}"
  },
  {
    "path": "public/popup.html",
    "content": "<html id=\"sponsorBlockPopupHTML\">\n    <head>\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /> \n\n        <link href=\"popup.css\" rel=\"stylesheet\">\n        <link href=\"shared.css\" rel=\"stylesheet\">\n        <script src=\"./js/popup.js\"></script>\n    </head>\n\n    <body id=\"sponsorBlockPopupBody\">\n\n    </body>\n</html>"
  },
  {
    "path": "public/res/countries.json",
    "content": "{\"Albania\":{\"allowed\":true},\"Algeria\":{\"allowed\":true},\"Angola\":{\"allowed\":true},\"Argentina\":{\"allowed\":true},\"Armenia\":{\"allowed\":true},\"Australia\":{\"allowed\":false},\"Austria\":{\"allowed\":false},\"Azerbaijan\":{\"allowed\":true},\"Bangladesh\":{\"allowed\":true},\"Belarus\":{\"allowed\":true},\"Belgium\":{\"allowed\":false},\"Belize\":{\"allowed\":true},\"Benin\":{\"allowed\":true},\"Bhutan\":{\"allowed\":true},\"Bolivia\":{\"allowed\":true},\"Bosnia and Herzegovina\":{\"allowed\":true},\"Botswana\":{\"allowed\":true},\"Brazil\":{\"allowed\":true},\"Bulgaria\":{\"allowed\":true},\"Burkina Faso\":{\"allowed\":true},\"Burundi\":{\"allowed\":true},\"Cameroon\":{\"allowed\":true},\"Canada\":{\"allowed\":false},\"Central African Republic\":{\"allowed\":true},\"Chad\":{\"allowed\":true},\"Chile\":{\"allowed\":true},\"China\":{\"allowed\":true},\"Colombia\":{\"allowed\":true},\"Comoros\":{\"allowed\":true},\"Costa Rica\":{\"allowed\":true},\"Croatia\":{\"allowed\":true},\"Cyprus\":{\"allowed\":false},\"Czech Republic\":{\"allowed\":false},\"Denmark\":{\"allowed\":false},\"Djibouti\":{\"allowed\":true},\"Dominican Republic\":{\"allowed\":true},\"DR Congo\":{\"allowed\":true},\"Ecuador\":{\"allowed\":true},\"Egypt\":{\"allowed\":true},\"El Salvador\":{\"allowed\":true},\"Estonia\":{\"allowed\":false},\"Eswatini\":{\"allowed\":true},\"Ethiopia\":{\"allowed\":true},\"Fiji\":{\"allowed\":true},\"Finland\":{\"allowed\":false},\"France\":{\"allowed\":false},\"Gabon\":{\"allowed\":true},\"Gambia\":{\"allowed\":true},\"Georgia\":{\"allowed\":true},\"Germany\":{\"allowed\":false},\"Ghana\":{\"allowed\":true},\"Greece\":{\"allowed\":true},\"Guatemala\":{\"allowed\":true},\"Guinea\":{\"allowed\":true},\"Guinea-Bissau\":{\"allowed\":true},\"Guyana\":{\"allowed\":true},\"Haiti\":{\"allowed\":true},\"Honduras\":{\"allowed\":true},\"Hungary\":{\"allowed\":true},\"Iceland\":{\"allowed\":false},\"India\":{\"allowed\":true},\"Iran\":{\"allowed\":true},\"Iraq\":{\"allowed\":true},\"Ireland\":{\"allowed\":false},\"Israel\":{\"allowed\":false},\"Italy\":{\"allowed\":false},\"Ivory Coast\":{\"allowed\":true},\"Jamaica\":{\"allowed\":true},\"Japan\":{\"allowed\":false},\"Jordan\":{\"allowed\":true},\"Kazakhstan\":{\"allowed\":true},\"Kenya\":{\"allowed\":true},\"Kiribati\":{\"allowed\":true},\"Kyrgyzstan\":{\"allowed\":true},\"Laos\":{\"allowed\":true},\"Latvia\":{\"allowed\":true},\"Lebanon\":{\"allowed\":true},\"Lesotho\":{\"allowed\":true},\"Liberia\":{\"allowed\":true},\"Lithuania\":{\"allowed\":true},\"Luxembourg\":{\"allowed\":false},\"Madagascar\":{\"allowed\":true},\"Malawi\":{\"allowed\":true},\"Malaysia\":{\"allowed\":true},\"Maldives\":{\"allowed\":true},\"Mali\":{\"allowed\":true},\"Malta\":{\"allowed\":false},\"Mauritania\":{\"allowed\":true},\"Mauritius\":{\"allowed\":true},\"Mexico\":{\"allowed\":true},\"Micronesia\":{\"allowed\":true},\"Moldova\":{\"allowed\":true},\"Mongolia\":{\"allowed\":true},\"Montenegro\":{\"allowed\":true},\"Morocco\":{\"allowed\":true},\"Mozambique\":{\"allowed\":true},\"Myanmar\":{\"allowed\":true},\"Namibia\":{\"allowed\":true},\"Nepal\":{\"allowed\":true},\"Netherlands\":{\"allowed\":false},\"Nicaragua\":{\"allowed\":true},\"Niger\":{\"allowed\":true},\"Nigeria\":{\"allowed\":true},\"North Macedonia\":{\"allowed\":true},\"Norway\":{\"allowed\":false},\"Pakistan\":{\"allowed\":true},\"Panama\":{\"allowed\":true},\"Papua New Guinea\":{\"allowed\":true},\"Paraguay\":{\"allowed\":true},\"Peru\":{\"allowed\":true},\"Philippines\":{\"allowed\":true},\"Poland\":{\"allowed\":true},\"Portugal\":{\"allowed\":true},\"Republic of the Congo\":{\"allowed\":true},\"Romania\":{\"allowed\":true},\"Russia\":{\"allowed\":true},\"Rwanda\":{\"allowed\":true},\"Saint Lucia\":{\"allowed\":true},\"Samoa\":{\"allowed\":true},\"Sao Tome and Principe\":{\"allowed\":true},\"Senegal\":{\"allowed\":true},\"Serbia\":{\"allowed\":true},\"Seychelles\":{\"allowed\":true},\"Sierra Leone\":{\"allowed\":true},\"Slovakia\":{\"allowed\":true},\"Slovenia\":{\"allowed\":false},\"Solomon Islands\":{\"allowed\":true},\"South Africa\":{\"allowed\":true},\"South Korea\":{\"allowed\":false},\"South Sudan\":{\"allowed\":true},\"Spain\":{\"allowed\":false},\"Sri Lanka\":{\"allowed\":true},\"Sudan\":{\"allowed\":true},\"Suriname\":{\"allowed\":true},\"Sweden\":{\"allowed\":false},\"Switzerland\":{\"allowed\":false},\"Syria\":{\"allowed\":true},\"Taiwan\":{\"allowed\":false},\"Tajikistan\":{\"allowed\":true},\"Tanzania\":{\"allowed\":true},\"Thailand\":{\"allowed\":true},\"Timor-Leste\":{\"allowed\":true},\"Togo\":{\"allowed\":true},\"Tonga\":{\"allowed\":true},\"Trinidad and Tobago\":{\"allowed\":true},\"Tunisia\":{\"allowed\":true},\"Turkey\":{\"allowed\":true},\"Turkmenistan\":{\"allowed\":true},\"Tuvalu\":{\"allowed\":true},\"Uganda\":{\"allowed\":true},\"Ukraine\":{\"allowed\":true},\"United Arab Emirates\":{\"allowed\":false},\"United Kingdom\":{\"allowed\":false},\"United States\":{\"allowed\":false},\"Uruguay\":{\"allowed\":true},\"Uzbekistan\":{\"allowed\":true},\"Vanuatu\":{\"allowed\":true},\"Venezuela\":{\"allowed\":true},\"Vietnam\":{\"allowed\":true},\"Yemen\":{\"allowed\":true},\"Zambia\":{\"allowed\":true},\"Zimbabwe\":{\"allowed\":true}}"
  },
  {
    "path": "public/shared.css",
    "content": ".sponsorSkipNoticeParent {\n    position: absolute;\n\n\tbottom: 100px;\n\tright: var(--skip-notice-right);\n}\n\n.sponsorSkipNoticeParent, .sponsorSkipNotice {\n\tborder-spacing: var(--skip-notice-border-horizontal) var(--skip-notice-border-vertical);\n\tpadding-left: var(--skip-notice-padding);\n\tpadding-right: var(--skip-notice-padding);\n\n\tborder-collapse: unset;\n}\n\n.sponsorSkipNoticeParent {\n\tmin-width: 390px;\n\tmax-width: 50%;\n}\n\n.sponsorSkipNotice {\n\twidth: 100%;\n}\n\n.sponsorSkipNoticeTableContainer {\n\tbackground-color: rgba(28, 28, 28, 0.9);\n\tborder-radius: 5px;\n\tmin-width: 100%;\n}\n\n.sponsorSkipNoticeTableContainer.prideTheme {\n\tbackground: url(\"icons/pride.svg\");\n\tbackground-size: cover;\n}\n\n.sponsorSkipNotice {\n\ttransition: all 0.1s ease-out;\n}\n\n.sponsorSkipNoticeLimitWidth {\n\tmax-width: calc(100% - 50px);\n}\n\n.sponsorSkipNotice .sbhidden {\n\tdisplay: none;\n}\n\n/* For Cloudtube */\n.sponsorSkipNotice td, .sponsorSkipNotice table, .sponsorSkipNotice th {\n\tborder: none;\n}\n\n.sponsorSkipNoticeFadeIn {\n\tanimation: fadeIn 0.5s ease-out;\n}\n\n.sponsorSkipNoticeFaded {\n\topacity: 0.5;\n}\n\n.sponsorSkipNoticeFadeOut {\n\ttransition: opacity 3s cubic-bezier(0.55, 0.055, 0.675, 0.19);\n\topacity: 0 !important;\n\tanimation: none !important;\n}\n\n.sponsorSkipNotice .sponsorSkipNoticeTimeLeft {\n\tcolor: #eeeeee;\n\n\tborder-radius: 4px;\n    padding: 2px 5px;\n    font-size: 12px;\n\n\tdisplay: flex;\n    align-items: center;\n\n\tborder: 1px solid #eeeeee;\n}\n\n.sponsorSkipNoticeTimeLeft img {\n\tvertical-align: middle;\n    height: 13px;\n\n\tpadding-top: 7.8%;\n    padding-bottom: 7.8%;\n}\n\n/* if two are very close to eachother */\n.secondSkipNotice {\n\tbottom: 290px;\n}\n\n.noticeLeftIcon {\n\tdisplay: flex;\n  \talign-items: center;\n}\n\n.sponsorSkipNotice .sponsorSkipNoticeUnskipSection {\n\tfloat: left;\n\n\tborder-left: 1px solid rgb(150, 150, 150);\n}\n\n.sponsorSkipNoticeButton {\n\tbackground: none;\n\tcolor: rgb(235, 235, 235);\n\tborder: none;\n\tdisplay: inline-block;\n\tfont-size: 13.3333px !important;\n\n\tcursor: pointer;\n\n\tmargin-right: 10px;\n\n    padding: 2px 5px;\n}\n\n.sponsorSkipNoticeButton:hover {\n\tbackground-color: rgba(235, 235, 235,0.2);\n\tborder-radius: 4px;\n\n\ttransition: background-color 0.4s;\n}\n\n.sponsorSkipNoticeFirstRow .sponsorSkipNoticeButton.sponsorSkipSmallButton {\n\theight: 1.3em;\n\tpadding: 0;\n}\n\n.sponsorTimesVoteButtonsContainer {\n\tfloat: left;\n\tvertical-align:middle;\n\tpadding: 2px 5px;\n\n\tmargin-right: 4px;\n}\n\n.sponsorTimesVoteButtonsContainer div{\n\tdisplay: inline-block;\n}\n\n.sponsorSkipNoticeRightSection {\n    right: 0;\n\tposition: absolute;\n\n\tfloat: right;\n\n\tmargin-right: 10px;\n\tdisplay: flex;\n\talign-items: center;\n}\n\n.sponsorSkipNoticeRightButton {\n\tmargin-right: 0;\n}\n\n.sponsorSkipNoticeCloseButton {\n\theight: 10px;\n\twidth: 10px;\n\tbox-sizing: unset;\n\n\tpadding: 2px 5px;\n\n\tmargin-left: 2px;\n    float: right;\n}\n\n.sponsorSkipNoticeCloseButton.biggerCloseButton {\n\tpadding: 20px;\n}\n\n.sponsorSkipMessage {\n\tfont-size: 14px;\n\tfont-weight: bold;\n\tcolor: rgb(235, 235, 235);\n\n\tmargin-top: auto;\n\tdisplay: inline-block;\n\tmargin-right: 10px;\n\tmargin-bottom: auto;\n}\n\n.sponsorSkipInfo {\n\tfont-size: 10px;\n    color: #000000;\n\ttext-align: center;\n\tmargin-top: 0px;\n}\n\n#sponsorTimesThanksForVotingText {\n\tfont-size: 20px;\n\tfont-weight: bold;\n    color: #000000;\n\ttext-align: center;\n\tmargin-top: 0px;\n\tmargin-bottom: 0px;\n}\n\n#sponsorTimesThanksForVotingInfoText {\n\tfont-size: 12px;\n\tfont-weight: bold;\n    color: #000000;\n\ttext-align: center;\n\tmargin-top: 0px;\n}\n\n.sponsorTimesVoteButtonMessage {\n\tfloat: left;\n}\n\n.sponsorTimesInfoMessage {\n\tfont-size: 13.3333px;\n    color: rgb(235, 235, 235);\n}\n\n.sb-guidelines-notice .sponsorTimesInfoMessage td {\n\tpadding-left: 5px;\n\tpadding-top: 2px;\n\tpadding-bottom: 2px;\n    font-size: 15px;\n\n\tdisplay: flex;\n\talign-items: center;\n}\n\n/*\n * Buttons that appear under a segment on click\n */\n.voteButton {\n\theight: 20px;\n\tpadding: 0 5px;\n\tcursor: pointer;\n}\n\n.voteButton:hover {\n\topacity: 0.8;\n}"
  },
  {
    "path": "src/background.ts",
    "content": "import * as CompileConfig from \"../config.json\";\n\nimport Config from \"./config\";\nimport { Registration } from \"./types\";\nimport \"content-scripts-register-polyfill\";\nimport { sendRealRequestToCustomServer, serializeOrStringify, setupBackgroundRequestProxy } from \"../maze-utils/src/background-request-proxy\";\nimport { setupTabUpdates } from \"../maze-utils/src/tab-updates\";\nimport { generateUserID } from \"../maze-utils/src/setup\";\n\nimport Utils from \"./utils\";\nimport { getExtensionIdsToImportFrom } from \"./utils/crossExtension\";\nimport { isFirefoxOrSafari, waitFor } from \"../maze-utils/src\";\nimport { injectUpdatedScripts } from \"../maze-utils/src/cleanup\";\nimport { logWarn } from \"./utils/logger\";\nimport { chromeP } from \"../maze-utils/src/browserApi\";\nimport { getHash } from \"../maze-utils/src/hash\";\nconst utils = new Utils({\n    registerFirefoxContentScript,\n    unregisterFirefoxContentScript\n});\n\nconst popupPort: Record<string, chrome.runtime.Port> = {};\n\n// Used only on Firefox, which does not support non persistent background pages.\nconst contentScriptRegistrations = {};\n\n// Register content script if needed\nutils.wait(() => Config.isReady()).then(function() {\n    if (Config.config.supportInvidious) utils.setupExtraSiteContentScripts();\n});\n\nsetupBackgroundRequestProxy();\nsetupTabUpdates(Config);\n\nchrome.runtime.onMessage.addListener(function (request, sender, callback) {\n    switch(request.message) {\n        case \"openConfig\":\n            chrome.tabs.create({url: chrome.runtime.getURL('options/options.html' + (request.hash ? '#' + request.hash : ''))});\n            return false;\n        case \"openHelp\":\n            chrome.tabs.create({url: chrome.runtime.getURL('help/index.html')});\n            return false;\n        case \"openPage\":\n            chrome.tabs.create({url: chrome.runtime.getURL(request.url)});\n            return false;\n        case \"submitVote\":\n            submitVote(request.type, request.UUID, request.category, request.videoID).then(callback);\n\n            //this allows the callback to be called later\n            return true;\n        case \"registerContentScript\":\n            registerFirefoxContentScript(request);\n            return false;\n        case \"unregisterContentScript\":\n            unregisterFirefoxContentScript(request.id)\n            return false;\n        case \"tabs\": {\n            chrome.tabs.query({\n                active: true,\n                currentWindow: true\n            }, tabs => {\n                chrome.tabs.sendMessage(\n                    tabs[0].id,\n                    request.data,\n                    (response) => {\n                        callback(response);\n                    }\n                );\n            });\n            return true;\n        }\n        case \"time\":\n        case \"infoUpdated\":\n        case \"videoChanged\":\n            if (sender.tab) {\n                try {\n                    popupPort[sender.tab.id]?.postMessage(request);\n                } catch (e) {\n                    // This can happen if the popup is closed\n                }\n            }\n            return false;\n        default:\n            return false;\n\t}\n});\n\nchrome.runtime.onMessageExternal.addListener((request, sender, callback) => {\n    if (getExtensionIdsToImportFrom().includes(sender.id)) {\n        if (request.message === \"requestConfig\") {\n            callback({\n                userID: Config.config.userID,\n                allowExpirements: Config.config.allowExpirements,\n                showDonationLink: Config.config.showDonationLink,\n                showUpsells: Config.config.showUpsells,\n                darkMode: Config.config.darkMode,\n            })\n        }\n    }\n});\n\nchrome.runtime.onConnect.addListener((port) => {\n    if (port.name === \"popup\") {\n        chrome.tabs.query({\n            active: true,\n            currentWindow: true\n        }, tabs => {\n            popupPort[tabs[0].id] = port;\n        });\n    }\n});\n\n//add help page on install\nchrome.runtime.onInstalled.addListener(function () {\n    // This let's the config sync to run fully before checking.\n    // This is required on Firefox\n    setTimeout(async () => {\n        const userID = Config.config.userID;\n\n        // If there is no userID, then it is the first install.\n        if (!userID && !Config.local.alreadyInstalled){\n            //open up the install page\n            chrome.tabs.create({url: chrome.runtime.getURL(\"/help/index.html\")});\n\n            //generate a userID\n            const newUserID = generateUserID();\n            //save this UUID\n            Config.config.userID = newUserID;\n            Config.local.alreadyInstalled = true;\n\n            // Don't show update notification\n            Config.config.categoryPillUpdate = true;\n        }\n\n        if (Config.config.supportInvidious) {\n            if (!(await utils.containsInvidiousPermission())) {\n                chrome.tabs.create({url: chrome.runtime.getURL(\"/permissions/index.html\")});\n            }\n        }\n\n        getHash(Config.config!.userID!).then((userID) => {\n            if (userID == \"60eed03c8644b7efa32df06977b3a4c11b62f63518e74a0e29baa1fd449cb54f\"\n                || userID == \"e347d9878bc4c8400d2d9e1164b1f2e630b04a4ca10f1a9270969a9d53da6ebb\"\n            ) {\n                Config.config.prideTheme = true;\n            }\n        });\n    }, 1500);\n\n    if (!isFirefoxOrSafari()) {\n        injectUpdatedScripts().catch(logWarn);\n\n        waitFor(() => Config.isReady()).then(() => {\n            if (Config.config.supportInvidious) {\n                injectUpdatedScripts([\n                    utils.getExtraSiteRegistration()\n                ])\n            }\n        }).catch(logWarn);\n    }\n});\n\n/**\n * Only works on Firefox.\n * Firefox requires that it be applied after every extension restart.\n *\n * @param {JSON} options\n */\nasync function registerFirefoxContentScript(options: Registration) {\n    if (\"scripting\" in chrome && \"getRegisteredContentScripts\" in chrome.scripting) {\n        const existingRegistrations = await chromeP.scripting.getRegisteredContentScripts({\n            ids: [options.id]\n        }).catch(() => []);\n\n        if (existingRegistrations && existingRegistrations.length > 0 \n            && options.matches.every((match) => existingRegistrations[0].matches.includes(match))) {\n            // No need to register another script, already registered\n            return;\n        }\n    }\n\n    await unregisterFirefoxContentScript(options.id);\n\n    if (\"scripting\" in chrome && \"getRegisteredContentScripts\" in chrome.scripting) {\n        await chromeP.scripting.registerContentScripts([{\n            id: options.id,\n            runAt: \"document_start\",\n            matches: options.matches,\n            allFrames: options.allFrames,\n            js: options.js,\n            css: options.css,\n            persistAcrossSessions: true,\n        }]);\n    } else {\n        chrome.contentScripts.register({\n            allFrames: options.allFrames,\n            js: options.js?.map?.(file => ({file})),\n            css: options.css?.map?.(file => ({file})),\n            matches: options.matches\n        }).then((registration) => void (contentScriptRegistrations[options.id] = registration));\n    }\n\n}\n\n/**\n * Only works on Firefox.\n * Firefox requires that this is handled by the background script\n */\nasync function  unregisterFirefoxContentScript(id: string) {\n    if (\"scripting\" in chrome && \"getRegisteredContentScripts\" in chrome.scripting) {\n        try {\n            await chromeP.scripting.unregisterContentScripts({\n                ids: [id]\n            });\n        } catch (e) {\n            // Not registered yet\n        }\n    } else {\n        if (contentScriptRegistrations[id]) {\n            contentScriptRegistrations[id].unregister();\n            delete contentScriptRegistrations[id];\n        }\n    }\n}\n\nasync function submitVote(type: number, UUID: string, category: string, videoID: string) {\n    let userID = Config.config.userID;\n\n    if (userID == undefined || userID === \"undefined\") {\n        //generate one\n        userID = generateUserID();\n        Config.config.userID = userID;\n    }\n\n    const typeSection = (type !== undefined) ? \"&type=\" + type : \"&category=\" + category;\n\n    try {\n        const response = await asyncRequestToServer(\"POST\", \"/api/voteOnSponsorTime?UUID=\" + UUID + \"&videoID=\" + videoID + \"&userID=\" + userID + typeSection);\n\n        return {\n            status: response.status,\n            ok: response.ok,\n            responseText: await response.text(),\n        };\n    } catch (e) {\n        console.error(\"Error while voting:\", e);\n        return {\n            error: serializeOrStringify(e),\n        };\n    }\n}\n\n\nasync function asyncRequestToServer(type: string, address: string, data = {}) {\n    const serverAddress = Config.config.testingServer ? CompileConfig.testingServerAddress : Config.config.serverAddress;\n\n    return await (sendRealRequestToCustomServer(type, serverAddress + address, data));\n}\n"
  },
  {
    "path": "src/components/CategoryPillComponent.tsx",
    "content": "import * as React from \"react\";\nimport Config from \"../config\";\nimport { Category, SegmentUUID, SponsorTime } from \"../types\";\n\nimport ThumbsUpSvg from \"../svg-icons/thumbs_up_svg\";\nimport ThumbsDownSvg from \"../svg-icons/thumbs_down_svg\";\nimport { downvoteButtonColor, SkipNoticeAction } from \"../utils/noticeUtils\";\nimport { VoteResponse } from \"../messageTypes\";\nimport { AnimationUtils } from \"../../maze-utils/src/animationUtils\";\nimport { Tooltip } from \"../render/Tooltip\";\nimport { formatJSErrorMessage, getLongErrorMessage } from \"../../maze-utils/src/formating\";\nimport { logRequest } from \"../../maze-utils/src/background-request-proxy\";\n\nexport interface CategoryPillProps {\n    vote: (type: number, UUID: SegmentUUID, category?: Category) => Promise<VoteResponse>;\n    showTextByDefault: boolean;\n    showTooltipOnClick: boolean;\n}\n\nexport interface CategoryPillState {\n    segment?: SponsorTime;\n    show: boolean;\n    open?: boolean;\n}\n\nclass CategoryPillComponent extends React.Component<CategoryPillProps, CategoryPillState> {\n    mainRef: React.MutableRefObject<HTMLSpanElement>;\n    tooltip?: Tooltip;\n\n    constructor(props: CategoryPillProps) {\n        super(props);\n\n        this.mainRef = React.createRef();\n\n        this.state = {\n            segment: null,\n            show: false,\n            open: false\n        };\n    }\n\n    render(): React.ReactElement {\n        const style: React.CSSProperties = {\n            backgroundColor: this.getColor(),\n            display: this.state.show ? \"flex\" : \"none\",\n            color: this.getTextColor(),\n        }\n\n        // To be able to remove the margin from the parent\n        this.mainRef?.current?.parentElement?.classList?.toggle(\"cbPillOpen\", this.state.show);\n\n        return (\n            <span style={style}\n                className={\"sponsorBlockCategoryPill\" + (!this.props.showTextByDefault ? \" sbPillNoText\" : \"\")}\n                aria-label={this.getTitleText()}\n                onClick={(e) => this.toggleOpen(e)}\n                onMouseEnter={() => this.openTooltip()}\n                onMouseLeave={() => this.closeTooltip()}\n                ref={this.mainRef}>\n                \n                <span className=\"sponsorBlockCategoryPillTitleSection\">\n                    <img className=\"sponsorSkipLogo sponsorSkipObject\"\n                        src={chrome.runtime.getURL(Config.config.prideTheme ? \"icons/sb-pride.png\" : \"icons/IconSponsorBlocker256px.png\")}>\n                    </img>\n\n                    {\n                        (this.props.showTextByDefault || this.state.open) &&\n                            <span className=\"sponsorBlockCategoryPillTitle\">\n                                {chrome.i18n.getMessage(\"category_\" + this.state.segment?.category)}\n                            </span>\n                    }\n                </span>\n\n                {this.state.open && (\n                    <>\n                        {/* Upvote Button */}\n                        <div id={\"sponsorTimesDownvoteButtonsContainerUpvoteCategoryPill\"}\n                                className=\"voteButton\"\n                                style={{marginLeft: \"5px\"}}\n                                title={chrome.i18n.getMessage(\"upvoteButtonInfo\")}\n                                onClick={(e) => this.vote(e, 1)}>\n                            <ThumbsUpSvg fill={Config.config.colorPalette.white} />\n                        </div>\n\n                        {/* Downvote Button */}\n                        <div id={\"sponsorTimesDownvoteButtonsContainerDownvoteCategoryPill\"}\n                                className=\"voteButton\"\n                                title={chrome.i18n.getMessage(\"reportButtonInfo\")}\n                                onClick={(event) => this.vote(event, 0)}>\n                            <ThumbsDownSvg fill={downvoteButtonColor(null, null, SkipNoticeAction.Downvote)} />\n                        </div>\n                    </>\n                )}\n\n                {/* Close Button */}\n                <img src={chrome.runtime.getURL(\"icons/close.png\")}\n                    className=\"categoryPillClose\"\n                    onClick={() => {\n                        this.setState({ show: false });\n                        this.closeTooltip();\n                    }}>\n                </img>\n            </span>\n        );\n    }\n\n    private toggleOpen(event: React.MouseEvent): void {\n        event.stopPropagation();\n\n        if (this.state.show) {\n            if (this.props.showTooltipOnClick) {\n                if (this.state.open) {\n                    this.closeTooltip();\n                } else {\n                    this.openTooltip();\n                }\n            }\n\n            this.setState({ open: !this.state.open });\n        }\n    }\n\n    private async vote(event: React.MouseEvent, type: number): Promise<void> {\n        event.stopPropagation();\n        if (this.state.segment) {\n            const stopAnimation = AnimationUtils.applyLoadingAnimation(event.currentTarget as HTMLElement, 0.3);\n\n            const response = await this.props.vote(type, this.state.segment.UUID);\n            await stopAnimation();\n\n            if (\"error\" in response) {\n                console.error(\"[SB] Caught error while attempting to vote on a FV label\", response.error);\n                alert(formatJSErrorMessage(response.error));\n            } else if (response.ok || response.status === 429) {\n                this.setState({\n                    open: false,\n                    show: type === 1\n                });\n\n                this.closeTooltip();\n            } else if (response.status !== 403) {\n                logRequest({headers: null, ...response}, \"SB\", \"vote on FV label\");\n                alert(getLongErrorMessage(response.status, response.responseText));\n            }\n        }\n    }\n\n    private getColor(): string {\n        // Handled by setCategoryColorCSSVariables() of content.ts\n        const category = this.state.segment?.category;\n        return category == null ? null : `var(--sb-category-preview-${category}, var(--sb-category-${category}))`;\n    }\n\n    private getTextColor(): string {\n        // Handled by setCategoryColorCSSVariables() of content.ts\n        const category = this.state.segment?.category;\n        return category == null ? null : `var(--sb-category-text-preview-${category}, var(--sb-category-text-${category}))`;\n    }\n\n    private openTooltip(): void {\n        if (this.tooltip) {\n            this.tooltip.close();\n        }\n\n        const tooltipMount = document.querySelector(\"#above-the-fold, ytm-slim-owner-renderer\") as HTMLElement;\n        if (tooltipMount) {\n            this.tooltip = new Tooltip({\n                text: this.getTitleText(),\n                referenceNode: tooltipMount,\n                bottomOffset: \"0px\",\n                opacity: 0.95,\n                displayTriangle: false,\n                showLogo: false,\n                showGotIt: false,\n                prependElement: tooltipMount.firstElementChild as HTMLElement\n            });\n        }\n    }\n\n    private closeTooltip(): void {\n        this.tooltip?.close?.();\n        this.tooltip = null;\n    }\n\n    getTitleText(): string {\n        const shortDescription = chrome.i18n.getMessage(`category_${this.state.segment?.category}_pill`);\n        return (shortDescription ? shortDescription + \". \": \"\") + chrome.i18n.getMessage(\"categoryPillTitleText\");\n    }\n}\n\nexport default CategoryPillComponent;\n"
  },
  {
    "path": "src/components/ChapterVoteComponent.tsx",
    "content": "import * as React from \"react\";\nimport Config from \"../config\";\nimport { ActionType, Category, SegmentUUID, SponsorTime } from \"../types\";\n\nimport ThumbsUpSvg from \"../svg-icons/thumbs_up_svg\";\nimport ThumbsDownSvg from \"../svg-icons/thumbs_down_svg\";\nimport { downvoteButtonColor, SkipNoticeAction } from \"../utils/noticeUtils\";\nimport { VoteResponse } from \"../messageTypes\";\nimport { AnimationUtils } from \"../../maze-utils/src/animationUtils\";\nimport { Tooltip } from \"../render/Tooltip\";\nimport { formatJSErrorMessage, getLongErrorMessage } from \"../../maze-utils/src/formating\";\nimport { logRequest } from \"../../maze-utils/src/background-request-proxy\";\n\nexport interface ChapterVoteProps {\n    vote: (type: number, UUID: SegmentUUID, category?: Category) => Promise<VoteResponse>;\n    size?: string;\n}\n\nexport interface ChapterVoteState {\n    segment?: SponsorTime;\n    show: boolean;\n    size?: string;\n}\n\nclass ChapterVoteComponent extends React.Component<ChapterVoteProps, ChapterVoteState> {\n    tooltip?: Tooltip;\n\n    constructor(props: ChapterVoteProps) {\n        super(props);\n\n        this.state = {\n            segment: null,\n            show: false,\n            size: props.size ?? \"22px\"\n        };\n    }\n\n    render(): React.ReactElement {\n        if (this.tooltip && !this.state.show) {\n            this.tooltip.close();\n            this.tooltip = null;\n        }\n\n        return (\n            <>\n                {/* Upvote Button */}\n                <button id={\"sponsorTimesDownvoteButtonsContainerUpvoteChapter\"}\n                        className={\"playerButton sbPlayerUpvote ytp-button \" + (!this.state.show ? \"sbhidden \" : \" \") + (document.location.host === \"tv.youtube.com\" ? \"sbButtonYTTV\" : \"\")}\n                        draggable=\"false\"\n                        title={chrome.i18n.getMessage(\"upvoteButtonInfo\")}\n                        onClick={(e) => this.vote(e, 1)}>\n                    <ThumbsUpSvg className=\"playerButtonImage sbChapterVoteButton\" \n                        fill={Config.config.colorPalette.white} \n                        width={this.state.size} height={this.state.size} />\n                </button>\n\n                {/* Downvote Button */}\n                <button id={\"sponsorTimesDownvoteButtonsContainerDownvoteChapter\"}\n                        className={\"playerButton sbPlayerDownvote ytp-button \" + (!this.state.show ? \"sbhidden \" : \" \") + (document.location.host === \"tv.youtube.com\" ? \"sbButtonYTTV\" : \"\")}\n                        draggable=\"false\"\n                        title={chrome.i18n.getMessage(\"reportButtonInfo\")}\n                        onClick={(e) => {\n                            const chapterNode = document.querySelector(\".ytp-chapter-container\") as HTMLElement;\n\n                            if (this.tooltip) {\n                                this.tooltip.close();\n                                this.tooltip = null;\n                            } else {\n                                if (this.state.segment?.actionType === ActionType.Chapter) {\n                                    const referenceNode = chapterNode?.parentElement?.parentElement;\n                                    if (referenceNode) {\n                                        const outerBounding = referenceNode.getBoundingClientRect();\n                                        const buttonBounding = (e.target as HTMLElement)?.parentElement?.getBoundingClientRect();\n                                        \n                                        this.tooltip = new Tooltip({\n                                            referenceNode: chapterNode?.parentElement?.parentElement,\n                                            prependElement: chapterNode?.parentElement,\n                                            showLogo: false,\n                                            showGotIt: false,\n                                            bottomOffset: `${outerBounding.height + 25}px`,\n                                            leftOffset: `${buttonBounding.x - outerBounding.x}px`,\n                                            extraClass: \"centeredSBTriangle\",\n                                            buttons: [\n                                                {\n                                                    name: chrome.i18n.getMessage(\"incorrectVote\"),\n                                                    listener: (event) => this.vote(event, 0, e.target as HTMLElement).then(() => {\n                                                        this.tooltip?.close();\n                                                        this.tooltip = null;\n                                                    })\n                                                }, {\n                                                    name: chrome.i18n.getMessage(\"harmfulVote\"),\n                                                    listener: (event) => this.vote(event, 30, e.target as HTMLElement).then(() => {\n                                                        this.tooltip?.close();\n                                                        this.tooltip = null;\n                                                    })\n                                                }\n                                            ]\n                                        });\n                                    }\n                                } else {\n                                    this.vote(e, 0, e.target as HTMLElement)\n                                }\n                            }\n                        }}>\n                    <ThumbsDownSvg \n                        className=\"playerButtonImage sbChapterVoteButton\"\n                        fill={downvoteButtonColor(this.state.segment ? [this.state.segment] : null, SkipNoticeAction.Downvote, SkipNoticeAction.Downvote)} \n                        width={this.state.size} \n                        height={this.state.size} />\n                </button>\n            </>\n        );\n    }\n\n    private async vote(event: React.MouseEvent, type: number, element?: HTMLElement): Promise<void> {\n        event.stopPropagation();\n        if (this.state.segment) {\n            const stopAnimation = AnimationUtils.applyLoadingAnimation(element ?? event.currentTarget as HTMLElement, 0.3);\n\n            const response = await this.props.vote(type, this.state.segment.UUID);\n            await stopAnimation();\n\n            if (\"error\" in response){\n                console.error(\"[SB] Caught error while attempting to vote on a chapter\", response.error);\n                alert(formatJSErrorMessage(response.error));\n            } else if (response.ok || response.status == 429) {\n                this.setState({\n                    show: type === 1\n                });\n            } else if (response.status !== 403) {\n                logRequest({headers: null, ...response}, \"SB\", \"vote on chapter\");\n                alert(getLongErrorMessage(response.status, response.responseText));\n            }\n        }\n    }\n}\n\nexport default ChapterVoteComponent;\n"
  },
  {
    "path": "src/components/NoticeComponent.tsx",
    "content": "import * as React from \"react\";\nimport Config from \"../config\";\nimport SbSvg from \"../svg-icons/sb_svg\";\n\nenum CountdownMode {\n    Timer,\n    Paused,\n    Stopped\n}\n\nexport interface NoticeProps {\n    noticeTitle: string;\n\n    maxCountdownTime?: () => number;\n    dontPauseCountdown?: boolean;\n    amountOfPreviousNotices?: number;\n    showInSecondSlot?: boolean;\n    timed?: boolean;\n    idSuffix?: string;\n\n    fadeIn?: boolean;\n    fadeOut?: boolean;\n    startFaded?: boolean;\n    firstColumn?: React.ReactElement[] | React.ReactElement;\n    firstRow?: React.ReactElement;\n    bottomRow?: React.ReactElement[];\n\n    smaller?: boolean;\n    limitWidth?: boolean;\n    extraClass?: string;\n    hideLogo?: boolean;\n    hideRightInfo?: boolean;\n    logoFill?: string;\n\n    // Callback for when this is closed\n    closeListener: () => void;\n    onMouseEnter?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;\n\n    zIndex?: number;\n    style?: React.CSSProperties;\n    biggerCloseButton?: boolean;\n    children?: React.ReactNode;\n}\n\ninterface MouseDownInfo {\n    x: number;\n    y: number;\n    right: number;\n    bottom: number;\n}\n\nexport interface NoticeState {\n    maxCountdownTime: () => number;\n\n    countdownTime: number;\n    countdownMode: CountdownMode;\n\n    mouseHovering: boolean;\n\n    startFaded: boolean;\n\n    mouseDownInfo: MouseDownInfo | null;\n    mouseMoved: boolean;\n    right: number;\n    bottom: number;\n}\n\n// Limits for dragging notice around\nconst bounds = [10, 100, 10, 10];\n\nclass NoticeComponent extends React.Component<NoticeProps, NoticeState> {\n    countdownInterval: NodeJS.Timeout;\n\n    idSuffix: string;\n\n    amountOfPreviousNotices: number;\n\n    parentRef: React.RefObject<HTMLDivElement>;\n\n    handleMouseMoveBinded: (e: MouseEvent) => void = this.handleMouseMove.bind(this);\n\n    constructor(props: NoticeProps) {\n        super(props);\n\n        this.parentRef = React.createRef();\n\n        const maxCountdownTime = () => {\n            if (this.props.maxCountdownTime) return this.props.maxCountdownTime();\n            else return Config.config.skipNoticeDuration;\n        };\n    \n        //the id for the setInterval running the countdown\n        this.countdownInterval = null;\n\n        this.amountOfPreviousNotices = props.amountOfPreviousNotices || 0;\n\n        this.idSuffix = props.idSuffix || \"\";\n\n        // Setup state\n        this.state = {\n            maxCountdownTime,\n\n            //the countdown until this notice closes\n            countdownTime: maxCountdownTime(),\n            countdownMode: CountdownMode.Timer,\n            mouseHovering: false,\n\n            startFaded: this.props.startFaded ?? false,\n\n            mouseDownInfo: null,\n            mouseMoved: false,\n            right: bounds[0],\n            bottom: props.showInSecondSlot ? 290 : bounds[1]\n        }\n    }\n\n    componentDidMount(): void {\n        this.startCountdown();\n    }\n\n    render(): React.ReactElement {\n        const noticeStyle: React.CSSProperties = {\n            zIndex: this.props.zIndex || (1000 + this.amountOfPreviousNotices),\n            right: this.state.right,\n            bottom: this.state.bottom,\n            userSelect: this.state.mouseDownInfo && this.state.mouseMoved ? \"none\" : \"auto\",\n            ...(this.props.style ?? {})\n        }\n\n        return (\n            <div id={\"sponsorSkipNotice\" + this.idSuffix} \n                className={\"sponsorSkipObject sponsorSkipNoticeParent\"\n                    + (this.props.showInSecondSlot ? \" secondSkipNotice\" : \"\")\n                    + (this.props.extraClass ? ` ${this.props.extraClass}` : \"\")}\n                onMouseEnter={(e) => this.onMouseEnter(e) }\n                onMouseLeave={() => {\n                    this.timerMouseLeave();\n                }}\n                onMouseDown={(e) => {\n                    document.addEventListener(\"mousemove\", this.handleMouseMoveBinded);\n\n                    this.setState({\n                        mouseDownInfo: {\n                            x: e.clientX,\n                            y: e.clientY,\n                            right: this.state.right,\n                            bottom: this.state.bottom\n                        },\n                        mouseMoved: false\n                    });\n                }}\n                onMouseUp={() => {\n                    document.removeEventListener(\"mousemove\", this.handleMouseMoveBinded);\n\n                    this.setState({\n                        mouseDownInfo: null\n                    });\n                }}\n                ref={this.parentRef}\n                style={noticeStyle} >\n                <div className={\"sponsorSkipNoticeTableContainer\" \n                        + (this.props.fadeIn ? \" sponsorSkipNoticeFadeIn\" : \"\")\n                        + (this.state.startFaded ? \" sponsorSkipNoticeFaded\" : \"\") \n                        + (Config.config.prideTheme ? \" prideTheme\" : \"\")}>\n                    <table className={\"sponsorSkipObject sponsorSkipNotice\"\n                                + (this.props.limitWidth ? \" sponsorSkipNoticeLimitWidth\" : \"\")}>\n                        <tbody>\n\n                            {/* First row */}\n                            <tr id={\"sponsorSkipNoticeFirstRow\" + this.idSuffix}\n                                    className=\"sponsorSkipNoticeFirstRow\">\n                                {/* Left column */}\n                                <td className=\"noticeLeftIcon\">\n                                    {/* Logo */}\n                                    {!this.props.hideLogo &&\n                                        (\n                                            !Config.config.prideTheme ?\n                                                <SbSvg\n                                                    id={\"sponsorSkipLogo\" + this.idSuffix} \n                                                    fill={this.props.logoFill}\n                                                    className=\"sponsorSkipLogo sponsorSkipObject\"/>\n                                            :\n                                                <img \n                                                    id={\"sponsorSkipLogo\" + this.idSuffix} \n                                                    src={chrome.runtime.getURL(\"icons/sb-pride.png\")}\n                                                    className=\"sponsorSkipLogo sponsorSkipObject\"/>\n                                        )\n                                    }\n\n                                    <span id={\"sponsorSkipMessage\" + this.idSuffix}\n                                        style={{float: \"left\", marginRight: this.props.hideLogo ? \"0px\" : null}}\n                                        className=\"sponsorSkipMessage sponsorSkipObject\">\n                                        \n                                        {this.props.noticeTitle}\n                                    </span>\n\n                                    {this.props.firstColumn}\n                                </td>\n\n                                {this.props.firstRow}\n\n                                {/* Right column */}\n                                {!this.props.hideRightInfo &&\n                                    <td className=\"sponsorSkipNoticeRightSection\"\n                                        style={{top: \"9.32px\"}}>\n                                        \n                                        {/* Time left */}\n                                        {this.props.timed ? ( \n                                            <span id={\"sponsorSkipNoticeTimeLeft\" + this.idSuffix}\n                                                onClick={() => this.toggleManualPause()}\n                                                className=\"sponsorSkipObject sponsorSkipNoticeTimeLeft\">\n\n                                                {this.getCountdownElements()}\n\n                                            </span>\n                                        ) : \"\"}\n                                    \n\n                                        {/* Close button */}\n                                        <img src={chrome.runtime.getURL(\"icons/close.png\")}\n                                            className={\"sponsorSkipObject sponsorSkipNoticeButton sponsorSkipNoticeCloseButton sponsorSkipNoticeRightButton\" \n                                                            + (this.props.biggerCloseButton ? \" biggerCloseButton\" : \"\")}\n                                            onClick={() => this.close()}>\n                                        </img>\n                                    </td>\n                                }\n                            </tr> \n\n                            {this.props.children}\n\n                            {!this.props.smaller && this.props.bottomRow ? \n                                this.props.bottomRow\n                            : null}\n\n                        </tbody> \n                    </table>\n                </div>\n\n                {/* Add as a hidden table to keep the height constant */}\n                {this.props.smaller && this.props.bottomRow ? \n                    <table style={{visibility: \"hidden\", paddingTop: \"14px\"}}>\n                        <tbody>\n                        {this.props.bottomRow}\n                        </tbody>\n                    </table>\n                : null}\n            </div>\n        );\n    }\n\n    getCountdownElements(): React.ReactElement[] {\n        return [(\n                    <span \n                        id={\"skipNoticeTimerText\" + this.idSuffix}\n                        key=\"skipNoticeTimerText\"\n                        className={this.state.countdownMode !== CountdownMode.Timer ? \"sbhidden\" : \"\"} >\n                            {chrome.i18n.getMessage(\"NoticeTimeAfterSkip\").replace(\"{seconds}\", Math.ceil(this.state.countdownTime).toString())}\n                    </span>\n                ),(\n                    <img \n                        id={\"skipNoticeTimerPaused\" + this.idSuffix}\n                        key=\"skipNoticeTimerPaused\"\n                        className={this.state.countdownMode !== CountdownMode.Paused ? \"sbhidden\" : \"\"}\n                        src={chrome.runtime.getURL(\"icons/pause.svg\")}\n                        alt={chrome.i18n.getMessage(\"paused\")} />\n                ),(\n                    <img \n                        id={\"skipNoticeTimerStopped\" + this.idSuffix}\n                        key=\"skipNoticeTimerStopped\"\n                        className={this.state.countdownMode !== CountdownMode.Stopped ? \"sbhidden\" : \"\"}\n                        src={chrome.runtime.getURL(\"icons/stop.svg\")}\n                        alt={chrome.i18n.getMessage(\"manualPaused\")} />\n        )];\n    }\n\n    onMouseEnter(event: React.MouseEvent<HTMLElement, MouseEvent>): void {\n        if (this.props.onMouseEnter) this.props.onMouseEnter(event);\n\n        this.fadedMouseEnter();\n        this.timerMouseEnter();\n    }\n\n    fadedMouseEnter(): void {\n        if (this.state.startFaded) {\n            this.setState({\n                startFaded: false\n            });\n        }\n    }\n\n    timerMouseEnter(): void {\n        if (this.state.countdownMode === CountdownMode.Stopped) return;\n\n        this.pauseCountdown();\n\n        this.setState({\n            mouseHovering: true\n        });\n    }\n\n    timerMouseLeave(): void {\n        if (this.state.countdownMode === CountdownMode.Stopped) return;\n\n        this.startCountdown();\n\n        this.setState({\n            mouseHovering: false\n        });\n    }\n\n    toggleManualPause(): void {\n        this.setState({\n            countdownMode: this.state.countdownMode === CountdownMode.Stopped ? CountdownMode.Timer : CountdownMode.Stopped\n        }, () => {\n            if (this.state.countdownMode === CountdownMode.Stopped || this.state.mouseHovering) {\n                this.pauseCountdown();\n            } else {\n                this.startCountdown();\n            }\n        });\n    }\n\n    //called every second to lower the countdown before hiding the notice\n    countdown(): void {\n        if (!this.props.timed) return;\n\n        const countdownTime = Math.min(this.state.countdownTime - 1, this.state.maxCountdownTime());\n\n        if (countdownTime <= 0) {\n            //remove this from setInterval\n            clearInterval(this.countdownInterval);\n\n            //time to close this notice\n            this.close();\n\n            return;\n        }\n\n        if (countdownTime == 3 && this.props.fadeOut) {\n            //start fade out animation\n            const notice = document.getElementById(\"sponsorSkipNotice\" + this.idSuffix);\n            notice?.style.removeProperty(\"animation\");\n            notice?.classList.add(\"sponsorSkipNoticeFadeOut\");\n        }\n\n        this.setState({\n            countdownTime\n        })\n    }\n    \n    removeFadeAnimation(): void {\n        //remove the fade out class if it exists\n        const notice = document.getElementById(\"sponsorSkipNotice\" + this.idSuffix);\n        notice.classList.remove(\"sponsorSkipNoticeFadeOut\");\n        notice.style.animation = \"none\";\n    }\n\n    pauseCountdown(): void {\n        if (!this.props.timed || this.props.dontPauseCountdown) return;\n\n        //remove setInterval\n        if (this.countdownInterval) clearInterval(this.countdownInterval);\n        this.countdownInterval = null;\n\n        //reset countdown and inform the user\n        this.setState({\n            countdownTime: this.state.maxCountdownTime(),\n            countdownMode: this.state.countdownMode === CountdownMode.Timer ? CountdownMode.Paused : this.state.countdownMode\n        });\n        \n        this.removeFadeAnimation();\n    }\n\n    startCountdown(): void {\n        if (!this.props.timed) return;\n\n        //if it has already started, don't start it again\n        if (this.countdownInterval !== null) return;\n\n        this.setState({\n            countdownTime: this.state.maxCountdownTime(),\n            countdownMode: CountdownMode.Timer\n        });\n\n        this.setupInterval();\n    }\n\n    setupInterval(): void {\n        if (this.countdownInterval) clearInterval(this.countdownInterval);\n\n        this.countdownInterval = setInterval(this.countdown.bind(this), 1000);\n    }\n\n    resetCountdown(): void {\n        if (!this.props.timed) return;\n\n        this.setupInterval();\n\n        this.setState({\n            countdownTime: this.state.maxCountdownTime(),\n            countdownMode: CountdownMode.Timer\n        });\n\n        this.removeFadeAnimation();\n    }\n    \n    /**\n     * @param silent If true, the close listener will not be called\n     */\n    close(silent?: boolean): void {\n        //remove setInterval\n        if (this.countdownInterval !== null) clearInterval(this.countdownInterval);\n\n        if (!silent) this.props.closeListener();\n    }\n\n    addNoticeInfoMessage(message: string, message2 = \"\"): void {\n        //TODO: Replace\n\n        const previousInfoMessage = document.getElementById(\"sponsorTimesInfoMessage\" + this.idSuffix);\n        if (previousInfoMessage != null) {\n            //remove it\n            document.getElementById(\"sponsorSkipNotice\" + this.idSuffix).removeChild(previousInfoMessage);\n        }\n\n        const previousInfoMessage2 = document.getElementById(\"sponsorTimesInfoMessage\" + this.idSuffix + \"2\");\n        if (previousInfoMessage2 != null) {\n            //remove it\n            document.getElementById(\"sponsorSkipNotice\" + this.idSuffix).removeChild(previousInfoMessage2);\n        }\n        \n        //add info\n        const thanksForVotingText = document.createElement(\"p\");\n        thanksForVotingText.id = \"sponsorTimesInfoMessage\" + this.idSuffix;\n        thanksForVotingText.className = \"sponsorTimesInfoMessage\";\n        thanksForVotingText.innerText = message;\n\n        //add element to div\n        document.querySelector(\"#sponsorSkipNotice\" + this.idSuffix + \" > tbody\").insertBefore(thanksForVotingText, document.getElementById(\"sponsorSkipNoticeSpacer\" + this.idSuffix));\n    \n        if (message2 !== undefined) {\n            const thanksForVotingText2 = document.createElement(\"p\");\n            thanksForVotingText2.id = \"sponsorTimesInfoMessage\" + this.idSuffix + \"2\";\n            thanksForVotingText2.className = \"sponsorTimesInfoMessage\";\n            thanksForVotingText2.innerText = message2;\n\n            //add element to div\n            document.querySelector(\"#sponsorSkipNotice\" + this.idSuffix + \" > tbody\").insertBefore(thanksForVotingText2, document.getElementById(\"sponsorSkipNoticeSpacer\" + this.idSuffix));\n        }\n    }\n\n    getElement(): React.RefObject<HTMLDivElement> {\n        return this.parentRef;\n    }\n\n    componentWillUnmount(): void {\n        document.removeEventListener(\"mousemove\", this.handleMouseMoveBinded);\n    }\n\n    // For dragging around notice\n    handleMouseMove(e: MouseEvent): void {\n        if (this.state.mouseDownInfo && e.buttons === 1) {\n            const [mouseX, mouseY] = [e.clientX, e.clientY];\n\n            const deltaX = mouseX - this.state.mouseDownInfo.x;\n            const deltaY = mouseY - this.state.mouseDownInfo.y;\n\n            if (deltaX > 0 || deltaY > 0) this.setState({ mouseMoved: true });\n\n            const element = this.parentRef.current;\n            const parent = element.parentElement.parentElement;\n            this.setState({\n                right: Math.min(parent.clientWidth - element.clientWidth - bounds[2], Math.max(bounds[0], this.state.mouseDownInfo.right - deltaX)),\n                bottom: Math.min(parent.clientHeight - element.clientHeight - bounds[3], Math.max(bounds[1], this.state.mouseDownInfo.bottom - deltaY))\n            });\n        } else {\n            document.removeEventListener(\"mousemove\", this.handleMouseMoveBinded);\n        }\n    }\n}\n\nexport default NoticeComponent;\n"
  },
  {
    "path": "src/components/NoticeTextSectionComponent.tsx",
    "content": "import * as React from \"react\";\n\nexport interface NoticeTextSelectionProps {\n    icon?: string;\n    text: string;\n    idSuffix: string;\n    onClick?: (event: React.MouseEvent) => unknown;\n    children?: React.ReactNode;\n}\n\nexport interface NoticeTextSelectionState {\n\n}\n\nclass NoticeTextSelectionComponent extends React.Component<NoticeTextSelectionProps, NoticeTextSelectionState> {\n\n    constructor(props: NoticeTextSelectionProps) {\n        super(props);\n    }\n\n    render(): React.ReactElement {\n        const style: React.CSSProperties = {};\n        if (this.props.onClick) {\n            style.cursor = \"pointer\";\n            style.textDecoration = \"underline\"\n        }\n\n        return (\n            <tr id={\"sponsorTimesInfoMessage\" + this.props.idSuffix}\n                onClick={this.props.onClick}\n                style={style}\n                className=\"sponsorTimesInfoMessage\">\n                    \n                <td>\n                    {this.props.icon ? \n                        <img src={chrome.runtime.getURL(this.props.icon)} className=\"sponsorTimesInfoIcon\" /> \n                    : null}\n\n                    <span>\n                        {this.getTextElements(this.props.text)}\n                    </span>\n                </td>\n            </tr>\n        );\n    }\n\n    private getTextElements(text: string): Array<string | React.ReactElement> {\n        const elements: Array<string | React.ReactElement> = [];\n        const textParts = text.split(/(?=\\s+)/);\n        for (const textPart of textParts) {\n            if (textPart.match(/^\\s*http/)) {\n                elements.push(\n                    <a href={textPart} target=\"_blank\" rel=\"noreferrer\">\n                        {textPart}\n                    </a>\n                );\n            } else {\n                elements.push(textPart);\n            }\n\n        }\n\n        return elements;\n    }\n}\n\nexport default NoticeTextSelectionComponent;"
  },
  {
    "path": "src/components/SelectorComponent.tsx",
    "content": "import * as React from \"react\";\n\nexport interface SelectorOption {\n    label: string;\n}\n\nexport interface SelectorProps { \n    id: string;\n    options: SelectorOption[];\n    onChange: (value: string) => void;\n    onMouseEnter?: () => void;\n    onMouseLeave?: () => void;\n}\n\nexport interface SelectorState {\n\n}\n\nclass SelectorComponent extends React.Component<SelectorProps, SelectorState> {\n\n    constructor(props: SelectorProps) {\n        super(props);\n\n        // Setup state\n        this.state = {\n            \n        }\n    }\n\n    render(): React.ReactElement {\n        return (\n            <div id={this.props.id}\n                style={{display: this.props.options.length > 0 ? \"inherit\" : \"none\"}}\n                className=\"sbSelector\">\n                <div onMouseEnter={this.props.onMouseEnter}\n                    onMouseLeave={this.props.onMouseLeave}\n                    className=\"sbSelectorBackground\">\n                    {this.getOptions()}\n                </div>\n            </div>\n        );\n    }\n\n    getOptions(): React.ReactElement[] {\n        const result: React.ReactElement[] = [];\n        for (const option of this.props.options) {\n            result.push(\n                <div className=\"sbSelectorOption\"\n                    onClick={(e) => {\n                        e.stopPropagation();\n                        this.props.onChange(option.label);\n                    }}\n                    key={option.label}>\n                    {option.label}\n                </div>\n            );\n        }\n\n        return result;\n    }\n}\n\nexport default SelectorComponent;"
  },
  {
    "path": "src/components/SkipNoticeComponent.tsx",
    "content": "import * as React from \"react\";\nimport * as CompileConfig from \"../../config.json\";\nimport Config from \"../config\"\nimport { Category, ContentContainer, SponsorTime, NoticeVisibilityMode, ActionType, SponsorSourceType, SegmentUUID } from \"../types\";\nimport NoticeComponent from \"./NoticeComponent\";\nimport NoticeTextSelectionComponent from \"./NoticeTextSectionComponent\";\nimport Utils from \"../utils\";\nconst utils = new Utils();\nimport { getSkippingText, getUpcomingText, getVoteText } from \"../utils/categoryUtils\";\n\nimport ThumbsUpSvg from \"../svg-icons/thumbs_up_svg\";\nimport ThumbsDownSvg from \"../svg-icons/thumbs_down_svg\";\nimport PencilSvg from \"../svg-icons/pencil_svg\";\nimport { downvoteButtonColor, SkipNoticeAction } from \"../utils/noticeUtils\";\nimport { generateUserID } from \"../../maze-utils/src/setup\";\nimport { keybindToString } from \"../../maze-utils/src/config\";\nimport { getFormattedTime } from \"../../maze-utils/src/formating\";\nimport { getCurrentTime, getVideo } from \"../../maze-utils/src/video\";\n\nenum SkipButtonState {\n    Undo, // Unskip\n    Redo, // Reskip\n    Start // Skip\n}\n\nexport interface SkipNoticeProps {\n    segments: SponsorTime[];\n\n    autoSkip: boolean;\n    startReskip?: boolean;\n    upcomingNotice?: boolean;\n    voteNotice?: boolean;\n    // Contains functions and variables from the content script needed by the skip notice\n    contentContainer: ContentContainer;\n\n    closeListener: () => void;\n    showKeybindHint?: boolean;\n    smaller: boolean;\n    fadeIn: boolean;\n    maxCountdownTime?: number;\n\n    componentDidMount?: () => void;\n\n    unskipTime?: number;\n}\n\nexport interface SkipNoticeState {\n    noticeTitle?: string;\n\n    messages?: string[];\n    messageOnClick?: (event: React.MouseEvent) => unknown;\n\n    countdownTime?: number;\n    maxCountdownTime?: () => number;\n    countdownText?: string;\n\n    skipButtonStates?: SkipButtonState[];\n    skipButtonCallbacks?: Array<(buttonIndex: number, index: number, forceSeek: boolean) => void>;\n    showSkipButton?: boolean[];\n\n    editing?: boolean;\n    choosingCategory?: boolean;\n    thanksForVotingText?: string; //null until the voting buttons should be hidden\n\n    actionState?: SkipNoticeAction;\n\n    showKeybindHint?: boolean;\n\n    smaller?: boolean;\n\n    voted?: SkipNoticeAction[];\n    copied?: SkipNoticeAction[];\n\n}\n\nclass SkipNoticeComponent extends React.Component<SkipNoticeProps, SkipNoticeState> {\n    segments: SponsorTime[];\n    autoSkip: boolean;\n    // Contains functions and variables from the content script needed by the skip notice\n    contentContainer: ContentContainer;\n\n    amountOfPreviousNotices: number;\n    showInSecondSlot: boolean;\n\n    idSuffix: string;\n\n    noticeRef: React.MutableRefObject<NoticeComponent>;\n    categoryOptionRef: React.RefObject<HTMLSelectElement>;\n\n    selectedColor: string;\n    unselectedColor: string;\n    lockedColor: string;\n\n    // Used to update on config change\n    configListener: () => void;\n\n    constructor(props: SkipNoticeProps) {\n        super(props);\n        this.noticeRef = React.createRef();\n        this.categoryOptionRef = React.createRef();\n\n        this.segments = props.segments;\n        this.autoSkip = props.autoSkip;\n        this.contentContainer = props.contentContainer;\n\n        const noticeTitle = this.props.voteNotice ? getVoteText(this.segments) : !this.props.upcomingNotice ? getSkippingText(this.segments, this.props.autoSkip) : getUpcomingText(this.segments);\n\n        const previousSkipNotices = document.querySelectorAll(\".sponsorSkipNoticeParent:not(.sponsorSkipUpcomingNotice)\");\n        this.amountOfPreviousNotices = previousSkipNotices.length;\n        // If there is at least one already in the first slot\n        this.showInSecondSlot = previousSkipNotices.length > 0 && [...previousSkipNotices].some(notice => !notice.classList.contains(\"secondSkipNotice\"));\n\n        // Sort segments\n        if (this.segments.length > 1) {\n            this.segments.sort((a, b) => a.segment[0] - b.segment[0]);\n        }\n\n        // This is the suffix added at the end of every id\n        for (const segment of this.segments) {\n            this.idSuffix += segment.UUID;\n        }\n        this.idSuffix += this.amountOfPreviousNotices;\n\n        this.selectedColor = Config.config.colorPalette.red;\n        this.unselectedColor = Config.config.colorPalette.white;\n        this.lockedColor = Config.config.colorPalette.locked;\n\n        const isMuteSegment = this.segments[0].actionType === ActionType.Mute;\n        const maxCountdownTime = props.maxCountdownTime ? () => props.maxCountdownTime : (isMuteSegment ? this.getFullDurationCountdown(0) : () => Config.config.skipNoticeDuration);\n\n        const defaultSkipButtonState = this.props.startReskip ? SkipButtonState.Redo : SkipButtonState.Undo;\n        const skipButtonStates = [defaultSkipButtonState, isMuteSegment ? SkipButtonState.Start : defaultSkipButtonState];\n\n        const defaultSkipButtonCallback = this.props.startReskip ? this.reskip.bind(this) : this.unskip.bind(this);\n        const skipButtonCallbacks = [defaultSkipButtonCallback, isMuteSegment ? this.reskip.bind(this) : defaultSkipButtonCallback];\n\n        // Setup state\n        this.state = {\n            noticeTitle,\n            messages: [],\n            messageOnClick: null,\n\n            //the countdown until this notice closes\n            maxCountdownTime,\n            countdownTime: maxCountdownTime(),\n            countdownText: null,\n\n            skipButtonStates,\n            skipButtonCallbacks,\n            showSkipButton: [true, true],\n\n            editing: false,\n            choosingCategory: false,\n            thanksForVotingText: null,\n\n            actionState: SkipNoticeAction.None,\n\n            showKeybindHint: this.props.showKeybindHint ?? true,\n\n            smaller: this.props.smaller ?? false,\n\n            // Keep track of what segment the user interacted with.\n            voted: new Array(this.props.segments.length).fill(SkipNoticeAction.None),\n            copied: new Array(this.props.segments.length).fill(SkipNoticeAction.None),\n        }\n\n        if (!this.autoSkip) {\n            // Assume manual skip is only skipping 1 submission\n            Object.assign(this.state, this.getUnskippedModeInfo(null, 0, SkipButtonState.Start));\n        }\n    }\n\n    render(): React.ReactElement {\n        const noticeStyle: React.CSSProperties = { }\n        if (this.contentContainer().onMobileYouTube) {\n            noticeStyle.bottom = \"4em\";\n            noticeStyle.transform = \"scale(0.8) translate(10%, 10%)\";\n        }\n\n        const firstColumn = this.getSkipButton(0);\n\n        return (\n            <NoticeComponent \n                noticeTitle={this.state.noticeTitle}\n                amountOfPreviousNotices={this.amountOfPreviousNotices}\n                showInSecondSlot={this.showInSecondSlot}\n                idSuffix={this.idSuffix}\n                fadeIn={this.props.fadeIn}\n                fadeOut={!this.props.upcomingNotice}\n                startFaded={Config.config.noticeVisibilityMode >= NoticeVisibilityMode.FadedForAll\n                    || (Config.config.noticeVisibilityMode >= NoticeVisibilityMode.FadedForAutoSkip && this.autoSkip)}\n                timed={true}\n                maxCountdownTime={this.state.maxCountdownTime}\n                style={noticeStyle}\n                biggerCloseButton={this.contentContainer().onMobileYouTube}\n                ref={this.noticeRef}\n                closeListener={() => this.closeListener()}\n                smaller={this.state.smaller}\n                logoFill={Config.config.barTypes[this.segments[0].category].color}\n                limitWidth={true}\n                firstColumn={firstColumn}\n                dontPauseCountdown={!!this.props.upcomingNotice}\n                bottomRow={[...this.getMessageBoxes(), ...this.getBottomRow() ]}\n                extraClass={this.props.upcomingNotice ? \"sponsorSkipUpcomingNotice\" : \"\"}\n                onMouseEnter={() => this.onMouseEnter() } >\n            </NoticeComponent>\n        );\n    }\n\n    componentDidMount(): void {\n        if (this.props.componentDidMount) {\n            this.props.componentDidMount();\n        }\n    }\n\n    getBottomRow(): JSX.Element[] {\n        return [\n            /* Bottom Row */\n            (<tr id={\"sponsorSkipNoticeSecondRow\" + this.idSuffix}\n                key={0}>\n\n                {/* Vote Button Container */}\n                {!this.state.thanksForVotingText ?\n                    <td id={\"sponsorTimesVoteButtonsContainer\" + this.idSuffix}\n                        className=\"sponsorTimesVoteButtonsContainer\">\n\n                        {/* Upvote Button */}\n                        <div id={\"sponsorTimesDownvoteButtonsContainerUpvote\" + this.idSuffix}\n                                className=\"voteButton\"\n                                style={{marginRight: \"5px\"}}\n                                title={chrome.i18n.getMessage(\"upvoteButtonInfo\")}\n                                onClick={() => this.prepAction(SkipNoticeAction.Upvote)}>\n                            <ThumbsUpSvg fill={(this.state.actionState === SkipNoticeAction.Upvote) ? this.selectedColor : this.unselectedColor} />\n                        </div>\n\n                        {/* Report Button */}\n                        <div id={\"sponsorTimesDownvoteButtonsContainerDownvote\" + this.idSuffix}\n                                className=\"voteButton\"\n                                style={{marginRight: \"5px\", marginLeft: \"5px\"}}\n                                title={chrome.i18n.getMessage(\"reportButtonInfo\")}\n                                onClick={() => this.prepAction(SkipNoticeAction.Downvote)}>\n                            <ThumbsDownSvg fill={downvoteButtonColor(this.segments, this.state.actionState, SkipNoticeAction.Downvote)} />\n                        </div>\n\n                        {/* Copy and Downvote Button */}\n                        {\n                            !this.props.voteNotice &&\n                            <div id={\"sponsorTimesDownvoteButtonsContainerCopyDownvote\" + this.idSuffix}\n                                    className=\"voteButton\"\n                                    style={{marginLeft: \"5px\"}}\n                                    onClick={() => this.openEditingOptions()}>\n                                <PencilSvg fill={this.state.editing === true\n                                                || this.state.actionState === SkipNoticeAction.CopyDownvote\n                                                || this.state.choosingCategory === true\n                                                ? this.selectedColor : this.unselectedColor} />\n                            </div>\n                        }\n                    </td>\n\n                    :\n\n                    <td id={\"sponsorTimesVoteButtonInfoMessage\" + this.idSuffix}\n                            className=\"sponsorTimesInfoMessage sponsorTimesVoteButtonMessage\"\n                            style={{marginRight: \"10px\"}}>\n\n                        {/* Submitted string */}\n                        <span style={{marginRight: \"10px\"}}>\n                            {this.state.thanksForVotingText}\n                        </span>\n\n                        {/* Continue Voting Button */}\n                        <button id={\"sponsorTimesContinueVotingContainer\" + this.idSuffix}\n                            className=\"sponsorSkipObject sponsorSkipNoticeButton\"\n                            title={\"Continue Voting\"}\n                            onClick={() => this.setState({\n                                thanksForVotingText: null,\n                                messages: []\n                            })}>\n                            {chrome.i18n.getMessage(\"ContinueVoting\")}\n                        </button>\n                    </td>\n                }\n\n                {/* Unskip/Skip Button */}\n                {!this.props.voteNotice && (!this.props.smaller || this.segments[0].actionType === ActionType.Mute)\n                    ? this.getSkipButton(1) : null}\n\n                {/* Never show button */}\n                {!this.autoSkip || this.props.startReskip || this.props.voteNotice ? \"\" :\n                    <td className=\"sponsorSkipNoticeRightSection\"\n                        key={1}>\n                        <button className=\"sponsorSkipObject sponsorSkipNoticeButton sponsorSkipNoticeRightButton\"\n                            onClick={this.contentContainer().dontShowNoticeAgain}>\n                            {chrome.i18n.getMessage(\"Hide\")}\n                        </button>\n                    </td>\n                }\n            </tr>),\n\n            /* Edit Segments Row */\n            (this.state.editing && !this.state.thanksForVotingText && !(this.state.choosingCategory || this.state.actionState === SkipNoticeAction.CopyDownvote) &&\n                <tr id={\"sponsorSkipNoticeEditSegmentsRow\" + this.idSuffix}\n                    key={2}>\n                    <td id={\"sponsorTimesEditSegmentsContainer\" + this.idSuffix}>\n\n                        {/* Copy Segment */}\n                        <button className=\"sponsorSkipObject sponsorSkipNoticeButton\"\n                                title={chrome.i18n.getMessage(\"CopyDownvoteButtonInfo\")}\n                                style={{color: downvoteButtonColor(this.segments, this.state.actionState, SkipNoticeAction.Downvote)}}\n                                onClick={() => this.prepAction(SkipNoticeAction.CopyDownvote)}>\n                            {chrome.i18n.getMessage(\"CopyAndDownvote\")}\n                        </button>\n\n                        {/* Category vote */}\n                        <button className=\"sponsorSkipObject sponsorSkipNoticeButton\"\n                                title={chrome.i18n.getMessage(\"ChangeCategoryTooltip\")}\n                                style={{color: (this.state.actionState === SkipNoticeAction.CategoryVote && this.state.editing == true) ? this.selectedColor : this.unselectedColor}}\n                                onClick={() => this.resetStateToStart(SkipNoticeAction.CategoryVote, true, true)}>\n                            {chrome.i18n.getMessage(\"incorrectCategory\")}\n                        </button>\n                    </td>\n                </tr>\n            ),\n\n            /* Category Chooser Row */\n            (this.state.choosingCategory && !this.state.thanksForVotingText &&\n                <tr id={\"sponsorSkipNoticeCategoryChooserRow\" + this.idSuffix}\n                    key={3}>\n                    <td>\n                        {/* Category Selector */}\n                        <select id={\"sponsorTimeCategories\" + this.idSuffix}\n                                className=\"sponsorTimeCategories sponsorTimeEditSelector\"\n                                defaultValue={this.segments[0].category}\n                                onMouseDown={(e) => e.stopPropagation()}\n                                ref={this.categoryOptionRef}>\n\n                            {this.getCategoryOptions()}\n                        </select>\n\n                        {/* Submit Button */}\n                        {this.segments.length === 1 &&\n                            <button className=\"sponsorSkipObject sponsorSkipNoticeButton\"\n                                    onClick={() => this.prepAction(SkipNoticeAction.CategoryVote)}>\n\n                                {chrome.i18n.getMessage(\"submit\")}\n                            </button>\n                        }\n                    </td>\n                </tr>\n            ),\n\n            /* Segment Chooser Row */\n            (this.state.actionState !== SkipNoticeAction.None && this.segments.length > 1 && !this.state.thanksForVotingText &&\n                <tr id={\"sponsorSkipNoticeSubmissionOptionsRow\" + this.idSuffix}\n                    key={4}>\n                    <td id={\"sponsorTimesSubmissionOptionsContainer\" + this.idSuffix}>\n                        {this.getSubmissionChooser()}\n                    </td>\n                </tr>\n            )\n        ];\n    }\n\n    getSkipButton(buttonIndex: number): JSX.Element {\n        if (this.state.showSkipButton[buttonIndex] && (this.segments.length > 1\n                || this.segments[0].actionType !== ActionType.Poi\n                || this.props.unskipTime)) {\n\n            const forceSeek = buttonIndex === 1 && this.segments[0].actionType === ActionType.Mute;\n\n            const style: React.CSSProperties = {\n                marginLeft: \"4px\",\n                color: ([SkipNoticeAction.Unskip0, SkipNoticeAction.Unskip1].includes(this.state.actionState))\n                    ? this.selectedColor : this.unselectedColor\n            };\n            if (this.contentContainer().onMobileYouTube) {\n                style.padding = \"20px\";\n                style.minWidth = \"100px\";\n            }\n\n            const showSkipButton = (buttonIndex !== 0 || this.props.smaller || !this.props.voteNotice || this.segments[0].actionType === ActionType.Mute) && !this.props.upcomingNotice;\n\n            return (\n                <span className=\"sponsorSkipNoticeUnskipSection\" style={{ visibility: !showSkipButton ? \"hidden\" : null }}>\n                    <button id={\"sponsorSkipUnskipButton\" + this.idSuffix}\n                            className=\"sponsorSkipObject sponsorSkipNoticeButton\"\n                            style={style}\n                            onClick={() => this.prepAction(buttonIndex === 1 ? SkipNoticeAction.Unskip1 : SkipNoticeAction.Unskip0)}>\n                        {this.getSkipButtonText(buttonIndex, forceSeek ? ActionType.Skip : null)\n                            + (!forceSeek && this.state.showKeybindHint\n                                ? \" (\" + keybindToString(Config.config.skipKeybind) + \")\" : \"\")}\n                    </button>\n                </span>\n            );\n        }\n        return null;\n    }\n\n    getSubmissionChooser(): JSX.Element[] {\n        const elements: JSX.Element[] = [];\n        for (let i = 0; i < this.segments.length; i++) {\n            elements.push(\n                <button className=\"sponsorSkipObject sponsorSkipNoticeButton\"\n                        style={{opacity: this.getSubmissionChooserOpacity(i),\n                                color: this.getSubmissionChooserColor(i)}}\n                        onClick={() => this.performAction(i)}\n                        autoFocus={i == 0}\n                        key={\"submission\" + i + this.segments[i].category + this.idSuffix}>\n                    {`${(i + 1)}. ${chrome.i18n.getMessage(\"category_\" + \n                        this.segments[i].category)} (${getFormattedTime(this.segments[i].segment[0])})`}\n                </button>\n            );\n        }\n        return elements;\n    }\n\n    getSubmissionChooserOpacity(index: number): number {\n        const isUpvote = this.state.actionState === SkipNoticeAction.Upvote;\n        const isDownvote = this.state.actionState == SkipNoticeAction.Downvote;\n        const isCopyDownvote = this.state.actionState == SkipNoticeAction.CopyDownvote;\n        const shouldBeGray: boolean = (isUpvote && this.state.voted[index] == SkipNoticeAction.Upvote) ||\n                                        (isDownvote && this.state.voted[index] == SkipNoticeAction.Downvote) ||\n                                        (isCopyDownvote && this.state.copied[index] == SkipNoticeAction.CopyDownvote);\n\n        return shouldBeGray ? 0.35 : 1;\n    }\n\n    getSubmissionChooserColor(index: number): string {\n        const isDownvote = this.state.actionState == SkipNoticeAction.Downvote;\n        const isCopyDownvote = this.state.actionState == SkipNoticeAction.CopyDownvote;\n        const shouldWarnUser = Config.config.isVip && (isDownvote || isCopyDownvote)\n                                        && this.segments[index].locked === 1;\n\n        return shouldWarnUser ? this.lockedColor : this.unselectedColor;\n    }\n\n    onMouseEnter(): void {\n        if (this.state.smaller && !this.props.upcomingNotice) {\n            this.setState({\n                smaller: false\n            });\n        }\n    }\n\n    getMessageBoxes(): JSX.Element[] {\n        if (this.state.messages.length === 0) {\n            // Add a spacer if there is no text\n            return [\n                <tr id={\"sponsorSkipNoticeSpacer\" + this.idSuffix}\n                    className=\"sponsorBlockSpacer\"\n                    key={\"messageBoxSpacer\"}>\n                </tr>\n            ];\n        }\n\n        const elements: JSX.Element[] = [];\n\n        for (let i = 0; i < this.state.messages.length; i++) {\n            elements.push(\n                <tr key={i + \"_messageBox\"}>\n                    <td key={i + \"_messageBox\"}>\n                        <NoticeTextSelectionComponent idSuffix={this.idSuffix}\n                            text={this.state.messages[i]}\n                            onClick={this.state.messageOnClick}\n                            key={i + \"_messageBox\"}>\n                        </NoticeTextSelectionComponent>\n                    </td>\n                </tr>\n            )\n        }\n\n        return elements;\n    }\n\n    prepAction(action: SkipNoticeAction): void {\n        if (this.segments.length === 1) {\n            this.performAction(0, action);\n        } else {\n            if (this.state.smaller) {\n                this.setState({\n                    smaller: false\n                });\n\n                this.noticeRef.current.fadedMouseEnter();\n                this.noticeRef.current.resetCountdown();\n            }\n\n            switch (action ?? this.state.actionState) {\n                case SkipNoticeAction.None:\n                    this.resetStateToStart();\n                    break;\n                case SkipNoticeAction.Upvote:\n                    this.resetStateToStart(SkipNoticeAction.Upvote);\n                    break;\n                case SkipNoticeAction.Downvote:\n                    this.resetStateToStart(SkipNoticeAction.Downvote);\n                    break;\n                case SkipNoticeAction.CategoryVote:\n                    this.resetStateToStart(SkipNoticeAction.CategoryVote, true, true);\n                    break;\n                case SkipNoticeAction.CopyDownvote:\n                    this.resetStateToStart(SkipNoticeAction.CopyDownvote, true);\n                    break;\n                case SkipNoticeAction.Unskip0:\n                    this.resetStateToStart(SkipNoticeAction.Unskip0);\n                    break;\n                case SkipNoticeAction.Unskip1:\n                    this.resetStateToStart(SkipNoticeAction.Unskip1);\n                    break;\n            }\n        }\n    }\n\n    /**\n     * Performs the action from the current state\n     *\n     * @param index\n     */\n    performAction(index: number, action?: SkipNoticeAction): void {\n        switch (action ?? this.state.actionState) {\n            case SkipNoticeAction.None:\n                this.noAction(index);\n                break;\n            case SkipNoticeAction.Upvote:\n                this.upvote(index);\n                break;\n            case SkipNoticeAction.Downvote:\n                this.downvote(index);\n                break;\n            case SkipNoticeAction.CategoryVote:\n                this.categoryVote(index);\n                break;\n            case SkipNoticeAction.CopyDownvote:\n                this.copyDownvote(index);\n                break;\n            case SkipNoticeAction.Unskip0:\n                this.unskipAction(0, index, false);\n                break;\n            case SkipNoticeAction.Unskip1:\n                this.unskipAction(1, index, true);\n                break;\n            default:\n                this.resetStateToStart();\n                break;\n        }\n    }\n\n    noAction(index: number): void {\n        const voted = this.state.voted;\n        voted[index] = SkipNoticeAction.None;\n\n        this.setState({\n            voted\n        });\n    }\n\n    upvote(index: number): void {\n        if (this.segments.length === 1) this.resetStateToStart();\n        this.contentContainer().vote(1, this.segments[index].UUID, undefined, this);\n    }\n\n    downvote(index: number): void {\n        if (this.segments.length === 1) this.resetStateToStart();\n\n        this.contentContainer().vote(0, this.segments[index].UUID, undefined, this);\n    }\n\n    categoryVote(index: number): void {\n        this.contentContainer().vote(undefined, this.segments[index].UUID, this.categoryOptionRef.current.value as Category, this)\n    }\n\n    copyDownvote(index: number): void {\n        const sponsorVideoID = this.props.contentContainer().sponsorVideoID;\n        const sponsorTimesSubmitting : SponsorTime = {\n            segment: this.segments[index].segment,\n            UUID: generateUserID() as SegmentUUID,\n            category: this.segments[index].category,\n            actionType: this.segments[index].actionType,\n            source: SponsorSourceType.Local\n        };\n\n        const segmentTimes = Config.local.unsubmittedSegments[sponsorVideoID] || [];\n        segmentTimes.push(sponsorTimesSubmitting);\n        Config.local.unsubmittedSegments[sponsorVideoID] = segmentTimes;\n        Config.forceLocalUpdate(\"unsubmittedSegments\");\n\n        this.props.contentContainer().sponsorTimesSubmitting.push(sponsorTimesSubmitting);\n        this.props.contentContainer().updatePreviewBar();\n        this.props.contentContainer().resetSponsorSubmissionNotice();\n        this.props.contentContainer().updateEditButtonsOnPlayer();\n\n        this.contentContainer().vote(0, this.segments[index].UUID, undefined, this);\n\n        const copied = this.state.copied;\n        copied[index] = SkipNoticeAction.CopyDownvote;\n\n        this.setState({\n            copied\n        });\n    }\n\n    unskipAction(buttonIndex: number, index: number, forceSeek: boolean): void {\n        this.state.skipButtonCallbacks[buttonIndex](buttonIndex, index, forceSeek);\n    }\n\n    openEditingOptions(): void {\n        this.resetStateToStart(undefined, true);\n    }\n\n    getCategoryOptions(): React.ReactElement[] {\n        const elements = [];\n\n        const categories = (CompileConfig.categoryList.filter((cat => CompileConfig.categorySupport[cat].includes(ActionType.Skip)))) as Category[];\n        for (const category of categories) {\n            elements.push(\n                <option value={category}\n                        key={category}\n                        className={this.getCategoryNameClass(category)}>\n                    {chrome.i18n.getMessage(\"category_\" + category)}\n                </option>\n            );\n        }\n        return elements;\n    }\n\n    getCategoryNameClass(category: string): string {\n        return this.props.contentContainer().lockedCategories.includes(category) ? \"sponsorBlockLockedColor\" : \"\"\n    }\n\n    unskip(buttonIndex: number, index: number, forceSeek: boolean): void {\n        this.contentContainer().unskipSponsorTime(this.segments[index], this.props.unskipTime, forceSeek, this.props.voteNotice);\n\n        this.unskippedMode(buttonIndex, index, this.segments[0].actionType === ActionType.Poi ? SkipButtonState.Undo : SkipButtonState.Redo);\n    }\n\n    reskip(buttonIndex: number, index: number, forceSeek: boolean): void {\n        this.contentContainer().reskipSponsorTime(this.segments[index], forceSeek);\n        this.reskippedMode(buttonIndex);\n    }\n\n    reskippedMode(buttonIndex: number): void {\n        const skipButtonStates = this.state.skipButtonStates;\n        skipButtonStates[buttonIndex] = SkipButtonState.Undo;\n\n        const skipButtonCallbacks = this.state.skipButtonCallbacks;\n        skipButtonCallbacks[buttonIndex] = this.unskip.bind(this);\n\n        const newState: SkipNoticeState = {\n            skipButtonStates,\n            skipButtonCallbacks,\n\n            maxCountdownTime: () => Config.config.skipNoticeDuration,\n            countdownTime: Config.config.skipNoticeDuration\n        };\n\n        //reset countdown\n        this.setState(newState, () => {\n            this.noticeRef.current.resetCountdown();\n        });\n    }\n\n    /** Sets up notice to be not skipped yet */\n    unskippedMode(buttonIndex: number, index: number, skipButtonState: SkipButtonState): void {\n        //setup new callback and reset countdown\n        this.setState(this.getUnskippedModeInfo(buttonIndex, index, skipButtonState), () => {\n            this.noticeRef.current.resetCountdown();\n        });\n    }\n\n    getUnskippedModeInfo(buttonIndex: number, index: number, skipButtonState: SkipButtonState): SkipNoticeState {\n        const changeCountdown = !this.props.voteNotice && this.segments[index].actionType !== ActionType.Poi;\n\n        const maxCountdownTime = changeCountdown ?\n            this.getFullDurationCountdown(index) : this.state.maxCountdownTime;\n\n        const skipButtonStates = this.state.skipButtonStates;\n        const skipButtonCallbacks = this.state.skipButtonCallbacks;\n        if (buttonIndex === null) {\n            for (let i = 0; i < skipButtonStates.length; i++) {\n                skipButtonStates[i] = skipButtonState;\n                skipButtonCallbacks[i] = this.reskip.bind(this);\n            }\n        } else {\n            skipButtonStates[buttonIndex] = skipButtonState;\n            skipButtonCallbacks[buttonIndex] = this.reskip.bind(this);\n\n            if (buttonIndex === 1) {\n                // Trigger both to move at once\n                skipButtonStates[0] = SkipButtonState.Redo;\n                skipButtonCallbacks[0] = this.reskip.bind(this);\n            }\n        }\n\n        return {\n            skipButtonStates,\n            skipButtonCallbacks,\n            // change max duration to however much of the sponsor is left\n            maxCountdownTime,\n            countdownTime: maxCountdownTime(),\n            showSkipButton: buttonIndex === 1 ? [true, true] : this.state.showSkipButton\n        } as SkipNoticeState;\n    }\n\n    getFullDurationCountdown(index: number): () => number {\n        return () => {\n            const sponsorTime = this.segments[index];\n            const duration = Math.round((sponsorTime.segment[1] - (getCurrentTime() ?? 0)) * (1 / (getVideo()?.playbackRate ?? 1)));\n\n            return Math.max(duration, Config.config.skipNoticeDuration);\n        };\n    }\n\n    afterVote(segment: SponsorTime, type: number, category: Category): void {\n        const index = utils.getSponsorIndexFromUUID(this.segments, segment.UUID);\n        const wikiLinkText = CompileConfig.wikiLinks[segment.category];\n\n        const voted = this.state.voted;\n        switch (type) {\n            case 0:\n                this.clearConfigListener();\n                this.setNoticeInfoMessageWithOnClick(() => window.open(wikiLinkText), chrome.i18n.getMessage(\"OpenCategoryWikiPage\"));\n\n                voted[index] = SkipNoticeAction.Downvote;\n                break;\n            case 1:\n                voted[index] = SkipNoticeAction.Upvote;\n                break;\n            case 20:\n                voted[index] = SkipNoticeAction.None;\n                break;\n        }\n\n        this.setState({\n            voted\n        });\n\n        this.addVoteButtonInfo(chrome.i18n.getMessage(\"voted\"));\n\n        if (segment && category) {\n            // This is the segment inside the skip notice\n            this.segments[index].category = category;\n        }\n    }\n\n    setNoticeInfoMessageWithOnClick(onClick: (event: React.MouseEvent) => unknown, ...messages: string[]): void {\n        this.setState({\n            messages,\n            messageOnClick: (event) => onClick(event)\n        });\n    }\n\n    setNoticeInfoMessage(...messages: string[]): void {\n        this.setState({\n            messages\n        });\n    }\n\n    addVoteButtonInfo(message: string): void {\n        this.setState({\n            thanksForVotingText: message\n        });\n    }\n\n    resetVoteButtonInfo(): void {\n        this.setState({\n            thanksForVotingText: null\n        });\n    }\n\n    closeListener(): void {\n        this.clearConfigListener();\n\n        this.props.closeListener();\n    }\n\n    clearConfigListener(): void {\n        if (this.configListener) {\n            Config.configSyncListeners.splice(Config.configSyncListeners.indexOf(this.configListener), 1);\n            this.configListener = null;\n        }\n    }\n\n    unmutedListener(time: number): void {\n        if (this.props.segments.length === 1\n                && this.props.segments[0].actionType === ActionType.Mute\n                && time >= this.props.segments[0].segment[1]) {\n            this.setState({\n                showSkipButton: [false, true]\n            });\n        }\n    }\n\n    resetStateToStart(actionState: SkipNoticeAction = SkipNoticeAction.None, editing = false, choosingCategory = false): void {\n        this.setState({\n            actionState: actionState,\n            editing: editing,\n            choosingCategory: choosingCategory,\n            thanksForVotingText: null,\n            messages: []\n        });\n    }\n\n    private getSkipButtonText(buttonIndex: number, forceType?: ActionType): string {\n        switch (this.state.skipButtonStates[buttonIndex]) {\n            case SkipButtonState.Undo:\n                return this.getUndoText(forceType);\n            case SkipButtonState.Redo:\n                return this.getRedoText(forceType);\n            case SkipButtonState.Start:\n                return this.getStartText(forceType);\n        }\n    }\n\n    private getUndoText(forceType?: ActionType): string {\n        const actionType = forceType || this.segments[0].actionType;\n        switch (actionType) {\n            case ActionType.Mute: {\n                return chrome.i18n.getMessage(\"unmute\");\n            }\n            case ActionType.Skip:\n            default: {\n                return chrome.i18n.getMessage(\"unskip\");\n            }\n        }\n    }\n\n    private getRedoText(forceType?: ActionType): string {\n        const actionType = forceType || this.segments[0].actionType;\n        switch (actionType) {\n            case ActionType.Mute: {\n                return chrome.i18n.getMessage(\"mute\");\n            }\n            case ActionType.Skip:\n            default: {\n                return chrome.i18n.getMessage(\"reskip\");\n            }\n        }\n    }\n\n    private getStartText(forceType?: ActionType): string {\n        const actionType = forceType || this.segments[0].actionType;\n        switch (actionType) {\n            case ActionType.Mute: {\n                return chrome.i18n.getMessage(\"mute\");\n            }\n            case ActionType.Skip:\n            default: {\n                return chrome.i18n.getMessage(\"skip\");\n            }\n        }\n    }\n}\n\nexport default SkipNoticeComponent;\n"
  },
  {
    "path": "src/components/SponsorTimeEditComponent.tsx",
    "content": "import * as React from \"react\";\nimport * as CompileConfig from \"../../config.json\";\nimport Config from \"../config\";\nimport { ActionType, Category, ChannelIDStatus, ContentContainer, SponsorHideType, SponsorTime } from \"../types\";\nimport SubmissionNoticeComponent from \"./SubmissionNoticeComponent\";\nimport { RectangleTooltip } from \"../render/RectangleTooltip\";\nimport SelectorComponent, { SelectorOption } from \"./SelectorComponent\";\nimport { DEFAULT_CATEGORY } from \"../utils/categoryUtils\";\nimport { getFormattedTime, getFormattedTimeToSeconds } from \"../../maze-utils/src/formating\";\nimport { asyncRequestToServer } from \"../utils/requests\";\nimport { defaultPreviewTime } from \"../utils/constants\";\nimport { getVideo, getVideoDuration } from \"../../maze-utils/src/video\";\nimport { AnimationUtils } from \"../../maze-utils/src/animationUtils\";\nimport { Tooltip } from \"../render/Tooltip\";\nimport { logRequest } from \"../../maze-utils/src/background-request-proxy\";\n\nexport interface SponsorTimeEditProps {\n    index: number;\n\n    idSuffix: string;\n    // Contains functions and variables from the content script needed by the skip notice\n    contentContainer: ContentContainer;\n\n    submissionNotice: SubmissionNoticeComponent;\n    categoryList?: Category[];\n    categoryChangeListener?: (index: number, category: Category) => void;\n    children?: React.ReactNode;\n}\n\nexport interface SponsorTimeEditState {\n    editing: boolean;\n    sponsorTimeEdits: [string, string];\n    selectedCategory: Category;\n    selectedActionType: ActionType;\n    description: string;\n    suggestedNames: SelectorOption[];\n    chapterNameSelectorOpen: boolean;\n    chapterNameSelectorHovering: boolean;\n}\n\nconst categoryNamesGrams: string[] = [].concat(...CompileConfig.categoryList.filter((name) => ![\"chapter\", \"intro\"].includes(name))\n    .map((name) => chrome.i18n.getMessage(\"category_\" + name).split(/\\/|\\s|-/)));\n\nclass SponsorTimeEditComponent extends React.Component<SponsorTimeEditProps, SponsorTimeEditState> {\n\n    idSuffix: string;\n\n    categoryOptionRef: React.RefObject<HTMLSelectElement>;\n    actionTypeOptionRef: React.RefObject<HTMLSelectElement>;\n    descriptionOptionRef: React.RefObject<HTMLInputElement>;\n\n    configUpdateListener: () => void;\n\n    previousSkipType: ActionType;\n    // Used when selecting POI or Full\n    timesBeforeChanging: number[] = [];\n    fullVideoWarningShown = false;\n    categoryNameWarningShown = false;\n\n    // For description auto-complete\n    fetchingSuggestions: boolean;\n\n    constructor(props: SponsorTimeEditProps) {\n        super(props);\n\n        this.categoryOptionRef = React.createRef();\n        this.actionTypeOptionRef = React.createRef();\n        this.descriptionOptionRef = React.createRef();\n\n        this.idSuffix = this.props.idSuffix;\n        this.previousSkipType = ActionType.Skip;\n\n        const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index];\n        this.state = {\n            editing: false,\n            sponsorTimeEdits: [null, null],\n            selectedCategory: sponsorTime.category ?? DEFAULT_CATEGORY as Category,\n            selectedActionType: sponsorTime.actionType,\n            description: sponsorTime.description || \"\",\n            suggestedNames: [],\n            chapterNameSelectorOpen: false,\n            chapterNameSelectorHovering: false\n        };\n    }\n\n    componentDidMount(): void {\n        // Prevent inputs from triggering key events\n        document.getElementById(\"sponsorTimeEditContainer\" + this.idSuffix).addEventListener('keydown', (e) => {\n            e.stopPropagation();\n        });\n\n        // Prevent scrolling while changing times\n        document.getElementById(\"sponsorTimesContainer\" + this.idSuffix).addEventListener('wheel', (e) => {\n            if (this.state.editing) {\n                e.preventDefault();\n            }\n        }, {passive: false});\n\n        // Add as a config listener\n        if (!this.configUpdateListener) {\n            this.configUpdateListener = () => this.configUpdate();\n            Config.configSyncListeners.push(this.configUpdate.bind(this));\n        }\n\n        this.checkToShowFullVideoWarning();\n    }\n\n    componentWillUnmount(): void {\n        if (this.configUpdateListener) {\n            Config.configSyncListeners.splice(Config.configSyncListeners.indexOf(this.configUpdate.bind(this)), 1);\n        }\n    }\n\n    render(): React.ReactElement {\n        this.checkToShowFullVideoWarning();\n        this.checkToShowChapterWarning();\n\n        const style: React.CSSProperties = {\n            textAlign: \"center\"\n        };\n\n        if (this.props.index != 0) {\n            style.marginTop = \"15px\";\n        }\n\n        const borderColor = this.state.selectedCategory ? Config.config.barTypes[this.state.selectedCategory]?.color : null;\n\n        // Create time display\n        let timeDisplay: JSX.Element;\n        const timeDisplayStyle: React.CSSProperties = {};\n        const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index];\n        const segment = sponsorTime.segment;\n        if (this.state.selectedActionType === ActionType.Full) timeDisplayStyle.display = \"none\";\n        if (this.state.editing) {\n            timeDisplay = (\n                <div id={\"sponsorTimesContainer\" + this.idSuffix}\n                    style={timeDisplayStyle}\n                    className=\"sponsorTimeDisplay\">\n\n                        {this.state.selectedActionType !== ActionType.Poi ? (\n                            <span id={\"startButton\" + this.idSuffix}\n                                className=\"sponsorNowButton\"\n                                onClick={() => this.setTimeTo(0, 0)}>\n                                    {chrome.i18n.getMessage(\"bracketStart\")}\n                            </span>\n                        ): \"\"}\n\n                        <span id={\"nowButton0\" + this.idSuffix}\n                            className=\"sponsorNowButton\"\n                            onClick={() => this.setTimeToNow(0)}>\n                                {chrome.i18n.getMessage(\"bracketNow\")}\n                        </span>\n                        <input id={\"submittingTime0\" + this.idSuffix}\n                            className=\"sponsorTimeEdit sponsorTimeEditInput\"\n                            type=\"text\"\n                            style={{color: \"inherit\", backgroundColor: \"inherit\", borderColor}}\n                            value={this.state.sponsorTimeEdits[0] ?? \"\"}\n                            onKeyDown={(e) => e.stopPropagation()}\n                            onKeyUp={(e) => e.stopPropagation()}\n                            onChange={(e) => this.handleOnChange(0, e, sponsorTime, e.target.value)}\n                            onWheel={(e) => this.changeTimesWhenScrolling(0, e, sponsorTime)}>\n                        </input>\n\n                        {this.state.selectedActionType !== ActionType.Poi ? (\n                            <span>\n                                <span>\n                                    {\" \" + chrome.i18n.getMessage(\"to\") + \" \"}\n                                </span>\n\n                                <input id={\"submittingTime1\" + this.idSuffix}\n                                    className=\"sponsorTimeEdit sponsorTimeEditInput\"\n                                    type=\"text\"\n                                    style={{color: \"inherit\", backgroundColor: \"inherit\", borderColor}}\n                                    value={this.state.sponsorTimeEdits[1] ?? \"\"}\n                                    onKeyDown={(e) => e.stopPropagation()}\n                                    onKeyUp={(e) => e.stopPropagation()}\n                                    onChange={(e) => this.handleOnChange(1, e, sponsorTime, e.target.value)}\n                                    onWheel={(e) => this.changeTimesWhenScrolling(1, e, sponsorTime)}>\n                                </input>\n\n                                <span id={\"nowButton1\" + this.idSuffix}\n                                    className=\"sponsorNowButton\"\n                                    onClick={() => this.setTimeToNow(1)}>\n                                        {chrome.i18n.getMessage(\"bracketNow\")}\n                                </span>\n\n                                <span id={\"endButton\" + this.idSuffix}\n                                    className=\"sponsorNowButton\"\n                                    onClick={() => this.setTimeToEnd()}>\n                                        {chrome.i18n.getMessage(\"bracketEnd\")}\n                                </span>\n                            </span>\n                        ): \"\"}\n                </div>\n            );\n        } else {\n            timeDisplay = (\n                \n                <div id={\"sponsorTimesContainer\" + this.idSuffix}\n                    style={timeDisplayStyle}\n                    className=\"sponsorTimeDisplay\"\n                    onClick={this.toggleEditTime.bind(this)}>\n                        {getFormattedTime(segment[0], true) +\n                            ((!isNaN(segment[1]) && this.state.selectedActionType !== ActionType.Poi)\n                                ? \" \" + chrome.i18n.getMessage(\"to\") + \" \" + getFormattedTime(segment[1], true) : \"\")}\n                </div>\n            );\n        }\n\n        return (\n            <div id={\"sponsorTimeEditContainer\" + this.idSuffix} style={style}>\n                \n                {timeDisplay}\n\n                {/* Category */}\n                <div style={{position: \"relative\"}}>\n                    <select id={\"sponsorTimeCategories\" + this.idSuffix}\n                        className=\"sponsorTimeEditSelector sponsorTimeCategories\"\n                        ref={this.categoryOptionRef}\n                        style={{color: \"inherit\", backgroundColor: \"inherit\", borderColor}}\n                        value={this.state.selectedCategory}\n                        onChange={(event) => this.categorySelectionChange(event)}>\n                        {this.getCategoryOptions()}\n                    </select>\n\n                    {/* open in new tab */}\n                    <a href={CompileConfig.wikiLinks[sponsorTime.category] \n                            || \"https://wiki.sponsor.ajay.app/index.php/Segment_Categories\"}\n                        target=\"_blank\" rel=\"noreferrer\">\n                        <img id={\"sponsorTimeCategoriesHelpButton\" + this.idSuffix}\n                            className=\"helpButton\"\n                            src={chrome.runtime.getURL(\"icons/help.svg\")}\n                            title={chrome.i18n.getMessage(\"categoryGuidelines\")} />\n                    </a>\n                </div>\n\n                {/* Action Type */}\n                {CompileConfig.categorySupport[sponsorTime.category] && \n                    (CompileConfig.categorySupport[sponsorTime.category]?.length > 1 \n                        || CompileConfig.categorySupport[sponsorTime.category]?.[0] === ActionType.Full) ? (\n                    <div style={{position: \"relative\"}}>\n                        <select id={\"sponsorTimeActionTypes\" + this.idSuffix}\n                            className=\"sponsorTimeEditSelector sponsorTimeActionTypes\"\n                            value={this.state.selectedActionType}\n                            style={{color: \"inherit\", backgroundColor: \"inherit\", borderColor}}\n                            ref={this.actionTypeOptionRef}\n                            onChange={(e) => this.actionTypeSelectionChange(e)}>\n                            {this.getActionTypeOptions(sponsorTime)}\n                        </select>\n                        <img\n                            className=\"voteButton hideSegmentSubmitButton\"\n                            title={chrome.i18n.getMessage(\"hideSegment\")}\n                            src={sponsorTime.hidden === SponsorHideType.Hidden ? chrome.runtime.getURL(\"icons/not_visible.svg\") : chrome.runtime.getURL(\"icons/visible.svg\")}\n                            onClick={(e) => {\n                                const stopAnimation = AnimationUtils.applyLoadingAnimation(e.currentTarget, 0.4);\n                                stopAnimation();\n    \n                                if (sponsorTime.hidden === SponsorHideType.Hidden) {\n                                    sponsorTime.hidden = SponsorHideType.Visible;\n                                } else {\n                                    sponsorTime.hidden = SponsorHideType.Hidden;\n                                }\n\n                                this.saveEditTimes();\n                        }}/>\n                    </div>\n                ): \"\"}\n\n                {/* Chapter Name */}\n                {this.state.selectedActionType=== ActionType.Chapter ? (\n                    <div onBlur={() => this.setState({chapterNameSelectorOpen: false})}>\n                        <input id={\"chapterName\" + this.idSuffix}\n                            className=\"sponsorTimeEdit sponsorTimeEditInput sponsorChapterNameInput\"\n                            style={{color: \"inherit\", backgroundColor: \"inherit\", borderColor}}\n                            ref={this.descriptionOptionRef}\n                            type=\"text\"\n                            value={this.state.description}\n                            onKeyDown={(e) => e.stopPropagation()}\n                            onKeyUp={(e) => e.stopPropagation()}\n                            onContextMenu={(e) => e.stopPropagation()}\n                            onChange={(e) => this.descriptionUpdate(e.target.value)}\n                            onFocus={() => this.setState({chapterNameSelectorOpen: true})}>\n                        </input>\n                        {this.state.description \n                            && (this.state.chapterNameSelectorOpen || this.state.chapterNameSelectorHovering) &&\n                            <SelectorComponent\n                                id={\"chapterNameSelector\" + this.idSuffix}\n                                options={this.state.suggestedNames}\n                                onMouseEnter={() => this.setState({chapterNameSelectorHovering: true})}\n                                onMouseLeave={() => this.setState({chapterNameSelectorHovering: false})}\n                                onChange={(v) => this.descriptionUpdate(v)}\n                            />\n                        }\n                    </div>\n                ): \"\"}\n\n                {/* Editing Tools */}\n\n                <div style={{ marginTop: \"3px\" }}>\n                    <span id={\"sponsorTimeDeleteButton\" + this.idSuffix}\n                        className=\"sponsorTimeEditButton\"\n                        onClick={this.deleteTime.bind(this)}>\n                        {chrome.i18n.getMessage(\"delete\")}\n                    </span>\n\n                    {(!isNaN(segment[1]) && ![ActionType.Poi, ActionType.Full].includes(this.state.selectedActionType)) \n                        && this.state.selectedActionType !== ActionType.Chapter ? (\n                        <span id={\"sponsorTimePreviewButton\" + this.idSuffix}\n                            className=\"sponsorTimeEditButton\"\n                            onClick={(e) => this.previewTime(e.ctrlKey, e.shiftKey)}>\n                            {chrome.i18n.getMessage(\"preview\")}\n                        </span>\n                    ): \"\"}\n\n                    {(!isNaN(segment[0]) && this.state.selectedActionType != ActionType.Full) ? (\n                        <span id={\"sponsorTimeInspectButton\" + this.idSuffix}\n                            className=\"sponsorTimeEditButton\"\n                            onClick={this.inspectTime.bind(this)}>\n                            {chrome.i18n.getMessage(\"inspect\")}\n                        </span>\n                    ): \"\"}\n\n                    {(!isNaN(segment[1]) && ![ActionType.Poi, ActionType.Full].includes(this.state.selectedActionType)) ? (\n                        <span id={\"sponsorTimePreviewEndButton\" + this.idSuffix}\n                            className=\"sponsorTimeEditButton\"\n                            onClick={(e) => this.previewTime(e.ctrlKey, e.shiftKey, true)}>\n                            {chrome.i18n.getMessage(\"End\")}\n                        </span>\n                    ): \"\"}\n\n                    {(!isNaN(segment[1]) && this.state.selectedActionType != ActionType.Full) ? (\n                        <span id={\"sponsorTimeEditButton\" + this.idSuffix}\n                            className=\"sponsorTimeEditButton\"\n                            onClick={this.toggleEditTime.bind(this)}>\n                            {this.state.editing ? chrome.i18n.getMessage(\"save\") : chrome.i18n.getMessage(\"edit\")}\n                        </span>\n                    ): \"\"}\n                </div>\n\n            </div>\n        );\n    }\n\n    handleOnChange(index: number, e: React.ChangeEvent, sponsorTime: SponsorTime, targetValue: string): void {\n        const sponsorTimeEdits = this.state.sponsorTimeEdits;\n        \n        // check if change is small engough to show tooltip\n        const before = getFormattedTimeToSeconds(sponsorTimeEdits[index]);\n        const after = getFormattedTimeToSeconds(targetValue);\n        const difference = Math.abs(before - after);\n        if (0 < difference && difference < 0.5) this.showScrollToEditToolTip();\n\n        sponsorTimeEdits[index] = targetValue;\n        if (index === 0 && sponsorTime.actionType === ActionType.Poi) sponsorTimeEdits[1] = targetValue;\n\n        this.setState({sponsorTimeEdits}, () => this.saveEditTimes());\n    }\n\n    changeTimesWhenScrolling(index: number, e: React.WheelEvent, sponsorTime: SponsorTime): void {\n        if (!Config.config.allowScrollingToEdit) return;\n        let step = 0;\n        // shift + ctrl = 1\n        // ctrl = 0.1\n        // default = 0.01\n        // shift = 0.001\n        if (e.shiftKey) {\n            step = (e.ctrlKey) ? 1 : 0.001;\n        } else {\n            step = (e.ctrlKey) ? 0.1 : 0.01;\n        }\n        \n        const sponsorTimeEdits = this.state.sponsorTimeEdits;\n        let timeAsNumber = getFormattedTimeToSeconds(this.state.sponsorTimeEdits[index]);\n        if (timeAsNumber !== null && e.deltaY != 0) {\n            if (e.deltaY < 0) {\n                timeAsNumber += step;\n            } else if (timeAsNumber >= step) {\n                timeAsNumber -= step;\n            } else {\n                timeAsNumber = 0;\n            }\n            \n            sponsorTimeEdits[index] = getFormattedTime(timeAsNumber, true);\n            if (sponsorTime.actionType === ActionType.Poi) sponsorTimeEdits[1] = sponsorTimeEdits[0];\n\n            this.setState({sponsorTimeEdits});\n            this.saveEditTimes();\n        }\n    }\n\n    showScrollToEditToolTip(): void {\n        if (!Config.config.scrollToEditTimeUpdate && document.getElementById(\"sponsorRectangleTooltip\" + \"sponsorTimesContainer\" + this.idSuffix) === null) {\n            this.showToolTip(chrome.i18n.getMessage(\"SponsorTimeEditScrollNewFeature\"), \"scrollToEdit\", () => { Config.config.scrollToEditTimeUpdate = true });\n        }\n    }\n\n    showToolTip(text: string, id: string, buttonFunction?: () => void): boolean {\n        const element = document.getElementById(\"sponsorTimesContainer\" + this.idSuffix);\n        if (element) {\n            const htmlId = `sponsorRectangleTooltip${id + this.idSuffix}`;\n            if (!document.getElementById(htmlId)) {\n                new RectangleTooltip({\n                    text,\n                    referenceNode: element.parentElement,\n                    prependElement: element,\n                    timeout: 15,\n                    bottomOffset: 0 + \"px\",\n                    leftOffset: -318 + \"px\",\n                    backgroundColor: \"rgba(28, 28, 28, 1.0)\",\n                    htmlId,\n                    buttonFunction,\n                    fontSize: \"14px\",\n                    maxHeight: \"200px\"\n                });\n            }\n\n            return true;\n        } else {\n            return false;\n        }\n    }\n\n    checkToShowFullVideoWarning(): void {\n        const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index];\n        const segmentDuration = sponsorTime.segment[1] - sponsorTime.segment[0];\n        const videoPercentage = segmentDuration / getVideoDuration();\n\n        if (videoPercentage > 0.6 && !this.fullVideoWarningShown \n                && (sponsorTime.category === \"sponsor\" || sponsorTime.category === \"selfpromo\" || sponsorTime.category === \"chooseACategory\")) {\n            if (this.showToolTip(chrome.i18n.getMessage(\"fullVideoTooltipWarning\"), \"fullVideoWarning\")) {\n                this.fullVideoWarningShown = true;\n            }\n        }\n    }\n\n    checkToShowChapterWarning(): void {\n        const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index];\n\n        if (sponsorTime.actionType === ActionType.Chapter && sponsorTime.description\n                && !this.categoryNameWarningShown\n                && categoryNamesGrams.some(\n                    (category) => sponsorTime.description.toLowerCase().includes(category.toLowerCase()))) {\n            if (this.showToolTip(chrome.i18n.getMessage(\"chapterNameTooltipWarning\"), \"chapterWarning\")) {\n                this.categoryNameWarningShown = true;\n            }\n        }\n    }\n\n    getCategoryOptions(): React.ReactElement[] {\n        const elements = [(\n            <option value={DEFAULT_CATEGORY}\n                    key={DEFAULT_CATEGORY}>\n                {chrome.i18n.getMessage(DEFAULT_CATEGORY)}\n            </option>\n        )];\n\n        for (const category of (this.props.categoryList ?? CompileConfig.categoryList)) {\n            // If permission not loaded, treat it like we have permission except chapter\n            const defaultBlockCategories = [\"chapter\"];\n            const permission = (Config.config.showCategoryWithoutPermission\n                || Config.config.permissions[category as Category]);\n            if ((defaultBlockCategories.includes(category) \n                || (permission !== undefined && !Config.config.showCategoryWithoutPermission)) && !permission) continue;\n\n            elements.push(\n                <option value={category}\n                        key={category}\n                        className={this.getCategoryLockedClass(category)}>\n                    {chrome.i18n.getMessage(\"category_\" + category)}\n                </option>\n            );\n        }\n\n        return elements;\n    }\n\n    getCategoryLockedClass(category: string): string {\n        return this.props.contentContainer().lockedCategories.includes(category) ? \"sponsorBlockLockedColor\" : \"\";\n    }\n\n    categorySelectionChange(event: React.ChangeEvent<HTMLSelectElement>): void {\n        const chosenCategory = event.target.value as Category;\n        this.setState({\n            selectedCategory: chosenCategory\n        });\n\n        // See if show more categories was pressed\n        if (chosenCategory !== DEFAULT_CATEGORY && !Config.config.categorySelections.some((category) => category.name === chosenCategory)) {\n            event.target.value = DEFAULT_CATEGORY;\n            \n            // Alert that they have to enable this category first\n            if (confirm(chrome.i18n.getMessage(\"enableThisCategoryFirst\")\n                            .replace(\"{0}\", chrome.i18n.getMessage(\"category_\" + chosenCategory)))) {\n                // Open options page\n                chrome.runtime.sendMessage({message: \"openConfig\", hash: \"behavior\"});\n            }\n            \n            return;\n        }\n\n        // Hook update\n        if (!Config.config.hookUpdate && chosenCategory === \"preview\") {\n            Config.config.hookUpdate = true;\n\n            const target = event.target.closest(\".sponsorSkipNotice tbody\");\n            if (target) {\n                new Tooltip({\n                    text: chrome.i18n.getMessage(\"hookNewFeature\"),\n                    referenceNode: target.parentElement,\n                    prependElement: target as HTMLElement,\n                    bottomOffset: \"30px\",\n                    opacity: 0.9,\n                    timeout: 100\n                });\n            }\n        }\n\n        const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index];\n        this.handleReplacingLostTimes(chosenCategory, sponsorTime.actionType, sponsorTime);\n        this.saveEditTimes();\n\n        if (this.props.categoryChangeListener) {\n            this.props.categoryChangeListener(this.props.index, chosenCategory);\n        }\n    }\n\n    actionTypeSelectionChange(event: React.ChangeEvent<HTMLSelectElement>): void {\n        const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index];\n\n        this.setState({\n            selectedActionType: event.target.value as ActionType\n        });\n\n        this.handleReplacingLostTimes(sponsorTime.category, event.target.value as ActionType, sponsorTime);\n        this.saveEditTimes();\n    }\n\n    private handleReplacingLostTimes(category: Category, actionType: ActionType, segment: SponsorTime): void {\n        if (CompileConfig.categorySupport[category]?.includes(ActionType.Poi)) {\n            if (this.previousSkipType !== ActionType.Poi) {\n                this.timesBeforeChanging = [null, segment.segment[1]];\n            }\n\n            this.setTimeTo(1, null);\n            this.props.contentContainer().updateEditButtonsOnPlayer();\n\n            if (this.props.contentContainer().sponsorTimesSubmitting\n                    .some((segment, i) => segment.category === category && i !== this.props.index)) {\n                alert(chrome.i18n.getMessage(\"poiOnlyOneSegment\"));\n            }\n\n            this.previousSkipType = ActionType.Poi;\n        } else if (CompileConfig.categorySupport[category]?.length === 1 \n                && CompileConfig.categorySupport[category]?.[0] === ActionType.Full) {\n            if (this.previousSkipType !== ActionType.Full) {\n                this.timesBeforeChanging = [...segment.segment];\n            }\n\n            this.previousSkipType = ActionType.Full;\n        } else if ((category === \"chooseACategory\" || ((CompileConfig.categorySupport[category]?.includes(ActionType.Skip)\n                        || CompileConfig.categorySupport[category]?.includes(ActionType.Chapter))\n                        && ![ActionType.Poi, ActionType.Full].includes(this.getNextActionType(category, actionType))))\n                    && this.previousSkipType !== ActionType.Skip) {\n            if (this.timesBeforeChanging[0]) {\n                this.setTimeTo(0, this.timesBeforeChanging[0]);\n            }\n            if (this.timesBeforeChanging[1]) {\n                this.setTimeTo(1, this.timesBeforeChanging[1]);\n            }\n\n            this.previousSkipType = ActionType.Skip;\n        }\n    }\n\n    getActionTypeOptions(sponsorTime: SponsorTime): React.ReactElement[] {\n        const elements = [];\n\n        for (const actionType of CompileConfig.categorySupport[sponsorTime.category]) {\n            elements.push(\n                <option value={actionType}\n                        key={actionType}>\n                    {chrome.i18n.getMessage(actionType)}\n                </option>\n            );\n        }\n\n        return elements;\n    }\n\n    setTimeToNow(index: number): void {\n        this.setTimeTo(index, this.props.contentContainer().getRealCurrentTime());\n    }\n\n    setTimeToEnd(): void {\n        this.setTimeTo(1, getVideoDuration());\n    }\n\n    /**\n     * @param index \n     * @param time If null, will set time to the first index's time\n     */\n    setTimeTo(index: number, time: number): void {\n        const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index];\n        if (time === null) time = sponsorTime.segment[0];\n\n        const addedTime = sponsorTime.segment.length === 1;\n        sponsorTime.segment[index] = time;\n        if (sponsorTime.actionType === ActionType.Poi) sponsorTime.segment[1] = time;\n\n        if (addedTime) {\n            this.props.contentContainer().updateEditButtonsOnPlayer();\n        }\n\n        this.setState({\n            sponsorTimeEdits: this.getFormattedSponsorTimesEdits(sponsorTime)\n        }, () => this.saveEditTimes());\n    }\n\n    toggleEditTime(): void {\n        if (this.state.editing) {\n            \n            this.setState({\n                editing: false\n            });\n\n            this.saveEditTimes();            \n        } else {\n            const sponsorTime = this.props.contentContainer().sponsorTimesSubmitting[this.props.index];\n\n            this.setState({\n                editing: true,\n                sponsorTimeEdits: this.getFormattedSponsorTimesEdits(sponsorTime)\n            });\n        }\n    }\n\n    /** Returns an array in the sponsorTimeEdits form (formatted time string) from a normal seconds sponsor time */\n    getFormattedSponsorTimesEdits(sponsorTime: SponsorTime): [string, string] {\n        return [getFormattedTime(sponsorTime.segment[0], true),\n            getFormattedTime(sponsorTime.segment[1], true)];\n    }\n\n    lastEditTime = 0;\n    editTimeTimeout: NodeJS.Timeout | null = null;\n    saveEditTimes(): void {\n        // Rate limit edits\n        const timeSinceLastEdit = Date.now() - this.lastEditTime;\n        const rateLimitTime = 200;\n        if (timeSinceLastEdit < rateLimitTime) {\n            if (!this.editTimeTimeout) {\n                this.editTimeTimeout = setTimeout(() => {\n                    this.saveEditTimes();\n                }, rateLimitTime - timeSinceLastEdit)\n            }\n\n            return;\n        }\n\n        this.lastEditTime = Date.now();\n        this.editTimeTimeout = null;\n\n        const sponsorTimesSubmitting = this.props.contentContainer().sponsorTimesSubmitting;\n        const category = this.categoryOptionRef.current.value as Category\n\n        if (this.state.editing) {\n            const startTime = getFormattedTimeToSeconds(this.state.sponsorTimeEdits[0]);\n            const endTime = getFormattedTimeToSeconds(this.state.sponsorTimeEdits[1]);\n\n            // Change segment time only if the format was correct\n            if (startTime !== null && endTime !== null) {\n                const addingTime = sponsorTimesSubmitting[this.props.index].segment.length === 1;\n                sponsorTimesSubmitting[this.props.index].segment = [startTime, endTime];\n\n                if (addingTime) {\n                    this.props.contentContainer().updateEditButtonsOnPlayer();\n                }\n            } else if (startTime !== null) {\n                // Only start time is valid, still an incomplete segment\n                sponsorTimesSubmitting[this.props.index].segment[0] = startTime;\n            }\n        } else if (this.state.sponsorTimeEdits[1] === null && category === \"outro\" && !sponsorTimesSubmitting[this.props.index].segment[1]) {\n            sponsorTimesSubmitting[this.props.index].segment[1] = getVideoDuration();\n            this.props.contentContainer().updateEditButtonsOnPlayer();\n        }\n\n        sponsorTimesSubmitting[this.props.index].category = category;\n\n        const actionType = this.getNextActionType(category, this.actionTypeOptionRef?.current?.value as ActionType);\n        sponsorTimesSubmitting[this.props.index].actionType = actionType;\n        this.setState({\n            selectedActionType: actionType\n        });\n\n        const description = actionType === ActionType.Chapter ? this.descriptionOptionRef?.current?.value : \"\";\n        sponsorTimesSubmitting[this.props.index].description = description;\n\n        Config.local.unsubmittedSegments[this.props.contentContainer().sponsorVideoID] = sponsorTimesSubmitting;\n        Config.forceLocalUpdate(\"unsubmittedSegments\");\n\n        this.props.contentContainer().updatePreviewBar();\n\n        if (sponsorTimesSubmitting[this.props.index].actionType === ActionType.Full \n                && (sponsorTimesSubmitting[this.props.index].segment[0] !== 0 || sponsorTimesSubmitting[this.props.index].segment[1] !== 0)) {\n            this.setTimeTo(0, 0);\n            this.setTimeTo(1, 0);\n        }\n    }\n\n    private getNextActionType(category: Category, actionType: ActionType): ActionType {\n        return actionType && CompileConfig.categorySupport[category]?.includes(actionType) ? actionType\n            : CompileConfig.categorySupport[category]?.[0] ?? ActionType.Skip\n    }\n\n    previewTime(ctrlPressed = false, shiftPressed = false, skipToEndTime = false): void {\n        const sponsorTimes = this.props.contentContainer().sponsorTimesSubmitting;\n        const index = this.props.index;\n        let seekTime = defaultPreviewTime;\n        if (ctrlPressed) seekTime = 0.5;\n        if (shiftPressed) seekTime = 0.25;\n\n        const startTime = sponsorTimes[index].segment[0];\n        const endTime = sponsorTimes[index].segment[1];\n\n        // If segment starts at 0:00, start playback at the end of the segment\n        const skipTime = (startTime === 0 || skipToEndTime) ? endTime : (startTime - (seekTime * getVideo().playbackRate));\n\n        this.props.contentContainer().previewTime(skipTime, !skipToEndTime);\n    }\n\n    inspectTime(): void {\n        const sponsorTimes = this.props.contentContainer().sponsorTimesSubmitting;\n        const index = this.props.index;\n\n        const skipTime = sponsorTimes[index].segment[0];\n\n        this.props.contentContainer().previewTime(skipTime + 0.0001, false);\n    }\n\n    deleteTime(): void {\n        const sponsorTimes = this.props.contentContainer().sponsorTimesSubmitting;\n        const index = this.props.index;\n        const removingIncomplete = sponsorTimes[index].segment.length < 2;\n\n        sponsorTimes.splice(index, 1);\n  \n        //save this\n        if (sponsorTimes.length > 0) {\n            Config.local.unsubmittedSegments[this.props.contentContainer().sponsorVideoID] = sponsorTimes;\n        } else {\n            delete Config.local.unsubmittedSegments[this.props.contentContainer().sponsorVideoID];\n        }\n        Config.forceLocalUpdate(\"unsubmittedSegments\");\n\n        this.props.contentContainer().updatePreviewBar();\n        \n        //if they are all removed\n        if (sponsorTimes.length == 0) {\n            this.props.submissionNotice.cancel();\n        } else {\n            //update display\n            this.props.submissionNotice.forceUpdate();\n        }\n\n        //if it is not a complete segment, or all are removed\n        if (sponsorTimes.length === 0 || removingIncomplete) {\n            //update video player\n            this.props.contentContainer().updateEditButtonsOnPlayer();\n        }\n    }\n\n    descriptionUpdate(description: string): void {\n        this.setState({\n            description\n        }, () => {\n            this.saveEditTimes();\n        });\n\n        if (!this.fetchingSuggestions) {\n            this.fetchSuggestions(description);\n        }\n    }\n\n    async fetchSuggestions(description: string): Promise<void> {\n        if (this.props.contentContainer().channelIDInfo.status !== ChannelIDStatus.Found) return;\n\n        this.fetchingSuggestions = true;\n        try {\n            const result = await asyncRequestToServer(\"GET\", \"/api/chapterNames\", {\n                description,\n                channelID: this.props.contentContainer().channelIDInfo.id\n            });\n            if (result.ok) {\n                const names = JSON.parse(result.responseText) as {description: string}[];\n                this.setState({\n                    suggestedNames: names.map(n => ({\n                        label: n.description\n                    }))\n                });\n            } else if (result.status !== 404) {\n                logRequest(result, \"SB\", \"chapter suggestion\")\n            }\n        } catch (e) {\n            console.warn(\"[SB] Caught error while fetching chapter suggestions\", e);\n        } finally {\n            this.fetchingSuggestions = false;\n        }\n    }\n\n    configUpdate(): void {\n        this.forceUpdate();\n    }\n}\n\nexport default SponsorTimeEditComponent;\n"
  },
  {
    "path": "src/components/SubmissionNoticeComponent.tsx",
    "content": "import * as React from \"react\";\nimport Config from \"../config\"\nimport GenericNotice from \"../render/GenericNotice\";\nimport { Category, ContentContainer } from \"../types\";\nimport * as CompileConfig from \"../../config.json\";\n\nimport NoticeComponent from \"./NoticeComponent\";\nimport NoticeTextSelectionComponent from \"./NoticeTextSectionComponent\";\nimport SponsorTimeEditComponent from \"./SponsorTimeEditComponent\";\nimport { getGuidelineInfo } from \"../utils/constants\";\nimport { exportTimes } from \"../utils/exporter\";\nimport { getVideo, isCurrentTimeWrong } from \"../../maze-utils/src/video\";\n\nexport interface SubmissionNoticeProps { \n    // Contains functions and variables from the content script needed by the skip notice\n    contentContainer: ContentContainer;\n\n    callback: () => Promise<boolean>;\n\n    closeListener: () => void;\n}\n\nexport interface SubmissionNoticeState {\n    noticeTitle: string;\n    messages: string[];\n    idSuffix: string;\n}\n\nclass SubmissionNoticeComponent extends React.Component<SubmissionNoticeProps, SubmissionNoticeState> {\n    // Contains functions and variables from the content script needed by the skip notice\n    contentContainer: ContentContainer;\n\n    callback: () => unknown;\n\n    noticeRef: React.MutableRefObject<NoticeComponent>;\n    timeEditRefs: React.RefObject<SponsorTimeEditComponent>[];\n\n    videoObserver: MutationObserver;\n\n    guidelinesReminder: GenericNotice;\n\n    lastSegmentCount: number;\n\n    constructor(props: SubmissionNoticeProps) {\n        super(props);\n        this.noticeRef = React.createRef();\n\n        this.contentContainer = props.contentContainer;\n        this.callback = props.callback;\n    \n        const noticeTitle = chrome.i18n.getMessage(\"confirmNoticeTitle\");\n\n        this.lastSegmentCount = this.props.contentContainer().sponsorTimesSubmitting.length;\n\n        // Setup state\n        this.state = {\n            noticeTitle,\n            messages: [],\n            idSuffix: \"SubmissionNotice\"\n        };\n    }\n\n    componentDidMount(): void {\n        // Catch and rerender when the video size changes\n        //TODO: Use ResizeObserver when it is supported in TypeScript\n        this.videoObserver = new MutationObserver(() => {\n            this.forceUpdate();\n        });\n\n        this.videoObserver.observe(getVideo(), {\n            attributes: true\n        });\n\n        // Prevent zooming while changing times\n        document.getElementById(\"sponsorSkipNoticeMiddleRow\" + this.state.idSuffix).addEventListener('wheel', function (event) {\n            if (event.ctrlKey) {\n                event.preventDefault();\n            }\n        }, {passive: false});\n    }\n\n    componentWillUnmount(): void {\n        if (this.videoObserver) {\n            this.videoObserver.disconnect();\n        }\n    }\n\n    componentDidUpdate() {\n        const currentSegmentCount = this.props.contentContainer().sponsorTimesSubmitting.length;\n        if (currentSegmentCount > this.lastSegmentCount) {\n            this.lastSegmentCount = currentSegmentCount;\n\n            this.scrollToBottom();\n        }\n    }\n\n    scrollToBottom() {\n        const scrollElement = this.noticeRef.current.getElement().current.querySelector(\"#sponsorSkipNoticeMiddleRowSubmissionNotice\");\n        scrollElement.scrollTo({\n            top: scrollElement.scrollHeight + 1000\n        });\n    }\n\n    render(): React.ReactElement {\n        const sortButton = \n            <img id={\"sponsorSkipSortButton\" + this.state.idSuffix} \n                className=\"sponsorSkipObject sponsorSkipNoticeButton sponsorSkipSmallButton\"\n                onClick={() => this.sortSegments()}\n                title={chrome.i18n.getMessage(\"sortSegments\")}\n                key=\"sortButton\"\n                src={chrome.runtime.getURL(\"icons/sort.svg\")}>\n            </img>;\n        const exportButton = \n            <img id={\"sponsorSkipExportButton\" + this.state.idSuffix} \n                className=\"sponsorSkipObject sponsorSkipNoticeButton sponsorSkipSmallButton\"\n                onClick={() => this.exportSegments()}\n                title={chrome.i18n.getMessage(\"exportSegments\")}\n                key=\"exportButton\"\n                src={chrome.runtime.getURL(\"icons/export.svg\")}>\n            </img>;\n        return (\n            <NoticeComponent noticeTitle={this.state.noticeTitle}\n                idSuffix={this.state.idSuffix}\n                ref={this.noticeRef}\n                closeListener={this.cancel.bind(this)}\n                zIndex={5000}\n                firstColumn={[sortButton, exportButton]}>\n\n                {/* Text Boxes */}\n                {this.getMessageBoxes()}\n\n                {/* Sponsor Time List */}\n                <tr id={\"sponsorSkipNoticeMiddleRow\" + this.state.idSuffix}\n                    className=\"sponsorTimeMessagesRow\"\n                    style={{maxHeight: (getVideo()?.offsetHeight - 200) + \"px\"}}\n                    onMouseDown={(e) => e.stopPropagation()}>\n                    <td style={{width: \"100%\"}}>\n                        {this.getSponsorTimeMessages()}\n                    </td>\n                </tr>\n\n                {/* Last Row */}\n                <tr id={\"sponsorSkipNoticeSecondRow\" + this.state.idSuffix}>\n\n                    <td className=\"sponsorSkipNoticeRightSection\"\n                        style={{position: \"relative\"}}>\n\n                        {/* Guidelines button */}\n                        <button className=\"sponsorSkipObject sponsorSkipNoticeButton sponsorSkipNoticeRightButton\"\n                            onClick={() => window.open(\"https://wiki.sponsor.ajay.app/w/Guidelines\")}>\n\n                            {chrome.i18n.getMessage(Config.config.submissionCountSinceCategories > 3 ? \"guidelines\" : \"readTheGuidelines\")}\n                        </button>\n\n                        {/* Submit Button */}\n                        <button className=\"sponsorSkipObject sponsorSkipNoticeButton sponsorSkipNoticeRightButton\"\n                            onClick={this.submit.bind(this)}>\n\n                            {chrome.i18n.getMessage(\"submit\")}\n                        </button>\n                    </td>\n                </tr>\n\n            </NoticeComponent>\n        );\n    }\n\n    getSponsorTimeMessages(): JSX.Element[] | JSX.Element {\n        const elements: JSX.Element[] = [];\n        this.timeEditRefs = [];\n\n        const sponsorTimes = this.props.contentContainer().sponsorTimesSubmitting;\n\n        for (let i = 0; i < sponsorTimes.length; i++) {\n            const timeRef = React.createRef<SponsorTimeEditComponent>();\n\n            elements.push(\n                <SponsorTimeEditComponent key={sponsorTimes[i].UUID}\n                    idSuffix={this.state.idSuffix + i}\n                    index={i}\n                    contentContainer={this.props.contentContainer}\n                    submissionNotice={this}\n                    categoryChangeListener={this.categoryChangeListener.bind(this)}\n                    ref={timeRef}>\n                </SponsorTimeEditComponent>\n            );\n\n            this.timeEditRefs.push(timeRef);\n        }\n\n        return elements;\n    }\n\n    getMessageBoxes(): JSX.Element[] | JSX.Element {\n        const elements: JSX.Element[] = [];\n\n        for (let i = 0; i < this.state.messages.length; i++) {\n            elements.push(\n                <NoticeTextSelectionComponent idSuffix={this.state.idSuffix + i}\n                    text={this.state.messages[i]}\n                    key={i}>\n                </NoticeTextSelectionComponent>\n            );\n        }\n\n        return elements;\n    }\n\n    cancel(): void {\n        this.guidelinesReminder?.close();\n        this.noticeRef.current.close(true);\n\n        this.contentContainer().resetSponsorSubmissionNotice(false);\n\n        this.props.closeListener();\n    }\n\n    submit(): void {\n        if (isCurrentTimeWrong()) {\n            alert(chrome.i18n.getMessage(\"submissionFailedServerSideAds\"));\n            return;\n        }\n\n        // save all items\n        for (const ref of this.timeEditRefs) {\n            ref.current.saveEditTimes();\n        }\n\n        const sponsorTimesSubmitting = this.props.contentContainer().sponsorTimesSubmitting;\n        for (const sponsorTime of sponsorTimesSubmitting) {\n            if (sponsorTime.category === \"chooseACategory\") {\n                alert(chrome.i18n.getMessage(\"youMustSelectACategory\"));\n                return;\n            }\n        }\n\n        // Check if any non music categories are being used on a music video\n        if (this.contentContainer().videoInfo?.microformat?.playerMicroformatRenderer?.category === \"Music\") {\n            for (const sponsorTime of sponsorTimesSubmitting) {\n                if (sponsorTime.category === \"sponsor\") {\n                    if (!confirm(chrome.i18n.getMessage(\"nonMusicCategoryOnMusic\"))) return;\n\n                    break;\n                }\n            }\n        }\n\n        this.props.callback().then((success) => {\n            if (success) {\n                this.cancel();\n            }\n        });\n    }\n\n    sortSegments(): void {\n        let sponsorTimesSubmitting = this.props.contentContainer().sponsorTimesSubmitting;\n        sponsorTimesSubmitting = sponsorTimesSubmitting.sort((a, b) => a.segment[0] - b.segment[0]);\n\n        Config.local.unsubmittedSegments[this.props.contentContainer().sponsorVideoID] = sponsorTimesSubmitting;\n        Config.forceLocalUpdate(\"unsubmittedSegments\");\n\n        this.forceUpdate();\n    }\n\n    exportSegments() {\n        const sponsorTimesSubmitting = this.props.contentContainer()\n            .sponsorTimesSubmitting.sort((a, b) => a.segment[0] - b.segment[0]);\n        window.navigator.clipboard.writeText(exportTimes(sponsorTimesSubmitting));\n\n        new GenericNotice(null, \"exportCopied\", {\n            title: chrome.i18n.getMessage(`CopiedExclamation`),\n            timed: true,\n            maxCountdownTime: () => 0.6,\n            referenceNode: document.querySelector(\".noticeLeftIcon\"),\n            dontPauseCountdown: true,\n            style: {\n                top: 0,\n                bottom: 0,\n                minWidth: 0,\n                right: \"30px\",\n                margin: \"auto\"\n            },\n            hideLogo: true,\n            hideRightInfo: true,\n            extraClass: \"exportCopiedNotice\"\n        });\n    }\n\n    categoryChangeListener(index: number, category: Category): void {\n        const dialogWidth = this.noticeRef?.current?.getElement()?.current?.offsetWidth;\n        if (category !== \"chooseACategory\" && Config.config.showCategoryGuidelines\n                && getVideo().offsetWidth > dialogWidth * 2) {\n            const options = {\n                title:  chrome.i18n.getMessage(`category_${category}`),\n                textBoxes: getGuidelineInfo(category),\n                buttons: [{\n                        name: chrome.i18n.getMessage(\"FullDetails\"),\n                        listener: () => window.open(CompileConfig.wikiLinks[category])\n                    },\n                    {\n                        name: chrome.i18n.getMessage(\"Hide\"),\n                        listener: () => {\n                            Config.config.showCategoryGuidelines = false;\n                            this.guidelinesReminder?.close();\n                            this.guidelinesReminder = null;\n                        }\n                }],\n                timed: false,\n                style: {\n                    right: `${dialogWidth + 10}px`,\n                },\n                extraClass: \"sb-guidelines-notice\"\n            };\n\n            if (options.textBoxes) {\n                if (this.guidelinesReminder) {\n                    this.guidelinesReminder.update(options);\n                } else {\n                    this.guidelinesReminder = new GenericNotice(null, \"GuidelinesReminder\", options);\n                }\n            } else {\n                this.guidelinesReminder?.close();\n                this.guidelinesReminder = null;\n            }\n        }\n    }\n}\n\nexport default SubmissionNoticeComponent;\n"
  },
  {
    "path": "src/components/options/AdvancedSkipOptionsComponent.tsx",
    "content": "import * as React from \"react\";\n\nimport Config from \"../../config\";\nimport { configToText, parseConfig, } from \"../../utils/skipRule\";\nimport { AdvancedSkipRule } from \"../../utils/skipRule.type\";\n\nlet configSaveTimeout: NodeJS.Timeout | null = null;\n\nexport function AdvancedSkipOptionsComponent() {\n    const [optionsOpen, setOptionsOpen] = React.useState(false);\n    const [config, setConfig] = React.useState(configToText(Config.local.skipRules));\n    const [configValid, setConfigValid] = React.useState(true);\n\n    return (\n        <div>\n            <div className=\"option-button\" onClick={() => {\n                setOptionsOpen(!optionsOpen);\n            }}>\n                {chrome.i18n.getMessage(\"openAdvancedSkipOptions\")}\n            </div>\n\n            {\n                optionsOpen &&\n                <div className=\"advanced-skip-options-menu\">\n                    <div className={\"advanced-config-help-message\"}>\n                        <a target=\"_blank\"\n                                rel=\"noopener noreferrer\"\n                                href=\"https://wiki.sponsor.ajay.app/w/Advanced_Skip_Options\">\n                            {chrome.i18n.getMessage(\"advancedSkipSettingsHelp\")}\n                        </a>\n\n                        <span className={configValid ? \"hidden\" : \"invalid-advanced-config\"}>\n                            {\" - \"}\n                            {chrome.i18n.getMessage(\"advancedSkipNotSaved\")}\n                        </span>\n                    </div>\n\n                    <textarea className={\"option-text-box \" + (configValid ? \"\" : \"invalid-advanced-config\")}\n                        rows={10}\n                        style={{ width: \"80%\" }}\n                        value={config}\n                        spellCheck={false}\n                        onChange={(e) => {\n                            setConfig(e.target.value);\n\n                            const compiled = compileConfig(e.target.value);\n                            setConfigValid(!!compiled && !(e.target.value.length > 0 && compiled.length === 0));\n\n                            if (compiled) {\n                                if (configSaveTimeout) {\n                                    clearTimeout(configSaveTimeout);\n                                }\n\n                                configSaveTimeout = setTimeout(() => {\n                                    Config.local.skipRules = compiled;\n                                }, 200);\n                            }\n                        }}\n                    />\n                </div>\n            }\n        </div>\n    );\n}\n\nfunction compileConfig(config: string): AdvancedSkipRule[] | null {\n    const { rules, errors } = parseConfig(config);\n\n    for (const error of errors) {\n        console.error(`[SB] Error on line ${error.span.start.line}: ${error.message}`);\n    }\n\n    if (errors.length === 0) {\n        return rules;\n    } else {\n        return null;\n    }\n}\n"
  },
  {
    "path": "src/components/options/CategoryChooserComponent.tsx",
    "content": "import * as React from \"react\";\n\nimport * as CompileConfig from \"../../../config.json\";\nimport { Category, CategorySelection, CategorySkipOption } from \"../../types\";\nimport { CategorySkipOptionsComponent, ExtraOptionComponent, ToggleOption } from \"./CategorySkipOptionsComponent\";\nimport { SelectOptionComponent } from \"./SelectOptionComponent\";\nimport Config, { ConfigurationID, CustomConfiguration } from \"../../config\";\nimport { generateUserID } from \"../../../maze-utils/src/setup\";\n\nlet forceUpdateSkipProfilesTimeout: NodeJS.Timeout | null = null;\nlet forceUpdateSkipProfileIDsTimeout: NodeJS.Timeout | null = null;\n\nexport function CategoryChooserComponent() {\n    const [configurations, setConfigurations] = React.useState(Config.local!.skipProfiles);\n    const [selectedConfigurationID, setSelectedConfigurationID] = React.useState<ConfigurationID | null>(null);\n    const [channelListText, setChannelListText] = React.useState(\"\");\n\n    const [configurationName, setConfigurationName] = React.useState(\"\");\n    const [selections, setSelections] = React.useState<CategorySelection[]>([]);\n\n    React.useEffect(() => {\n        setConfigurationName(getConfigurationValue(selectedConfigurationID, \"name\", \"\"));\n\n        updateChannelList(setChannelListText, selectedConfigurationID!);\n        setSelections(getConfigurationValue<CategorySelection[]>(selectedConfigurationID, \"categorySelections\"));\n\n        if (selectedConfigurationID === null) {\n            document.querySelectorAll(\".hide-when-skip-profile\").forEach((e) => e.classList.remove(\"hidden\"));\n        } else {\n            document.querySelectorAll(\".hide-when-skip-profile\").forEach((e) => e.classList.add(\"hidden\"));\n        }\n    }, [selectedConfigurationID]);\n\n    const createNewConfig = () => {\n        let newID = generateUserID().substring(0, 5);\n        while (Config.local.skipProfiles[newID]) {\n            newID = generateUserID().substring(0, 5);\n        }\n\n        const newConfiguration: CustomConfiguration = {\n            name: `${chrome.i18n.getMessage(\"NewConfiguration\")} ${Object.keys(Config.local.skipProfiles).length}`,\n            categorySelections: [],\n            showAutogeneratedChapters: null,\n            autoSkipOnMusicVideos: null,\n            skipNonMusicOnlyOnYoutubeMusic: null,\n            muteSegments: null,\n            fullVideoSegments: null,\n            manualSkipOnFullVideo: null,\n            minDuration: null\n        };\n\n        Config.local!.skipProfiles[newID] = newConfiguration;\n        forceUpdateConfigurations();\n        setConfigurations(Config.local!.skipProfiles);\n        setSelectedConfigurationID(newID as ConfigurationID);\n\n        updateChannelList(setChannelListText, newID as ConfigurationID);\n    };\n    React.useEffect(() => {\n        if (window.location.hash === \"#newProfile\") {\n            createNewConfig();\n        }\n    }, []);\n\n    return (\n        <>\n            <div className=\"categoryChooserTopRow\">\n                <SelectOptionComponent\n                    id=\"channelProfiles\"\n                    onChange={(value) => {\n                        if (value === \"null\") value = null;\n\n                        setSelectedConfigurationID(value as ConfigurationID);\n                        updateChannelList(setChannelListText, value as ConfigurationID);\n                    }}\n                    value={selectedConfigurationID!}\n                    options={[{\n                        value: \"null\",\n                        label: chrome.i18n.getMessage(\"DefaultConfiguration\")\n                    }].concat(Object.entries(configurations).map(([key, value]) => ({\n                        value: key,\n                        label: value.name\n                    })))}\n                />\n\n                <div \n                    className=\"option-button trigger-button\"\n                    onClick={() => createNewConfig()}>\n                    {chrome.i18n.getMessage(\"NewConfiguration\")}\n                </div>\n            </div>\n\n            {\n                selectedConfigurationID &&\n                <div className=\"configurationInfo\">\n                    <input \n                        type=\"text\"\n                        id=\"configurationName\"\n                        value={configurationName}\n                        placeholder={chrome.i18n.getMessage(\"ConfigurationName\")}\n                        style={{width: document.getElementById(\"channelProfiles\")?.clientWidth ?? null}}\n                        onChange={(e) => {\n                            const newName = e.target.value;\n                            getConfig(selectedConfigurationID)!.name = newName;\n                            setConfigurationName(newName);\n\n                            forceUpdateConfigurations();\n                            setConfigurations(Config.local!.skipProfiles);\n                        }}/>\n\n                    <div>\n                        {chrome.i18n.getMessage(\"ChannelListInstructionsSB\")}\n                    </div>\n                \n                    <textarea \n                        className=\"option-text-box\" \n                        rows={10} \n                        value={channelListText}\n                        onChange={(e) => {\n                            const newText = e.target.value;\n                            setChannelListText(newText);\n\n                            const channels = newText.split(\"\\n\").map((channel) => channel.trim()).filter((channel) => channel !== \"\");\n                            if (channels.length > 0) {\n                                for (const [channelID, id] of Object.entries(Config.local!.channelSkipProfileIDs)) {\n                                    if (id === selectedConfigurationID) {\n                                        if (!channels.includes(channelID)) {\n                                            delete Config.local!.channelSkipProfileIDs[channelID];\n                                        }\n                                    }\n                                }\n\n                                for (const channel of channels) {\n                                    Config.local!.channelSkipProfileIDs[channel] = selectedConfigurationID;\n                                }\n                            }\n\n                            forceUpdateConfigurationIDs();\n                        }}/>\n                    \n                    <div \n                        className=\"option-button trigger-button\"\n                        onClick={() => {\n                            if (confirm(chrome.i18n.getMessage(\"areYouSureDeleteConfig\"))) {\n                                delete Config.local.skipProfiles[selectedConfigurationID];\n                                forceUpdateConfigurations();\n\n                                for (const [channelID, id] of Object.entries(Config.local.channelSkipProfileIDs)) {\n                                    if (id === selectedConfigurationID) {\n                                        delete Config.local.channelSkipProfileIDs[channelID];\n                                    }\n                                }\n                                forceUpdateConfigurationIDs();\n\n                                if (Config.local.skipProfileTemp && Config.local.skipProfileTemp.configID === selectedConfigurationID) {\n                                    Config.local.skipProfileTemp = null;\n                                }\n\n                                setConfigurations(Config.local!.skipProfiles);\n                                const newID = Object.keys(Config.local!.skipProfiles)[0] as ConfigurationID;\n                                setSelectedConfigurationID(newID ?? null);\n                            }\n                        }}>\n                        {chrome.i18n.getMessage(\"DeleteConfiguration\")}\n                    </div>\n                </div>\n            }\n\n            <table id=\"categoryChooserTable\"\n                className=\"categoryChooserTable\"> \n                <tbody>\n                    {/* Headers */}\n                    <tr id={\"CategoryOptionsRow\"}\n                            className=\"categoryTableElement categoryTableHeader\">\n                        <th id={\"CategoryOptionName\"}>\n                            {chrome.i18n.getMessage(\"category\")}\n                        </th>\n\n                        <th id={\"CategorySkipOption\"}\n                            className=\"skipOption\">\n                            {chrome.i18n.getMessage(\"skipOption\")}\n                        </th>\n\n                        <th id={\"CategoryColorOption\"}\n                            className=\"colorOption\">\n                            {chrome.i18n.getMessage(\"seekBarColor\")}\n                        </th>\n\n                        <th id={\"CategoryPreviewColorOption\"}\n                            className=\"previewColorOption\">\n                            {chrome.i18n.getMessage(\"previewColor\")}\n                        </th>\n                    </tr>\n\n                    <CategorySkipOptions\n                        selectedConfigurationID={selectedConfigurationID}\n                        selections={selections}\n                        setSelections={setSelections}\n                    />\n                </tbody> \n            </table>\n\n            <ExtraOptionsComponent\n                selectedConfigurationID={selectedConfigurationID!}/>\n        </>\n    );\n}\n\nfunction CategorySkipOptions({ selectedConfigurationID, selections, setSelections}: { selectedConfigurationID: ConfigurationID | null;\n        selections: CategorySelection[]; setSelections: (s: CategorySelection[]) => void; }): JSX.Element {\n    const elements: JSX.Element[] = [];\n    const defaultSkipOption = selectedConfigurationID === null ? CategorySkipOption.Disabled : CategorySkipOption.FallbackToDefault;\n\n    for (const category of CompileConfig.categoryList) {\n        elements.push(\n            <CategorySkipOptionsComponent\n                category={category as Category}\n                selection={selections.find(selection => selection.name === category)?.option ?? defaultSkipOption}\n                updateSelection={(option: CategorySkipOption) => {\n                    const existingSelection = selections.find(selection => selection.name === category);\n                    const deletingSelection = (option === CategorySkipOption.Disabled && selectedConfigurationID === null)\n                        || (option === CategorySkipOption.FallbackToDefault && selectedConfigurationID !== null);\n                    if (existingSelection) {\n                        existingSelection.option = option;\n\n                        if (deletingSelection) {\n                            selections.splice(selections.indexOf(existingSelection), 1);\n                        }\n                    } else if (!deletingSelection) {\n                        selections.push({\n                            name: category as Category,\n                            option: option\n                        });\n                    }\n\n                    // Clone so React notices the change\n                    selections = [...selections];\n\n                    updateConfigurationValue(selectedConfigurationID, \"categorySelections\", selections, setSelections);\n                }}\n                isDefaultConfig={selectedConfigurationID === null}\n                selectedConfigurationID={selectedConfigurationID}\n                key={category}>\n            </CategorySkipOptionsComponent>\n        );\n    }\n\n    return <>\n        {elements}\n    </>;\n}\n\nfunction forceUpdateConfigurations() {\n    if (forceUpdateSkipProfilesTimeout) {\n        clearTimeout(forceUpdateSkipProfilesTimeout);\n    }\n\n    forceUpdateSkipProfilesTimeout = setTimeout(() => {\n        Config.forceLocalUpdate(\"skipProfiles\");\n    }, 50);\n}\n\nfunction forceUpdateConfigurationIDs() {\n    if (forceUpdateSkipProfileIDsTimeout) {\n        clearTimeout(forceUpdateSkipProfileIDsTimeout);\n    }\n\n    forceUpdateSkipProfileIDsTimeout = setTimeout(() => {\n        Config.forceLocalUpdate(\"channelSkipProfileIDs\");\n    }, 50);\n}\n\nfunction updateChannelList(setChannelListText: (value: string) => void, selectedConfigurationID: ConfigurationID) {\n    setChannelListText(Object.entries(Config.local!.channelSkipProfileIDs)\n        .filter(([, id]) => id === selectedConfigurationID)\n        .map(([channelID]) => channelID).join(\"\\n\"))\n}\n\nfunction getConfig(selectedConfigurationID: ConfigurationID | null) {\n    return selectedConfigurationID ? Config.local!.skipProfiles[selectedConfigurationID] : null;\n}\n\nexport function getConfigurationValue<T>(selectedConfigurationID: ConfigurationID | null, option: string, defaultValue?: T): T {\n    if (selectedConfigurationID === null) {\n        if (defaultValue !== undefined) {\n            return defaultValue;\n        } else {\n            return Config.config[option];\n        }\n    } else {\n        return getConfig(selectedConfigurationID)[option];\n    }\n}\n\nexport function updateConfigurationValue(selectedConfigurationID: ConfigurationID | null, option: string, value: unknown, setFunction?: (value: unknown) => void) {\n     if (selectedConfigurationID === null) {\n        Config.config[option] = value;\n    } else {\n        const config = getConfig(selectedConfigurationID);\n        if (value !== null) {\n            config[option] = value;\n        } else {\n            delete config[option];\n        }\n\n        forceUpdateConfigurations();\n    }\n\n    if (setFunction) setFunction(value);\n}\n\nfunction ExtraOptionsComponent(props: {selectedConfigurationID: ConfigurationID}): JSX.Element {\n    const options: ToggleOption[][] = [[{\n        configKey: \"muteSegments\",\n        label: chrome.i18n.getMessage(\"muteSegments\"),\n        type: \"toggle\"\n    }], [{\n        configKey: \"fullVideoSegments\",\n        label: chrome.i18n.getMessage(\"fullVideoSegments\"),\n        type: \"toggle\"\n    }, {\n        configKey: \"fullVideoLabelsOnThumbnails\",\n        label: chrome.i18n.getMessage(\"fullVideoLabelsOnThumbnails\"),\n        type: \"toggle\",\n        dontShowOnCustomConfigs: true\n    }, {\n        configKey: \"manualSkipOnFullVideo\",\n        label: chrome.i18n.getMessage(\"enableManualSkipOnFullVideo\"),\n        description: chrome.i18n.getMessage(\"whatManualSkipOnFullVideo\"),\n        type: \"toggle\"\n    }], [{\n        configKey: \"minDuration\",\n        label: chrome.i18n.getMessage(\"minDuration\"),\n        description: chrome.i18n.getMessage(\"minDurationDescription\"),\n        type: \"number\"\n    }]];\n\n    const result: JSX.Element[] = [];\n\n    for (const optionGroup of options) {\n        const groupResult: JSX.Element[] = [];\n        for (const option of optionGroup) {\n            groupResult.push(\n                <ExtraOptionComponent\n                    option={option}\n                    selectedConfigurationID={props.selectedConfigurationID}\n                    key={option.configKey}/>\n            );\n        }\n\n        result.push(\n            <div className=\"extraOptionGroup\" key={optionGroup.map(o => o.configKey).join(\"-\")}>\n                {groupResult}\n            </div>\n        );\n    }\n\n    return (<>\n        {result}\n    </>);\n}"
  },
  {
    "path": "src/components/options/CategorySkipOptionsComponent.tsx",
    "content": "import * as React from \"react\";\n\nimport Config, { ConfigurationID } from \"../../config\"\nimport * as CompileConfig from \"../../../config.json\";\nimport { Category, CategorySkipOption } from \"../../types\";\n\nimport { getCategorySuffix } from \"../../utils/categoryUtils\";\nimport { ToggleOptionComponent } from \"./ToggleOptionComponent\";\nimport { getConfigurationValue, updateConfigurationValue } from \"./CategoryChooserComponent\";\nimport { NumberInputOptionComponent } from \"./NumberInputOptionComponent\";\n\nexport interface CategorySkipOptionsProps { \n    category: Category;\n    selection: CategorySkipOption;\n    updateSelection(selection: CategorySkipOption): void;\n    isDefaultConfig: boolean;\n    selectedConfigurationID: ConfigurationID;\n    defaultColor?: string;\n    defaultPreviewColor?: string;\n    children?: React.ReactNode;\n}\n\nexport interface ToggleOption {\n    configKey: string;\n    label: string;\n    type: \"toggle\" | \"number\";\n    description?: string;\n    dontDisable?: boolean;\n    dontShowOnCustomConfigs?: boolean;\n}\n\nexport function CategorySkipOptionsComponent(props: CategorySkipOptionsProps): React.ReactElement {\n    const [color, setColor] = React.useState(props.defaultColor || Config.config.barTypes[props.category]?.color);\n    const [previewColor, setPreviewColor] = React.useState(props.defaultPreviewColor || Config.config.barTypes[\"preview-\" + props.category]?.color);\n\n    const selectedOption = React.useMemo(() => {\n        switch (props.selection) {\n            case CategorySkipOption.ShowOverlay:\n                return \"showOverlay\";\n            case CategorySkipOption.ManualSkip:\n                return \"manualSkip\";\n            case CategorySkipOption.AutoSkip:\n                return \"autoSkip\";\n            case CategorySkipOption.FallbackToDefault:\n                return \"fallbackToDefault\";\n            default:\n                return \"disable\";\n        }\n    }, [props.selection]);\n\n    const setBarColorTimeout = React.useRef<NodeJS.Timeout | null>(null);\n\n    return (\n        <>\n            <tr id={props.category + \"OptionsRow\"}\n                className={`categoryTableElement`} >\n                <td id={props.category + \"OptionName\"}\n                    className=\"categoryTableLabel\">\n                        {chrome.i18n.getMessage(\"category_\" + props.category)}\n                </td>\n\n                <td id={props.category + \"SkipOption\"}\n                    className=\"skipOption\">\n                    <select\n                        className=\"optionsSelector\"\n                        value={selectedOption}\n                        onChange={(e) => skipOptionSelected(e, props.category, props.updateSelection)}>\n                            {getCategorySkipOptions(props.category, props.isDefaultConfig)}\n                    </select>\n                </td>\n\n                {props.category !== \"chapter\" &&\n                    <td id={props.category + \"ColorOption\"}\n                        className=\"colorOption\">\n                        <input\n                            className=\"categoryColorTextBox option-text-box\"\n                            type=\"color\"\n                            disabled={!props.isDefaultConfig}\n                            onChange={(event) => {\n                                if (setBarColorTimeout.current) {\n                                    clearTimeout(setBarColorTimeout.current);\n                                }\n\n                                setColor(event.currentTarget.value);\n                                Config.config.barTypes[props.category].color = event.currentTarget.value;\n\n                                // Make listener get called\n                                setBarColorTimeout.current = setTimeout(() => {\n                                    Config.config.barTypes = Config.config.barTypes;\n                                }, 50);\n                            }}\n                            value={color} />\n                    </td>\n                }\n\n                {![\"chapter\", \"exclusive_access\"].includes(props.category) &&\n                    <td id={props.category + \"PreviewColorOption\"}\n                        className=\"previewColorOption\">\n                        <input\n                            className=\"categoryColorTextBox option-text-box\"\n                            type=\"color\"\n                            disabled={!props.isDefaultConfig}\n                            onChange={(event) => {\n                                if (setBarColorTimeout.current) {\n                                    clearTimeout(setBarColorTimeout.current);\n                                }\n\n                                setPreviewColor(event.currentTarget.value);\n                                Config.config.barTypes[\"preview-\" + props.category].color = event.currentTarget.value;\n\n                                // Make listener get called\n                                setBarColorTimeout.current = setTimeout(() => {\n                                    Config.config.barTypes = Config.config.barTypes;\n                                }, 50);\n                            }}\n                            value={previewColor} />\n                    </td>\n                }\n\n            </tr>\n\n            <tr id={props.category + \"DescriptionRow\"}\n                className={`small-description categoryTableDescription`}>\n                    <td\n                        colSpan={2}>\n                        {chrome.i18n.getMessage(\"category_\" + props.category + \"_description\")}\n                        {' '}\n                        <a href={CompileConfig.wikiLinks[props.category]} target=\"_blank\" rel=\"noreferrer\">\n                            {`${chrome.i18n.getMessage(\"LearnMore\")}`}\n                        </a>\n                    </td>\n            </tr>\n            \n            <ExtraOptionComponents\n                category={props.category}\n                selectedConfigurationID={props.selectedConfigurationID}\n            />\n        </>\n    );\n}\n\nfunction skipOptionSelected(event: React.ChangeEvent<HTMLSelectElement>,\n        category: Category, updateSelection: (selection: CategorySkipOption) => void): void {\n    let option: CategorySkipOption;\n    switch (event.target.value) {\n        case \"fallbackToDefault\":\n            option = CategorySkipOption.FallbackToDefault;\n            break;\n        case \"disable\":\n            option = CategorySkipOption.Disabled;\n            break;\n        case \"showOverlay\":\n            option = CategorySkipOption.ShowOverlay;\n            break;\n        case \"manualSkip\":\n            option = CategorySkipOption.ManualSkip;\n            break;\n        case \"autoSkip\":\n            option = CategorySkipOption.AutoSkip;\n\n            if (category === \"filler\" && !Config.config.isVip) {\n                if (!confirm(chrome.i18n.getMessage(\"FillerWarning\"))) {\n                    event.target.value = \"disable\";\n                }\n            }\n\n            break;\n    }\n\n    updateSelection(option);\n}\n\nfunction getCategorySkipOptions(category: Category, isDefaultConfig: boolean): JSX.Element[] {\n    const elements: JSX.Element[] = [];\n\n    let optionNames = [\"disable\", \"showOverlay\", \"manualSkip\", \"autoSkip\"];\n    if (category === \"chapter\") optionNames = [\"disable\", \"showOverlay\"]\n    else if (category === \"exclusive_access\") optionNames = [\"disable\", \"showOverlay\"];\n\n    if (!isDefaultConfig) {\n        optionNames = [\"fallbackToDefault\"].concat(optionNames);\n    }\n\n    for (const optionName of optionNames) {\n        elements.push(\n            <option key={optionName} value={optionName}>\n                {chrome.i18n.getMessage(optionName !== \"disable\" ? optionName + getCategorySuffix(category)\n                                                                    : optionName) || chrome.i18n.getMessage(optionName)}\n            </option>\n        );\n    }\n\n    return elements;\n}\n\n\nfunction ExtraOptionComponents(props: {category: string; selectedConfigurationID: ConfigurationID}): JSX.Element {\n    const result = [];\n    for (const option of getExtraOptions(props.category)) {\n        result.push(\n            <ExtraOptionComponent\n                key={option.configKey}\n                option={option}\n                selectedConfigurationID={props.selectedConfigurationID}\n            />\n        )\n    }\n\n    return (\n    <>\n        {result}\n    </>);\n}\n\nexport function ExtraOptionComponent({option, selectedConfigurationID}: {option: ToggleOption; selectedConfigurationID: ConfigurationID}): JSX.Element {\n    const [value, setValue] = React.useState(getConfigurationValue(selectedConfigurationID, option.configKey));\n    React.useEffect(() => {\n        setValue(getConfigurationValue(selectedConfigurationID, option.configKey));\n    }, [selectedConfigurationID]);\n\n    return (\n        <tr key={option.configKey} className={`${option.dontShowOnCustomConfigs && selectedConfigurationID !== null ? \"hidden\" : \"\"}`}>\n            <td id={`${option.configKey}`} className=\"categoryExtraOptions\">\n                {\n                    option.type === \"toggle\" ?\n                        <ToggleOptionComponent \n                            checked={value ?? Config.config[option.configKey]}\n                            partiallyHidden={value === null}\n                            showResetButton={value !== null && selectedConfigurationID !== null}\n                            onChange={(checked) => {\n                                updateConfigurationValue(selectedConfigurationID, option.configKey, checked, setValue);\n                            }}\n                            onReset={() => {\n                                updateConfigurationValue(selectedConfigurationID, option.configKey, null, setValue);\n                            }}\n                            label={option.label}\n                            description={option.description}\n                            style={{width: \"inherit\"}}\n                        />\n                    :\n                        <NumberInputOptionComponent \n                            value={value ?? Config.config[option.configKey]}\n                            partiallyHidden={value === null}\n                            showResetButton={value !== null && selectedConfigurationID !== null}\n                            onChange={(value) => {\n                                updateConfigurationValue(selectedConfigurationID, option.configKey, value, setValue);\n                            }}\n                            onReset={() => {\n                                updateConfigurationValue(selectedConfigurationID, option.configKey, null, setValue);\n                            }}\n                            label={option.label}\n                            description={option.description}\n                            style={{width: \"inherit\"}}\n                        />\n                }\n            </td>\n        </tr>\n    );\n}\n\nfunction getExtraOptions(category: string): ToggleOption[] {\n    switch (category) {\n        case \"chapter\":\n            return [{\n                configKey: \"renderSegmentsAsChapters\",\n                label: chrome.i18n.getMessage(\"renderAsChapters\"),\n                type: \"toggle\",\n                dontDisable: true,\n                dontShowOnCustomConfigs: true\n            }, {\n                configKey: \"showSegmentNameInChapterBar\",\n                label: chrome.i18n.getMessage(\"showSegmentNameInChapterBar\"),\n                type: \"toggle\",\n                dontDisable: true,\n                dontShowOnCustomConfigs: true\n            }, {\n                configKey: \"showAutogeneratedChapters\",\n                label: chrome.i18n.getMessage(\"showAutogeneratedChapters\"),\n                type: \"toggle\",\n                dontDisable: true\n            }];\n        case \"music_offtopic\":\n            return [{\n                configKey: \"autoSkipOnMusicVideos\",\n                label: chrome.i18n.getMessage(\"autoSkipOnMusicVideos\"),\n                type: \"toggle\"\n            }, {\n                configKey: \"skipNonMusicOnlyOnYoutubeMusic\",\n                label: chrome.i18n.getMessage(\"skipNonMusicOnlyOnYoutubeMusic\"),\n                type: \"toggle\"\n            }];\n        default:\n            return [];\n    }\n}"
  },
  {
    "path": "src/components/options/KeybindComponent.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot, Root } from 'react-dom/client';\nimport Config from \"../../config\";\nimport KeybindDialogComponent from \"./KeybindDialogComponent\";\nimport { formatKey, Keybind, keybindEquals, keybindToString } from \"../../../maze-utils/src/config\";\n\nexport interface KeybindProps { \n    option: string;\n}\n\nexport interface KeybindState { \n    keybind: Keybind;\n}\n\nlet dialog;\nlet root: Root;\n\nclass KeybindComponent extends React.Component<KeybindProps, KeybindState> {\n    constructor(props: KeybindProps) {\n        super(props);\n        this.state = {keybind: Config.config[this.props.option]};\n    }\n\n    render(): React.ReactElement {\n        return(\n            <>\n                <div className=\"keybind-buttons inline\" title={chrome.i18n.getMessage(\"change\")} onClick={() => this.openEditDialog()}>\n                    {this.state.keybind?.ctrl && <div className=\"key keyControl\">Ctrl</div>}\n                    {this.state.keybind?.ctrl && <span className=\"keyControl\">+</span>}\n                    {this.state.keybind?.alt && <div className=\"key keyAlt\">Alt</div>}\n                    {this.state.keybind?.alt && <span className=\"keyAlt\">+</span>}\n                    {this.state.keybind?.shift && <div className=\"key keyShift\">Shift</div>}\n                    {this.state.keybind?.shift && <span className=\"keyShift\">+</span>}\n                    {this.state.keybind?.key != null && <div className=\"key keyBase\">{formatKey(this.state.keybind.key)}</div>}\n                    {this.state.keybind == null && <span className=\"unbound\">{chrome.i18n.getMessage(\"notSet\")}</span>}\n                </div>\n\n            {this.state.keybind != null &&\n                <div className=\"option-button trigger-button inline\" onClick={() => this.unbind()}>\n                    {chrome.i18n.getMessage(\"unbind\")}\n                </div>\n            }\n            </>\n        );\n    }\n\n    equals(other: Keybind): boolean {\n        return keybindEquals(this.state.keybind, other);\n    }\n\n    toString(): string {\n        return keybindToString(this.state.keybind);\n    }\n\n    openEditDialog(): void {\n        dialog = parent.document.createElement(\"div\");\n        dialog.id = \"keybind-dialog\";\n        parent.document.body.prepend(dialog);\n        root = createRoot(dialog);\n        root.render(<KeybindDialogComponent option={this.props.option} closeListener={(updateWith) => this.closeEditDialog(updateWith)} />);\n    }\n\n    closeEditDialog(updateWith: Keybind): void {\n        root.unmount();\n        dialog.remove();\n        if (updateWith != null)\n            this.setState({keybind: updateWith});\n    }\n\n    unbind(): void {\n        this.setState({keybind: null});\n        Config.config[this.props.option] = null;\n    }\n}\n\nexport default KeybindComponent;"
  },
  {
    "path": "src/components/options/KeybindDialogComponent.tsx",
    "content": "import * as React from \"react\";\nimport { ChangeEvent } from \"react\";\nimport Config from \"../../config\";\nimport { Keybind, formatKey, keybindEquals } from \"../../../maze-utils/src/config\";\n\nexport interface KeybindDialogProps { \n    option: string;\n    closeListener: (updateWith) => void;\n}\n\nexport interface KeybindDialogState {\n    key: Keybind;\n    error: ErrorMessage;\n}\n\ninterface ErrorMessage {\n    message: string;\n    blocking: boolean;\n}\n\nclass KeybindDialogComponent extends React.Component<KeybindDialogProps, KeybindDialogState> {\n\n    constructor(props: KeybindDialogProps) {\n        super(props);\n        this.state = {\n            key: {\n                key: null,\n                code: null,\n                ctrl: false,\n                alt: false,\n                shift: false\n            },\n            error: {\n                message: null,\n                blocking: false\n            }\n        };\n    }\n\n    render(): React.ReactElement {\n        return(\n            <>\n                <div className=\"blocker\"></div>\n                <div className=\"dialog\">\n                    <div id=\"change-keybind-description\">{chrome.i18n.getMessage(\"keybindDescription\")}</div>\n                    <div id=\"change-keybind-settings\">\n                        <div id=\"change-keybind-modifiers\" className=\"inline\">\n                            <div>\n                                <input id=\"change-keybind-ctrl\" type=\"checkbox\" onChange={this.keybindModifierChecked} />\n                                <label htmlFor=\"change-keybind-ctrl\">Ctrl</label>\n                            </div>\n                            <div>\n                                <input id=\"change-keybind-alt\" type=\"checkbox\" onChange={this.keybindModifierChecked} />\n                                <label htmlFor=\"change-keybind-alt\">Alt</label>\n                            </div>\n                            <div>\n                                <input id=\"change-keybind-shift\" type=\"checkbox\" onChange={this.keybindModifierChecked} />\n                                <label htmlFor=\"change-keybind-shift\">Shift</label>\n                            </div>\n                        </div>\n                        <div className=\"key inline\">{formatKey(this.state.key.key)}</div>\n                    </div>\n                    <div id=\"change-keybind-error\">{this.state.error?.message}</div>\n                    <div id=\"change-keybind-buttons\">\n                        <div className={\"option-button save-button inline\" + ((this.state.error?.blocking || this.state.key.key == null) ? \" disabled\" : \"\")} onClick={() => this.save()}>\n                            {chrome.i18n.getMessage(\"save\")}\n                        </div>\n                        <div className=\"option-button cancel-button inline\" onClick={() => this.props.closeListener(null)}>\n                            {chrome.i18n.getMessage(\"cancel\")}\n                        </div>\n                    </div>\n                </div>\n            </>\n        );\n    }\n\n    componentDidMount(): void {\n        parent.document.addEventListener(\"keydown\", this.keybindKeyPressed);\n        document.addEventListener(\"keydown\", this.keybindKeyPressed);\n    }\n\n    componentWillUnmount(): void {\n        parent.document.removeEventListener(\"keydown\", this.keybindKeyPressed);\n        document.removeEventListener(\"keydown\", this.keybindKeyPressed);\n    }\n\n    keybindKeyPressed = (e: KeyboardEvent): void => {\n        if (!e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.getModifierState(\"AltGraph\")) {\n            if (e.code == \"Escape\") {\n                this.props.closeListener(null);\n                return;\n            }\n    \n            this.setState({\n                key: {\n                    key: e.key,\n                    code: e.code,\n                    ctrl: this.state.key.ctrl,\n                    alt: this.state.key.alt,\n                    shift: this.state.key.shift}\n            }, () => this.setState({ error: this.isKeybindAvailable() }));\n        }\n    }\n    \n    keybindModifierChecked = (e: ChangeEvent<HTMLInputElement>): void => {\n        const id = e.target.id;\n        const val = e.target.checked;\n    \n        this.setState({\n            key: {\n                key: this.state.key.key,\n                code: this.state.key.code,\n                ctrl: id == \"change-keybind-ctrl\" ? val: this.state.key.ctrl,\n                alt: id == \"change-keybind-alt\" ? val: this.state.key.alt,\n                shift: id == \"change-keybind-shift\" ? val: this.state.key.shift}\n        }, () => this.setState({ error: this.isKeybindAvailable() }));\n    }\n\n    isKeybindAvailable(): ErrorMessage {\n        if (this.state.key.key == null)\n            return null;\n\n        let youtubeShortcuts: Keybind[];\n        if (/[a-zA-Z0-9,.+\\-\\][:]/.test(this.state.key.key)) {\n            youtubeShortcuts = [{key: \"k\"}, {key: \"j\"}, {key: \"l\"}, {key: \"p\", shift: true}, {key: \"n\", shift: true}, {key: \",\"}, {key: \".\"}, {key: \",\", shift: true}, {key: \".\", shift: true},\n                {key: \"ArrowRight\"}, {key: \"ArrowLeft\"}, {key: \"ArrowUp\"}, {key: \"ArrowDown\"}, {key: \"c\"}, {key: \"o\"},\n                {key: \"w\"}, {key: \"+\"}, {key: \"-\"}, {key: \"f\"}, {key: \"t\"}, {key: \"i\"}, {key: \"m\"}, {key: \"a\"}, {key: \"s\"}, {key: \"d\"}, {key: \"Home\"}, {key: \"End\"},\n                {key: \"0\"}, {key: \"1\"}, {key: \"2\"}, {key: \"3\"}, {key: \"4\"}, {key: \"5\"}, {key: \"6\"}, {key: \"7\"}, {key: \"8\"}, {key: \"9\"}, {key: \"]\"}, {key: \"[\"}];\n        } else {\n            youtubeShortcuts = [{key: null, code: \"KeyK\"}, {key: null, code: \"KeyJ\"}, {key: null, code: \"KeyL\"}, {key: null, code: \"KeyP\", shift: true}, {key: null, code: \"KeyN\", shift: true},\n                {key: null, code: \"Comma\"}, {key: null, code: \"Period\"}, {key: null, code: \"Comma\", shift: true}, {key: null, code: \"Period\", shift: true}, {key: null, code: \"Space\"},\n                {key: null, code: \"KeyC\"}, {key: null, code: \"KeyO\"}, {key: null, code: \"KeyW\"}, {key: null, code: \"Equal\"}, {key: null, code: \"Minus\"}, {key: null, code: \"KeyF\"}, {key: null, code: \"KeyT\"},\n                {key: null, code: \"KeyI\"}, {key: null, code: \"KeyM\"}, {key: null, code: \"KeyA\"}, {key: null, code: \"KeyS\"}, {key: null, code: \"KeyD\"}, {key: null, code: \"BracketLeft\"}, {key: null, code: \"BracketRight\"}];\n        }\n        \n        for (const shortcut of youtubeShortcuts) {\n            const withShift = Object.assign({}, shortcut);\n            if (!/[0-9]/.test(this.state.key.key)) //shift+numbers don't seem to do anything on youtube, all other keys do\n                withShift.shift = true;\n            if (this.equals(shortcut) || this.equals(withShift))\n                return {message: chrome.i18n.getMessage(\"youtubeKeybindWarning\"), blocking: false};\n        }\n\n        if (this.props.option !== \"skipKeybind\" && this.equals(Config.config['skipKeybind']) ||\n                this.props.option !== \"submitKeybind\" && this.equals(Config.config['submitKeybind']) ||\n                this.props.option !== \"actuallySubmitKeybind\" && this.equals(Config.config['actuallySubmitKeybind']) ||\n                this.props.option !== \"previewKeybind\" && this.equals(Config.config['previewKeybind']) ||\n                this.props.option !== \"closeSkipNoticeKeybind\" && this.equals(Config.config['closeSkipNoticeKeybind']) ||\n                this.props.option !== \"startSponsorKeybind\" && this.equals(Config.config['startSponsorKeybind']) ||\n                this.props.option !== \"downvoteKeybind\" && this.equals(Config.config['downvoteKeybind']) ||\n                this.props.option !== \"upvoteKeybind\" && this.equals(Config.config['upvoteKeybind']))\n            return {message: chrome.i18n.getMessage(\"keyAlreadyUsed\"), blocking: true};\n\n        return null;\n    }\n\n    equals(other: Keybind): boolean {\n        return keybindEquals(this.state.key, other);\n    }\n\n    save(): void {\n        if (this.state.key.key != null && !this.state.error?.blocking) {\n            Config.config[this.props.option] = this.state.key;\n            this.props.closeListener(this.state.key);\n        }\n    }\n}\n\nexport default KeybindDialogComponent;"
  },
  {
    "path": "src/components/options/NumberInputOptionComponent.tsx",
    "content": "import * as React from \"react\";\nimport ResetIcon from \"../../svg-icons/resetIcon\";\n\nexport interface NumberInputOptionProps { \n    label: string;\n    description?: string;\n    disabled?: boolean;\n    style?: React.CSSProperties;\n    value: number;\n    onChange(value: number): void;\n    partiallyHidden?: boolean;\n    showResetButton?: boolean;\n    onReset?(): void;\n}\n\nexport function NumberInputOptionComponent(props: NumberInputOptionProps): React.ReactElement {\n    return (\n        <div className={`sb-number-option ${props.disabled ? \"disabled\" : \"\"} ${props.partiallyHidden ? \"partiallyHidden\" : \"\"}`}>\n            <div style={props.style}>\n                <label className=\"number-container\">\n                    <span className=\"optionLabel\">\n                        {props.label}\n                    </span>\n                    <input id={props.label} \n                        className=\"sb-number-input\"\n                        type=\"number\"\n                        step=\"0.1\"\n                        min=\"0\"\n                        value={props.value}\n                        disabled={props.disabled} \n                        onChange={(e) => props.onChange(Number(e.target.value))}/>\n                </label>\n\n                {\n                    props.showResetButton &&\n                        <span className=\"reset-button sb-switch-label\" title={chrome.i18n.getMessage(\"fallbackToDefault\")} onClick={() => {\n                            props.onReset?.();\n                        }}>\n                            <ResetIcon/>\n                        </span>\n                }\n            </div>\n\n            {\n                props.description &&\n                    <div className=\"small-description\">\n                        {props.description}\n                    </div>\n            }\n        </div>\n    );\n}"
  },
  {
    "path": "src/components/options/SelectOptionComponent.tsx",
    "content": "import * as React from \"react\";\nimport ResetIcon from \"../../svg-icons/resetIcon\";\n\nexport interface SelectOption {\n    value: string;\n    label: string;\n}\n\nexport interface SelectOptionComponentProps {\n    id: string;\n    onChange: (value: string) => void;\n    value: string;\n    label?: string;\n    title?: string;\n    options: SelectOption[];\n    style?: React.CSSProperties;\n    className?: string;\n    showResetButton?: boolean;\n    onReset?: () => void;\n    applyFormattingToOptions?: boolean;\n}\n\nexport const SelectOptionComponent = (props: SelectOptionComponentProps) => {\n    return (\n        <div className={`sb-optionContainer ${props.className ?? \"\"}`} style={props.style}>\n            {\n                props.label &&\n                    <label className=\"sb-optionLabel\" htmlFor={props.id}>\n                        {props.label}\n                    </label>\n            }\n            <select id={props.id}\n                className=\"sb-selector-element optionsSelector\"\n                value={props.value}\n                title={props.title}\n                onChange={(e) => {\n                    props.onChange(e.target.value);\n                }}>\n                {getOptions(props.options)}\n            </select>\n\n            {\n                props.showResetButton &&\n                <div className=\"reset-button\" onClick={() => {\n                    props.onReset?.();\n                }}>\n                    <ResetIcon/>\n                </div>\n            }\n        </div>\n    );\n};\n\nfunction getOptions(options: SelectOption[]): React.ReactNode[] {\n    return options.map((option) => {\n        return (\n            <option value={option.value} key={option.value}>{option.label}</option>\n        );\n    });\n}"
  },
  {
    "path": "src/components/options/ToggleOptionComponent.tsx",
    "content": "import * as React from \"react\";\nimport ResetIcon from \"../../svg-icons/resetIcon\";\n\nexport interface ToggleOptionProps { \n    label: string;\n    description?: string;\n    disabled?: boolean;\n    style?: React.CSSProperties;\n    checked: boolean | null;\n    onChange(checked: boolean): void;\n    partiallyHidden?: boolean;\n    showResetButton?: boolean;\n    onReset?(): void;\n}\n\nexport function ToggleOptionComponent(props: ToggleOptionProps): React.ReactElement {\n    return (\n        <div className={`sb-toggle-option ${props.disabled ? \"disabled\" : \"\"} ${props.partiallyHidden ? \"partiallyHidden\" : \"\"}`}>\n            <div className=\"switch-container\" style={props.style}>\n                <label className=\"switch\">\n                    <input id={props.label} \n                        type=\"checkbox\" \n                        checked={props.checked} \n                        disabled={props.disabled} \n                        onChange={(e) => props.onChange(e.target.checked)}/>\n                    <span className=\"slider round\"></span>\n                </label>\n                <label className=\"switch-label\" htmlFor={props.label}>\n                    {props.label}\n                </label>\n\n                {\n                    props.showResetButton &&\n                        <div className=\"reset-button sb-switch-label\" title={chrome.i18n.getMessage(\"fallbackToDefault\")} onClick={() => {\n                            props.onReset?.();\n                        }}>\n                            <ResetIcon/>\n                        </div>\n                }\n            </div>\n\n            {\n                props.description &&\n                    <div className=\"small-description\">\n                        {props.description}\n                    </div>\n            }\n        </div>\n    );\n}"
  },
  {
    "path": "src/components/options/UnsubmittedVideoListComponent.tsx",
    "content": "import * as React from \"react\";\n\nimport Config from \"../../config\";\nimport UnsubmittedVideoListItem from \"./UnsubmittedVideoListItem\";\n\nexport interface UnsubmittedVideoListProps {\n\n}\n\nexport interface UnsubmittedVideoListState {\n\n}\n\nclass UnsubmittedVideoListComponent extends React.Component<UnsubmittedVideoListProps, UnsubmittedVideoListState> {\n\n    constructor(props: UnsubmittedVideoListProps) {\n        super(props);\n\n        // Setup state\n        this.state = {\n\n        };\n    }\n\n    render(): React.ReactElement {\n        // Render nothing if there are no unsubmitted segments\n        if (Object.keys(Config.local.unsubmittedSegments).length == 0)\n            return <></>;\n\n        return (\n            <table id=\"unsubmittedVideosList\"\n                className=\"categoryChooserTable\"\n                style={{marginTop: \"10px\"}} >\n                <tbody>\n                    {/* Headers */}\n                    <tr id=\"UnsubmittedVideosListHeader\"\n                            className=\"categoryTableElement categoryTableHeader\">\n                        <th id=\"UnsubmittedVideoID\">\n                            {chrome.i18n.getMessage(\"videoID\")}\n                        </th>\n\n                        <th id=\"UnsubmittedSegmentCount\">\n                            {chrome.i18n.getMessage(\"segmentCount\")}\n                        </th>\n\n                        <th id=\"UnsubmittedVideoActions\">\n                            {chrome.i18n.getMessage(\"actions\")}\n                        </th>\n\n                    </tr>\n\n                    {this.getUnsubmittedVideos()}\n                </tbody>\n            </table>\n        );\n    }\n\n    getUnsubmittedVideos(): JSX.Element[] {\n        const elements: JSX.Element[] = [];\n\n        for (const videoID of Object.keys(Config.local.unsubmittedSegments)) {\n            elements.push(\n                <UnsubmittedVideoListItem videoID={videoID} key={videoID}>\n                </UnsubmittedVideoListItem>\n            );\n        }\n\n        return elements;\n    }\n}\n\nexport default UnsubmittedVideoListComponent;\n"
  },
  {
    "path": "src/components/options/UnsubmittedVideoListItem.tsx",
    "content": "import * as React from \"react\";\n\nimport Config from \"../../config\";\nimport { exportTimes, exportTimesAsHashParam } from \"../../utils/exporter\";\n\nexport interface UnsubmittedVideosListItemProps {\n    videoID: string;\n    children?: React.ReactNode;\n}\n\nexport interface UnsubmittedVideosListItemState {\n}\n\nclass UnsubmittedVideoListItem extends React.Component<UnsubmittedVideosListItemProps, UnsubmittedVideosListItemState> {\n\n    constructor(props: UnsubmittedVideosListItemProps) {\n        super(props);\n\n        // Setup state\n        this.state = {\n\n        };\n    }\n\n    render(): React.ReactElement {\n        const segmentCount = Config.local.unsubmittedSegments[this.props.videoID]?.length ?? 0;\n\n        return (\n            <>\n                <tr id={this.props.videoID + \"UnsubmittedSegmentsRow\"}\n                    className=\"categoryTableElement\">\n                    <td id={this.props.videoID + \"UnsubmittedVideoID\"}\n                        className=\"categoryTableLabel\">\n                        <a href={`https://youtu.be/${this.props.videoID}`}\n                           target=\"_blank\" rel=\"noreferrer\">\n                            {this.props.videoID}\n                        </a>\n                    </td>\n\n                    <td id={this.props.videoID + \"UnsubmittedSegmentCount\"}>\n                        {segmentCount}\n                    </td>\n\n                    <td id={this.props.videoID + \"UnsubmittedVideoActions\"}>\n                        <div id={this.props.videoID + \"ExportSegmentsAction\"}\n                             className=\"option-button inline low-profile\"\n                             onClick={this.exportSegments.bind(this)}>\n                            {chrome.i18n.getMessage(\"exportSegments\")}\n                        </div>\n                        {\" \"}\n                        <div id={this.props.videoID + \"ExportSegmentsAsURLAction\"}\n                             className=\"option-button inline low-profile\"\n                             onClick={this.exportSegmentsAsURL.bind(this)}>\n                            {chrome.i18n.getMessage(\"exportSegmentsAsURL\")}\n                        </div>\n                        {\" \"}\n                        <div id={this.props.videoID + \"ClearSegmentsAction\"}\n                             className=\"option-button inline low-profile\"\n                             onClick={this.clearSegments.bind(this)}>\n                            {chrome.i18n.getMessage(\"clearTimes\")}\n                        </div>\n                    </td>\n\n                </tr>\n\n            </>\n        );\n    }\n\n    clearSegments(): void {\n        if (confirm(chrome.i18n.getMessage(\"clearThis\"))) {\n            delete Config.local.unsubmittedSegments[this.props.videoID];\n            Config.forceLocalUpdate(\"unsubmittedSegments\");\n        }\n    }\n\n    exportSegments(): void {\n        this.copyToClipboard(exportTimes(Config.local.unsubmittedSegments[this.props.videoID]));\n    }\n\n    exportSegmentsAsURL(): void {\n        this.copyToClipboard(`https://youtube.com/watch?v=${this.props.videoID}${exportTimesAsHashParam(Config.local.unsubmittedSegments[this.props.videoID])}`)\n    }\n\n    copyToClipboard(text: string): void {\n        navigator.clipboard.writeText(text)\n            .then(() => {\n                alert(chrome.i18n.getMessage(\"CopiedExclamation\"));\n            })\n            .catch(() => {\n                alert(chrome.i18n.getMessage(\"copyDebugInformationFailed\"));\n            });\n    }\n}\n\nexport default UnsubmittedVideoListItem;\n"
  },
  {
    "path": "src/components/options/UnsubmittedVideosComponent.tsx",
    "content": "import * as React from \"react\";\nimport Config from \"../../config\";\nimport UnsubmittedVideoListComponent from \"./UnsubmittedVideoListComponent\";\n\nexport interface UnsubmittedVideosProps {\n\n}\n\nexport interface UnsubmittedVideosState {\n    tableVisible: boolean;\n}\n\nclass UnsubmittedVideosComponent extends React.Component<UnsubmittedVideosProps, UnsubmittedVideosState> {\n\n    constructor(props: UnsubmittedVideosProps) {\n        super(props);\n\n        this.state = {\n            tableVisible: false,\n        };\n    }\n\n    render(): React.ReactElement {\n        const videoCount = Object.keys(Config.local.unsubmittedSegments).length;\n        const segmentCount = Object.values(Config.local.unsubmittedSegments).reduce((acc: number, vid: Array<unknown>) => acc + vid.length, 0);\n\n        return <>\n            <div style={{marginBottom: \"10px\"}}>\n                {segmentCount == 0 ?\n                    chrome.i18n.getMessage(\"unsubmittedSegmentCountsZero\") :\n                    chrome.i18n.getMessage(\"unsubmittedSegmentCounts\")\n                        .replace(\"{0}\", `${segmentCount} ${chrome.i18n.getMessage(\"unsubmittedSegments\" + (segmentCount == 1 ? \"Singular\" : \"Plural\"))}`)\n                        .replace(\"{1}\", `${videoCount} ${chrome.i18n.getMessage(\"videos\" + (videoCount == 1 ? \"Singular\" : \"Plural\"))}`)\n                }\n            </div>\n\n            {videoCount > 0 && <div className=\"option-button inline\" onClick={() => this.setState({tableVisible: !this.state.tableVisible})}>\n                {chrome.i18n.getMessage(this.state.tableVisible ? \"hideUnsubmittedSegments\" : \"showUnsubmittedSegments\")}\n            </div>}\n            {\" \"}\n            <div className=\"option-button inline\" onClick={this.clearAllSegments}>\n                {chrome.i18n.getMessage(\"clearUnsubmittedSegments\")}\n            </div>\n\n            {this.state.tableVisible && <UnsubmittedVideoListComponent/>}\n        </>;\n    }\n\n    clearAllSegments(): void {\n        if (confirm(chrome.i18n.getMessage(\"clearUnsubmittedSegmentsConfirm\")))\n            Config.local.unsubmittedSegments = {};\n    }\n}\n\nexport default UnsubmittedVideosComponent;\n"
  },
  {
    "path": "src/config.ts",
    "content": "import * as CompileConfig from \"../config.json\";\nimport * as invidiousList from \"../ci/invidiouslist.json\";\nimport { Category, CategorySelection, CategorySkipOption, NoticeVisibilityMode, PreviewBarOption, SponsorHideType, SponsorTime, VideoID, SegmentListDefaultTab } from \"./types\";\nimport { Keybind, keybindEquals, ProtoConfig } from \"../maze-utils/src/config\";\nimport type { HashedValue } from \"../maze-utils/src/hash\";\nimport { AdvancedSkipCheck, AdvancedSkipPredicate, AdvancedSkipRule, Permission, PredicateOperator } from \"./utils/skipRule.type\";\n\ninterface SBConfig {\n    userID: string;\n    isVip: boolean;\n    permissions: Record<Category, Permission>;\n    defaultCategory: Category;\n    segmentListDefaultTab: SegmentListDefaultTab;\n    renderSegmentsAsChapters: boolean;\n    forceChannelCheck: boolean;\n    minutesSaved: number;\n    skipCount: number;\n    sponsorTimesContributed: number;\n    submissionCountSinceCategories: number; // New count used to show the \"Read The Guidelines!!\" message\n    showTimeWithSkips: boolean;\n    disableSkipping: boolean;\n    muteSegments: boolean;\n    fullVideoSegments: boolean;\n    fullVideoLabelsOnThumbnails: boolean;\n    manualSkipOnFullVideo: boolean;\n    trackViewCount: boolean;\n    trackViewCountInPrivate: boolean;\n    trackDownvotes: boolean;\n    trackDownvotesInPrivate: boolean;\n    dontShowNotice: boolean;\n    showUpcomingNotice: boolean;\n    noticeVisibilityMode: NoticeVisibilityMode;\n    hideVideoPlayerControls: boolean;\n    hideInfoButtonPlayerControls: boolean;\n    hideDeleteButtonPlayerControls: boolean;\n    hideUploadButtonPlayerControls: boolean;\n    hideSkipButtonPlayerControls: boolean;\n    hideDiscordLaunches: number;\n    hideDiscordLink: boolean;\n    invidiousInstances: string[];\n    supportInvidious: boolean;\n    serverAddress: string;\n    minDuration: number;\n    skipNoticeDuration: number;\n    audioNotificationOnSkip: boolean;\n    checkForUnlistedVideos: boolean;\n    testingServer: boolean;\n    ytInfoPermissionGranted: boolean;\n    allowExpirements: boolean;\n    showDonationLink: boolean;\n    showPopupDonationCount: number;\n    showUpsells: boolean;\n    showNewFeaturePopups: boolean;\n    donateClicked: number;\n    autoHideInfoButton: boolean;\n    autoSkipOnMusicVideos: boolean;\n    skipNonMusicOnlyOnYoutubeMusic: boolean;\n    colorPalette: {\n        red: string;\n        white: string;\n        locked: string;\n    };\n    scrollToEditTimeUpdate: boolean;\n    categoryPillUpdate: boolean;\n    hookUpdate: boolean;\n    showChapterInfoMessage: boolean;\n    darkMode: boolean;\n    showCategoryGuidelines: boolean;\n    showCategoryWithoutPermission: boolean;\n    showSegmentNameInChapterBar: boolean;\n    showAutogeneratedChapters: boolean;\n    useVirtualTime: boolean;\n    showSegmentFailedToFetchWarning: boolean;\n    allowScrollingToEdit: boolean;\n    deArrowInstalled: boolean;\n    showDeArrowPromotion: boolean;\n    showDeArrowInSettings: boolean;\n    shownDeArrowPromotion: boolean;\n    showZoomToFillError2: boolean;\n    cleanPopup: boolean;\n    hideSegmentCreationInPopup: boolean;\n    prideTheme: boolean;\n\n    // Used to cache calculated text color info\n    categoryPillColors: {\n        [key in Category]: {\n            lastColor: string;\n            textColor: string;\n        }\n    };\n\n    skipKeybind: Keybind;\n    skipToHighlightKeybind: Keybind;\n    startSponsorKeybind: Keybind;\n    submitKeybind: Keybind;\n    actuallySubmitKeybind: Keybind;\n    previewKeybind: Keybind;\n    nextChapterKeybind: Keybind;\n    previousChapterKeybind: Keybind;\n    closeSkipNoticeKeybind: Keybind;\n    upvoteKeybind: Keybind;\n    downvoteKeybind: Keybind;\n\n    // What categories should be skipped\n    categorySelections: CategorySelection[];\n\n    payments: {\n        licenseKey: string;\n        lastCheck: number;\n        lastFreeCheck: number;\n        freeAccess: boolean;\n        chaptersAllowed: boolean;\n    };\n\n    // Preview bar\n    barTypes: {\n        \"preview-chooseACategory\": PreviewBarOption;\n        \"sponsor\": PreviewBarOption;\n        \"preview-sponsor\": PreviewBarOption;\n        \"selfpromo\": PreviewBarOption;\n        \"preview-selfpromo\": PreviewBarOption;\n        \"exclusive_access\": PreviewBarOption;\n        \"interaction\": PreviewBarOption;\n        \"preview-interaction\": PreviewBarOption;\n        \"intro\": PreviewBarOption;\n        \"preview-intro\": PreviewBarOption;\n        \"outro\": PreviewBarOption;\n        \"preview-outro\": PreviewBarOption;\n        \"preview\": PreviewBarOption;\n        \"preview-preview\": PreviewBarOption;\n        \"music_offtopic\": PreviewBarOption;\n        \"preview-music_offtopic\": PreviewBarOption;\n        \"poi_highlight\": PreviewBarOption;\n        \"preview-poi_highlight\": PreviewBarOption;\n        \"filler\": PreviewBarOption;\n        \"preview-filler\": PreviewBarOption;\n    };\n}\n\nexport type VideoDownvotes = { segments: { uuid: HashedValue; hidden: SponsorHideType }[]; lastAccess: number };\n\nexport type ConfigurationID = string & { __configurationID: never };\n\nexport interface CustomConfiguration {\n    name: string;\n    categorySelections: CategorySelection[];\n    showAutogeneratedChapters: boolean | null;\n    autoSkipOnMusicVideos: boolean | null;\n    skipNonMusicOnlyOnYoutubeMusic: boolean | null;\n    muteSegments: boolean | null;\n    fullVideoSegments: boolean | null;\n    manualSkipOnFullVideo: boolean | null;\n    minDuration: number | null;\n}\n\ninterface SBStorage {\n    /* VideoID prefixes to UUID prefixes */\n    downvotedSegments: Record<VideoID & HashedValue, VideoDownvotes>;\n    navigationApiAvailable: boolean;\n\n    // Used when sync storage disabled\n    alreadyInstalled: boolean;\n\n    /* Contains unsubmitted segments that the user has created. */\n    unsubmittedSegments: Record<string, SponsorTime[]>;\n\n    channelSkipProfileIDs: Record<string, ConfigurationID>;\n    skipProfileTemp: { time: number; configID: ConfigurationID } | null;\n    skipProfiles: Record<ConfigurationID, CustomConfiguration>;\n\n    skipRules: AdvancedSkipRule[];\n}\n\nclass ConfigClass extends ProtoConfig<SBConfig, SBStorage> {\n    resetToDefault() {\n        chrome.storage.sync.set({\n            ...this.syncDefaults,\n            userID: this.config.userID,\n            minutesSaved: this.config.minutesSaved,\n            skipCount: this.config.skipCount,\n            sponsorTimesContributed: this.config.sponsorTimesContributed\n        });\n\n        chrome.storage.local.set({\n            ...this.localDefaults,\n        });\n    }\n}\n\nfunction migrateOldSyncFormats(config: SBConfig, local: SBStorage) {\n    if (local[\"skipRules\"] && local[\"skipRules\"].length !== 0 && local[\"skipRules\"][0][\"rules\"]) {\n        const output: AdvancedSkipRule[] = [];\n\n        for (const rule of local[\"skipRules\"]) {\n            const rules: object[] = rule[\"rules\"];\n\n            if (rules.length !== 0) {\n                let predicate: AdvancedSkipPredicate = {\n                    kind: \"check\",\n                    ...rules[0] as AdvancedSkipCheck,\n                };\n\n                for (let i = 1; i < rules.length; i++) {\n                    predicate = {\n                        kind: \"operator\",\n                        operator: PredicateOperator.And,\n                        left: predicate,\n                        right: {\n                            kind: \"check\",\n                            ...rules[i] as AdvancedSkipCheck,\n                        },\n                    };\n                }\n\n                const comment = rule[\"comment\"] as string;\n\n                output.push({\n                    predicate,\n                    skipOption: rule.skipOption,\n                    comments: comment.length === 0 ? [] : comment.split(/;\\s*/),\n                });\n            }\n        }\n\n        local[\"skipRules\"] = output;\n    }\n\n    if (config[\"whitelistedChannels\"]) {\n        // convert to skipProfiles\n        const whitelistedChannels = config[\"whitelistedChannels\"] as string[];\n        const skipProfileID: ConfigurationID = \"default-whitelist\" as ConfigurationID;\n\n        local.skipProfiles[skipProfileID] = {\n            name: chrome.i18n.getMessage(\"WhitelistedChannels\"),\n            categorySelections: config.categorySelections\n                .filter((s) => ![\"exclusive_access\", \"chapter\"].includes(s.name))\n                .map(s => ({\n                    name: s.name,\n                    option: CategorySkipOption.ShowOverlay\n            })),\n            showAutogeneratedChapters: null,\n            autoSkipOnMusicVideos: null,\n            skipNonMusicOnlyOnYoutubeMusic: null,\n            muteSegments: null,\n            fullVideoSegments: null,\n            manualSkipOnFullVideo: null,\n            minDuration: null\n        };\n        local.skipProfiles = local.skipProfiles;\n\n        for (const channelID of whitelistedChannels) {\n            local.channelSkipProfileIDs[channelID] = skipProfileID;\n        }\n        local.channelSkipProfileIDs = local.channelSkipProfileIDs;\n\n        chrome.storage.sync.remove(\"whitelistedChannels\");\n    }\n\n    if (!config[\"changeChapterColor\"]) {\n        config.barTypes[\"chapter\"].color = \"#ffd983\";\n        config[\"changeChapterColor\"] = true;\n        chrome.storage.sync.set({\n            \"changeChapterColor\": true,\n            \"barTypes\": config.barTypes\n        });\n    }\n\n    if (config[\"showZoomToFillError\"]) {\n        chrome.storage.sync.remove(\"showZoomToFillError\");\n    }\n\n    if (config[\"unsubmittedSegments\"] && Object.keys(config[\"unsubmittedSegments\"]).length > 0) {\n        chrome.storage.local.set({\n            unsubmittedSegments: config[\"unsubmittedSegments\"]\n        }, () => {\n            chrome.storage.sync.remove(\"unsubmittedSegments\");\n        });\n    }\n\n    if (!config[\"chapterCategoryAdded\"]) {\n        config[\"chapterCategoryAdded\"] = true;\n\n        if (!config.categorySelections.some((s) => s.name === \"chapter\")) {\n            config.categorySelections.push({\n                name: \"chapter\" as Category,\n                option: CategorySkipOption.ShowOverlay\n            });\n\n            config.categorySelections = config.categorySelections;\n        }\n    }\n\n    if (config[\"exclusive_accessCategoryAdded\"] !== undefined) {\n        chrome.storage.sync.remove(\"exclusive_accessCategoryAdded\");\n    }\n\n    if (config[\"fillerUpdate\"] !== undefined) {\n        chrome.storage.sync.remove(\"fillerUpdate\");\n    }\n    if (config[\"highlightCategoryAdded\"] !== undefined) {\n        chrome.storage.sync.remove(\"highlightCategoryAdded\");\n    }\n    if (config[\"highlightCategoryUpdate\"] !== undefined) {\n        chrome.storage.sync.remove(\"highlightCategoryUpdate\");\n    }\n\n    if (config[\"askAboutUnlistedVideos\"]) {\n        chrome.storage.sync.remove(\"askAboutUnlistedVideos\");\n    }\n\n    if (!config[\"autoSkipOnMusicVideosUpdate\"]) {\n        config[\"autoSkipOnMusicVideosUpdate\"] = true;\n        for (const selection of config.categorySelections) {\n            if (selection.name === \"music_offtopic\"\n                && selection.option === CategorySkipOption.AutoSkip) {\n\n                config.autoSkipOnMusicVideos = true;\n                break;\n            }\n        }\n    }\n\n    if (config[\"disableAutoSkip\"]) {\n        for (const selection of config.categorySelections) {\n            if (selection.name === \"sponsor\") {\n                selection.option = CategorySkipOption.ManualSkip;\n\n                chrome.storage.sync.remove(\"disableAutoSkip\");\n            }\n        }\n    }\n\n    if (typeof config[\"skipKeybind\"] == \"string\") {\n        config[\"skipKeybind\"] = { key: config[\"skipKeybind\"] };\n    }\n\n    if (typeof config[\"startSponsorKeybind\"] == \"string\") {\n        config[\"startSponsorKeybind\"] = { key: config[\"startSponsorKeybind\"] };\n    }\n\n    if (typeof config[\"submitKeybind\"] == \"string\") {\n        config[\"submitKeybind\"] = { key: config[\"submitKeybind\"] };\n    }\n\n    // Unbind key if it matches a previous one set by the user (should be ordered oldest to newest)\n    const keybinds = [\"skipKeybind\", \"startSponsorKeybind\", \"submitKeybind\"];\n    for (let i = keybinds.length - 1; i >= 0; i--) {\n        for (let j = 0; j < keybinds.length; j++) {\n            if (i == j)\n                continue;\n            if (keybindEquals(config[keybinds[i]], config[keybinds[j]]))\n                config[keybinds[i]] = null;\n        }\n    }\n\n    // Remove some old unused options\n    if (config[\"sponsorVideoID\"] !== undefined) {\n        chrome.storage.sync.remove(\"sponsorVideoID\");\n    }\n    if (config[\"previousVideoID\"] !== undefined) {\n        chrome.storage.sync.remove(\"previousVideoID\");\n    }\n\n    // populate invidiousInstances with new instances if 3p support is **DISABLED**\n    if (!config[\"supportInvidious\"] && config[\"invidiousInstances\"].length < invidiousList.length) {\n        config[\"invidiousInstances\"] = [...new Set([...invidiousList, ...config[\"invidiousInstances\"]])];\n    }\n\n    if (config[\"lastIsVipUpdate\"]) {\n        chrome.storage.sync.remove(\"lastIsVipUpdate\");\n    }\n}\n\nconst syncDefaults = {\n    userID: null,\n    isVip: false,\n    permissions: {},\n    defaultCategory: \"chooseACategory\" as Category,\n    segmentListDefaultTab: SegmentListDefaultTab.Segments,\n    renderSegmentsAsChapters: false,\n    forceChannelCheck: false,\n    minutesSaved: 0,\n    skipCount: 0,\n    sponsorTimesContributed: 0,\n    submissionCountSinceCategories: 0,\n    showTimeWithSkips: true,\n    disableSkipping: false,\n    muteSegments: true,\n    fullVideoSegments: true,\n    fullVideoLabelsOnThumbnails: true,\n    manualSkipOnFullVideo: false,\n    trackViewCount: true,\n    trackViewCountInPrivate: true,\n    trackDownvotes: true,\n    trackDownvotesInPrivate: false,\n    dontShowNotice: false,\n    showUpcomingNotice: false,\n    noticeVisibilityMode: NoticeVisibilityMode.FadedForAutoSkip,\n    hideVideoPlayerControls: false,\n    hideInfoButtonPlayerControls: false,\n    hideDeleteButtonPlayerControls: false,\n    hideUploadButtonPlayerControls: false,\n    hideSkipButtonPlayerControls: false,\n    hideDiscordLaunches: 0,\n    hideDiscordLink: false,\n    invidiousInstances: [],\n    supportInvidious: false,\n    serverAddress: CompileConfig.serverAddress,\n    minDuration: 0,\n    skipNoticeDuration: 4,\n    audioNotificationOnSkip: false,\n    checkForUnlistedVideos: false,\n    testingServer: false,\n    ytInfoPermissionGranted: false,\n    allowExpirements: true,\n    showDonationLink: true,\n    showPopupDonationCount: 0,\n    showUpsells: true,\n    showNewFeaturePopups: true,\n    donateClicked: 0,\n    autoHideInfoButton: true,\n    autoSkipOnMusicVideos: false,\n    skipNonMusicOnlyOnYoutubeMusic: false,\n    scrollToEditTimeUpdate: false, // false means the tooltip will be shown\n    categoryPillUpdate: false,\n    hookUpdate: false,\n    showChapterInfoMessage: true,\n    darkMode: true,\n    showCategoryGuidelines: true,\n    showCategoryWithoutPermission: false,\n    showSegmentNameInChapterBar: true,\n    showAutogeneratedChapters: true,\n    useVirtualTime: true,\n    showSegmentFailedToFetchWarning: true,\n    allowScrollingToEdit: true,\n    deArrowInstalled: false,\n    showDeArrowPromotion: true,\n    showDeArrowInSettings: true,\n    shownDeArrowPromotion: false,\n    showZoomToFillError2: true,\n    cleanPopup: false,\n    hideSegmentCreationInPopup: false,\n    prideTheme: false,\n\n    categoryPillColors: {},\n\n    /**\n     * Default keybinds should not set \"code\" as that's gonna be different based on the user's locale. They should also only use EITHER ctrl OR alt modifiers (or none).\n     * Using ctrl+alt, or shift may produce a different character that we will not be able to recognize in different locales.\n     * The exception for shift is letters, where it only capitalizes. So shift+A is fine, but shift+1 isn't.\n     * Don't forget to add the new keybind to the checks in \"KeybindDialogComponent.isKeybindAvailable()\" and in \"migrateOldFormats()\"!\n     *      TODO: Find a way to skip having to update these checks. Maybe storing keybinds in a Map?\n     */\n    skipKeybind: { key: \"Enter\" },\n    skipToHighlightKeybind: { key: \"Enter\", ctrl: true },\n    startSponsorKeybind: { key: \";\" },\n    submitKeybind: { key: \"'\" },\n    actuallySubmitKeybind: { key: \"'\", ctrl: true },\n    previewKeybind: { key: \";\", ctrl: true },\n    nextChapterKeybind: { key: \"ArrowRight\", ctrl: true },\n    previousChapterKeybind: { key: \"ArrowLeft\", ctrl: true },\n    closeSkipNoticeKeybind: { key: \"Backspace\" },\n    downvoteKeybind: { key: \"h\", shift: true },\n    upvoteKeybind: { key: \"g\", shift: true },\n\n    categorySelections: [{\n        name: \"sponsor\" as Category,\n        option: CategorySkipOption.AutoSkip\n    }, {\n        name: \"poi_highlight\" as Category,\n        option: CategorySkipOption.ManualSkip\n    }, {\n        name: \"exclusive_access\" as Category,\n        option: CategorySkipOption.ShowOverlay\n    }, {\n        name: \"chapter\" as Category,\n        option: CategorySkipOption.ShowOverlay\n    }],\n\n    payments: {\n        licenseKey: null,\n        lastCheck: 0,\n        lastFreeCheck: 0,\n        freeAccess: false,\n        chaptersAllowed: false\n    },\n\n    colorPalette: {\n        red: \"#780303\",\n        white: \"#ffffff\",\n        locked: \"#ffc83d\"\n    },\n\n    // Preview bar\n    barTypes: {\n        \"preview-chooseACategory\": {\n            color: \"#ffffff\",\n            opacity: \"0.7\"\n        },\n        \"sponsor\": {\n            color: \"#00d400\",\n            opacity: \"0.7\"\n        },\n        \"preview-sponsor\": {\n            color: \"#007800\",\n            opacity: \"0.7\"\n        },\n        \"selfpromo\": {\n            color: \"#ffff00\",\n            opacity: \"0.7\"\n        },\n        \"preview-selfpromo\": {\n            color: \"#bfbf35\",\n            opacity: \"0.7\"\n        },\n        \"exclusive_access\": {\n            color: \"#008a5c\",\n            opacity: \"0.7\"\n        },\n        \"interaction\": {\n            color: \"#cc00ff\",\n            opacity: \"0.7\"\n        },\n        \"preview-interaction\": {\n            color: \"#6c0087\",\n            opacity: \"0.7\"\n        },\n        \"intro\": {\n            color: \"#00ffff\",\n            opacity: \"0.7\"\n        },\n        \"preview-intro\": {\n            color: \"#008080\",\n            opacity: \"0.7\"\n        },\n        \"outro\": {\n            color: \"#0202ed\",\n            opacity: \"0.7\"\n        },\n        \"preview-outro\": {\n            color: \"#000070\",\n            opacity: \"0.7\"\n        },\n        \"preview\": {\n            color: \"#008fd6\",\n            opacity: \"0.7\"\n        },\n        \"preview-preview\": {\n            color: \"#005799\",\n            opacity: \"0.7\"\n        },\n        \"hook\": {\n            color: \"#395699\",\n            opacity: \"0.8\"\n        },\n        \"preview-hook\": {\n            color: \"#273963\",\n            opacity: \"0.7\"\n        },\n        \"music_offtopic\": {\n            color: \"#ff9900\",\n            opacity: \"0.7\"\n        },\n        \"preview-music_offtopic\": {\n            color: \"#a6634a\",\n            opacity: \"0.7\"\n        },\n        \"poi_highlight\": {\n            color: \"#ff1684\",\n            opacity: \"0.7\"\n        },\n        \"preview-poi_highlight\": {\n            color: \"#9b044c\",\n            opacity: \"0.7\"\n        },\n        \"filler\": {\n            color: \"#7300FF\",\n            opacity: \"0.9\"\n        },\n        \"preview-filler\": {\n            color: \"#2E0066\",\n            opacity: \"0.7\"\n        },\n        \"chapter\": {\n            color: \"#ffd983\",\n            opacity: \"0\"\n        },\n    }\n};\n\nconst localDefaults = {\n    downvotedSegments: {},\n    navigationApiAvailable: null,\n    alreadyInstalled: false,\n\n    unsubmittedSegments: {},\n    skipRules: [],\n\n    channelSkipProfileIDs: {},\n    skipProfiles: {},\n    skipProfileTemp: null\n};\n\nconst Config = new ConfigClass(syncDefaults, localDefaults, migrateOldSyncFormats);\nexport default Config;\n\nexport function generateDebugDetails(): string {\n    // Build output debug information object\n    const output = {\n        debug: {\n            userAgent: navigator.userAgent,\n            platform: navigator.platform,\n            language: navigator.language,\n            extensionVersion: chrome.runtime.getManifest().version\n        },\n        config: JSON.parse(JSON.stringify(Config.cachedSyncConfig)) // Deep clone config object\n    };\n\n    // Sanitise sensitive user config values\n    delete output.config.userID;\n    output.config.serverAddress = (output.config.serverAddress === CompileConfig.serverAddress)\n        ? \"Default server address\" : \"Custom server address\";\n    output.config.invidiousInstances = output.config.invidiousInstances.length;\n    output.config.skipRules = output.config.skipRules.length;\n\n    return JSON.stringify(output, null, 4);\n}\n"
  },
  {
    "path": "src/content.ts",
    "content": "import Config from \"./config\";\nimport {\n    ActionType,\n    Category,\n    CategorySkipOption,\n    ChannelIDStatus,\n    ContentContainer,\n    ScheduledTime,\n    SegmentUUID,\n    SkipToTimeParams,\n    SponsorHideType,\n    SponsorSourceType,\n    SponsorTime,\n    ToggleSkippable,\n    VideoID,\n    VideoInfo,\n} from \"./types\";\nimport Utils from \"./utils\";\nimport PreviewBar, { PreviewBarSegment } from \"./js-components/previewBar\";\nimport SkipNotice from \"./render/SkipNotice\";\nimport SkipNoticeComponent from \"./components/SkipNoticeComponent\";\nimport UpcomingNotice from \"./render/UpcomingNotice\";\nimport SubmissionNotice from \"./render/SubmissionNotice\";\nimport { Message, MessageResponse, VoteResponse } from \"./messageTypes\";\nimport { SkipButtonControlBar } from \"./js-components/skipButtonControlBar\";\nimport { getStartTimeFromUrl } from \"./utils/urlParser\";\nimport { getControls, getExistingChapters, getHashParams, hasAutogeneratedChapters, isPlayingPlaylist, isVisible } from \"./utils/pageUtils\";\nimport { CategoryPill } from \"./render/CategoryPill\";\nimport { AnimationUtils } from \"../maze-utils/src/animationUtils\";\nimport { GenericUtils } from \"./utils/genericUtils\";\nimport { logDebug, logWarn } from \"./utils/logger\";\nimport { importTimes } from \"./utils/exporter\";\nimport { ChapterVote } from \"./render/ChapterVote\";\nimport { openWarningDialog } from \"./utils/warnings\";\nimport { extensionUserAgent, isFirefoxOrSafari, waitFor } from \"../maze-utils/src\";\nimport { formatJSErrorMessage, getFormattedTime, getLongErrorMessage } from \"../maze-utils/src/formating\";\nimport { getChannelIDInfo, getVideo, getIsAdPlaying, getIsLivePremiere, setIsAdPlaying, checkVideoIDChange, getVideoID, getYouTubeVideoID, setupVideoModule, checkIfNewVideoID, isOnInvidious, isOnMobileYouTube, isOnYouTubeMusic, isOnYTTV, getLastNonInlineVideoID, triggerVideoIDChange, triggerVideoElementChange, getIsInline, getCurrentTime, setCurrentTime, getVideoDuration, verifyCurrentTime, waitForVideo } from \"../maze-utils/src/video\";\nimport { Keybind, StorageChangesObject, isSafari, keybindEquals, keybindToString } from \"../maze-utils/src/config\";\nimport { findValidElement } from \"../maze-utils/src/dom\"\nimport { getHash, HashedValue } from \"../maze-utils/src/hash\";\nimport { generateUserID } from \"../maze-utils/src/setup\";\nimport { updateAll } from \"../maze-utils/src/thumbnailManagement\";\nimport { setupThumbnailListener } from \"./utils/thumbnails\";\nimport * as documentScript from \"../dist/js/document.js\";\nimport { isVorapisInstalled, runCompatibilityChecks } from \"./utils/compatibility\";\nimport { cleanPage } from \"./utils/pageCleaner\";\nimport { addCleanupListener } from \"../maze-utils/src/cleanup\";\nimport { hideDeArrowPromotion, tryShowingDeArrowPromotion } from \"./dearrowPromotion\";\nimport { asyncRequestToServer } from \"./utils/requests\";\nimport { isMobileControlsOpen } from \"./utils/mobileUtils\";\nimport { defaultPreviewTime } from \"./utils/constants\";\nimport { onVideoPage } from \"../maze-utils/src/pageInfo\";\nimport { getSegmentsForVideo } from \"./utils/segmentData\";\nimport { getCategoryDefaultSelection, getCategorySelection } from \"./utils/skipRule\";\nimport { getSkipProfileBool, getSkipProfileIDForTab, hideTooShortSegments, setCurrentTabSkipProfile } from \"./utils/skipProfiles\";\nimport { FetchResponse, logRequest } from \"../maze-utils/src/background-request-proxy\";\n\ncleanPage();\n\nconst utils = new Utils();\n\nutils.wait(() => Config.isReady(), 5000, 10).then(() => {\n    // Hack to get the CSS loaded on permission-based sites (Invidious)\n    addCSS();\n    setCategoryColorCSSVariables();\n\n    runCompatibilityChecks();\n});\n\nconst skipBuffer = 0.003;\n// If this close to the end, skip to the end\nconst endTimeSkipBuffer = 0.5;\n\n//was sponsor data found when doing SponsorsLookup\nlet sponsorDataFound = false;\n//the actual sponsorTimes if loaded and UUIDs associated with them\nlet sponsorTimes: SponsorTime[] = [];\nlet existingChaptersImported = false;\nlet importingChaptersWaitingForFocus = false;\nlet importingChaptersWaiting = false;\nlet loopedChapter :SponsorTime = null;\n// List of open skip notices\nconst skipNotices: SkipNotice[] = [];\nlet upcomingNotice: UpcomingNotice | null = null;\nlet activeSkipKeybindElement: ToggleSkippable = null;\nlet shownSegmentFailedToFetchWarning = false;\nlet selectedSegment: SegmentUUID | null = null;\nlet previewedSegment = false;\n\n// JSON video info\nlet videoInfo: VideoInfo = null;\n// Locked Categories in this tab, like: [\"sponsor\",\"intro\",\"outro\"]\nlet lockedCategories: Category[] = [];\n// Used to calculate a more precise \"virtual\" video time\nconst lastKnownVideoTime: { videoTime: number; preciseTime: number; fromPause: boolean; approximateDelay: number } = {\n    videoTime: null,\n    preciseTime: null,\n    fromPause: false,\n    approximateDelay: null,\n};\n// It resumes with a slightly later time on chromium\nlet lastTimeFromWaitingEvent: number = null;\nconst lastNextChapterKeybind = {\n    time: 0,\n    date: 0\n};\n\n// Skips are scheduled to ensure precision.\n// Skips are rescheduled every seeking event.\n// Skips are canceled every seeking event\nlet currentSkipSchedule: NodeJS.Timeout = null;\nlet currentSkipInterval: NodeJS.Timeout = null;\nlet currentVirtualTimeInterval: NodeJS.Timeout = null;\nlet currentUpcomingSchedule: NodeJS.Timeout = null;\n\n/** Has the sponsor been skipped */\nlet sponsorSkipped: boolean[] = [];\n\nlet videoMuted = false; // Has it been attempted to be muted\nconst controlsWithEventListeners: HTMLElement[] = [];\n\nsetupVideoModule({\n    videoIDChange,\n    channelIDChange,\n    videoElementChange,\n    playerInit: () => {\n        previewBar = null; // remove old previewbar\n        createPreviewBar();\n    },\n    updatePlayerBar: () => {\n        updatePreviewBar();\n        updateVisibilityOfPlayerControlsButton();\n    },\n    resetValues,\n    documentScript: chrome.runtime.getManifest().manifest_version === 2 ? documentScript : undefined\n}, () => Config);\nsetupThumbnailListener();\n\n// Is the video currently being switched\nlet switchingVideos = null;\n\n// Used by the play and playing listeners to make sure two aren't\n// called at the same time\nlet lastCheckTime = 0;\nlet lastCheckVideoTime = -1;\n\n// To determine if a video resolution change is happening\nlet firstPlay = true;\n\nlet previewBar: PreviewBar = null;\n// Skip to highlight button\nlet skipButtonControlBar: SkipButtonControlBar = null;\n// For full video sponsors/selfpromo\nlet categoryPill: CategoryPill = null;\n\n/** Element containing the player controls on the YouTube player. */\nlet controls: HTMLElement | null = null;\n\n/** Contains buttons created by `createButton()`. */\nconst playerButtons: Record<string, {button: HTMLButtonElement; image: HTMLImageElement; setupListener: boolean}> = {};\n\naddHotkeyListener();\n\n/** Segments created by the user which have not yet been submitted. */\nlet sponsorTimesSubmitting: SponsorTime[] = [];\nlet loadedPreloadedSegment = false;\n\n//becomes true when isInfoFound is called\n//this is used to close the popup on YouTube when the other popup opens\nlet popupInitialised = false;\n\nlet submissionNotice: SubmissionNotice = null;\n\nlet lastResponseStatus: number | Error | string;\n\n// Contains all of the functions and variables needed by the skip notice\nconst skipNoticeContentContainer: ContentContainer = () => ({\n    vote,\n    dontShowNoticeAgain,\n    unskipSponsorTime,\n    sponsorTimes,\n    sponsorTimesSubmitting,\n    skipNotices,\n    sponsorVideoID: getVideoID(),\n    reskipSponsorTime,\n    updatePreviewBar,\n    onMobileYouTube: isOnMobileYouTube(),\n    sponsorSubmissionNotice: submissionNotice,\n    resetSponsorSubmissionNotice,\n    updateEditButtonsOnPlayer,\n    previewTime,\n    videoInfo,\n    getRealCurrentTime: getRealCurrentTime,\n    lockedCategories,\n    channelIDInfo: getChannelIDInfo()\n});\n\n// value determining when to count segment as skipped and send telemetry to server (percent based)\nconst manualSkipPercentCount = 0.5;\n\n//get messages from the background script and the popup\nchrome.runtime.onMessage.addListener(messageListener);\n\nfunction messageListener(request: Message, sender: unknown, sendResponse: (response: MessageResponse) => void): void | boolean {\n    //messages from popup script\n    switch(request.message){\n        case \"update\":\n            checkVideoIDChange();\n            break;\n        case \"sponsorStart\":\n            startOrEndTimingNewSegment()\n\n            sendResponse({\n                creatingSegment: isSegmentCreationInProgress(),\n            });\n\n            break;\n        case \"isInfoFound\":\n            //send the sponsor times along with if it's found\n            sendResponse({\n                found: sponsorDataFound,\n                status: lastResponseStatus,\n                sponsorTimes: sponsorTimes.filter((segment) => getCategorySelection(segment).option !== CategorySkipOption.Disabled),\n                time: getCurrentTime() ?? 0,\n                onMobileYouTube: isOnMobileYouTube(),\n                videoID: getVideoID(),\n                loopedChapter: loopedChapter?.UUID,\n                channelID: getChannelIDInfo().id,\n                channelAuthor: getChannelIDInfo().author,\n                currentTabSkipProfileID: getSkipProfileIDForTab()\n            });\n\n            if (!request.updating && popupInitialised && document.getElementById(\"sponsorBlockPopupContainer\") != null) {\n                //the popup should be closed now that another is opening\n                closeInfoMenu();\n            }\n\n            popupInitialised = true;\n            break;\n        case \"getChannelID\":\n            sendResponse({\n                channelID: getChannelIDInfo().id,\n                isYTTV: (document.location.host === \"tv.youtube.com\")\n            });\n\n            break;\n        case \"submitTimes\":\n            openSubmissionMenu();\n            break;\n        case \"refreshSegments\":\n            // update video on refresh if videoID invalid\n            if (!getVideoID()) {\n                checkVideoIDChange();\n            }\n            // if popup rescieves no response, or the videoID is invalid,\n            // it will assume the page is not a video page and stop the refresh animation\n            sendResponse({ hasVideo: getVideoID() != null });\n            // fetch segments\n            if (getVideoID()) {\n                sponsorsLookup(false, true);\n            }\n\n            break;\n        case \"unskip\":\n            unskipSponsorTime(sponsorTimes.find((segment) => segment.UUID === request.UUID), null, true);\n            break;\n        case \"reskip\":\n            reskipSponsorTime(sponsorTimes.find((segment) => segment.UUID === request.UUID), true);\n            break;\n        case \"selectSegment\":\n            selectSegment(request.UUID);\n            break;\n        case \"submitVote\":\n            vote(request.type, request.UUID).then((response) => sendResponse(response));\n            return true;\n        case \"hideSegment\":\n            utils.getSponsorTimeFromUUID(sponsorTimes, request.UUID).hidden = request.type;\n            utils.addHiddenSegment(getVideoID(), request.UUID, request.type);\n            updatePreviewBar();\n\n            if (skipButtonControlBar?.isEnabled()\n                && sponsorTimesSubmitting.every((s) => s.hidden !== SponsorHideType.Visible || s.actionType !== ActionType.Poi)) {\n                skipButtonControlBar.disable();\n            }\n            break;\n        case \"closePopup\":\n            closeInfoMenu();\n            break;\n        case \"copyToClipboard\":\n            navigator.clipboard.writeText(request.text);\n            break;\n        case \"loopChapter\":\n            if (!request.UUID){\n                loopedChapter = null;\n                break;\n            }\n            loopedChapter = {...utils.getSponsorTimeFromUUID(sponsorTimes, request.UUID)};\n            loopedChapter.segment = [loopedChapter.segment[1], loopedChapter.segment[0]];\n            break;\n        case \"importSegments\": {\n            const importedSegments = importTimes(request.data, getVideoDuration());\n            let addedSegments = false;\n            for (const segment of importedSegments) {\n                if (!sponsorTimesSubmitting.some(\n                        (s) => Math.abs(s.segment[0] - segment.segment[0]) < 1\n                            && Math.abs(s.segment[1] - segment.segment[1]) < 1\n                            && s.description === segment.description)) {\n                    const hasChaptersPermission = (Config.config.showCategoryWithoutPermission\n                        || Config.config.permissions[\"chapter\"]);\n                    if (segment.category === \"chapter\" && (!getCategoryDefaultSelection(\"chapter\") || !hasChaptersPermission)) {\n                        segment.category = \"chooseACategory\" as Category;\n                        segment.actionType = ActionType.Skip;\n                        segment.description = \"\";\n                    }\n\n                    sponsorTimesSubmitting.push(segment);\n                    addedSegments = true;\n                }\n            }\n\n            if (addedSegments) {\n                Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;\n                Config.forceLocalUpdate(\"unsubmittedSegments\");\n\n                updateEditButtonsOnPlayer();\n                updateSponsorTimesSubmitting(false);\n                openSubmissionMenu();\n            }\n\n            sendResponse({\n                importedSegments\n            });\n            break;\n        }\n        case \"keydown\":\n            (document.body || document).dispatchEvent(new KeyboardEvent('keydown', {\n                key: request.key,\n                keyCode: request.keyCode,\n                code: request.code,\n                which: request.which,\n                shiftKey: request.shiftKey,\n                ctrlKey: request.ctrlKey,\n                altKey: request.altKey,\n                metaKey: request.metaKey\n            }));\n            break;\n        case \"getLogs\":\n            sendResponse({\n                debug: window[\"SBLogs\"].debug,\n                warn: window[\"SBLogs\"].warn\n            });\n            break;\n        case \"setCurrentTabSkipProfile\":\n            setCurrentTabSkipProfile(request.configID);\n            channelIDChange();\n            break;\n    }\n\n    sendResponse({});\n}\n\n/**\n * Called when the config is updated\n */\nfunction contentConfigUpdateListener(changes: StorageChangesObject) {\n    for (const key in changes) {\n        switch(key) {\n            case \"hideVideoPlayerControls\":\n            case \"hideInfoButtonPlayerControls\":\n            case \"hideDeleteButtonPlayerControls\":\n                updateVisibilityOfPlayerControlsButton()\n                break;\n            case \"categorySelections\":\n                channelIDChange();\n                break;\n            case \"barTypes\":\n                setCategoryColorCSSVariables();\n                break;\n            case \"fullVideoSegments\":\n            case \"fullVideoLabelsOnThumbnails\":\n                updateAll();\n                break;\n        }\n    }\n}\nfunction contentLocalConfigUpdateListener(changes: StorageChangesObject) {\n    for (const key in changes) {\n        switch(key) {\n            case \"channelSkipProfileIDs\":\n            case \"skipProfiles\":\n            case \"skipProfileTemp\":\n            case \"skipRules\":\n                channelIDChange();\n                break;\n        }\n    }\n}\n\nif (!window.location.href.includes(\"youtube.com/live_chat\")) {\n    if (!Config.configSyncListeners.includes(contentConfigUpdateListener)) {\n        Config.configSyncListeners.push(contentConfigUpdateListener);\n    }\n\n    if (!Config.configLocalListeners.includes(contentLocalConfigUpdateListener)) {\n        Config.configLocalListeners.push(contentLocalConfigUpdateListener);\n    }\n}\n\nfunction resetValues() {\n    lastCheckTime = 0;\n    lastCheckVideoTime = -1;\n    previewedSegment = false;\n    firstPlay = true;\n\n    sponsorTimes = [];\n    existingChaptersImported = false;\n    sponsorSkipped = [];\n    loopedChapter = null;\n    lastResponseStatus = 0;\n    shownSegmentFailedToFetchWarning = false;\n\n    videoInfo = null;\n    lockedCategories = [];\n\n    //empty the preview bar\n    if (previewBar !== null) {\n        previewBar.clear();\n    }\n\n    //reset sponsor data found check\n    sponsorDataFound = false;\n\n    // When first loading a video, it is not switching videos\n    // Hover play also doesn't need this check\n    if (switchingVideos === null || !onVideoPage()) {\n        switchingVideos = false;\n    } else {\n        switchingVideos = true;\n        logDebug(\"Setting switching videos to true (reset data)\");\n    }\n\n    skipButtonControlBar?.disable();\n    categoryPill?.setVisibility(false);\n\n    for (let i = 0; i < skipNotices.length; i++) {\n        skipNotices.pop()?.close();\n    }\n\n    if (upcomingNotice) {\n        upcomingNotice.close();\n        upcomingNotice = null;\n    }\n\n    hideDeArrowPromotion();\n}\n\nfunction videoIDChange(): void {\n    //setup the preview bar\n    if (previewBar === null) {\n        if (isOnMobileYouTube()) {\n            // Mobile YouTube workaround\n            const observer = new MutationObserver(handleMobileControlsMutations);\n            let controlsContainer = null;\n\n            utils.wait(() => {\n                controlsContainer = document.getElementById(\"player-control-container\")\n                return controlsContainer !== null\n            }).then(() => {\n                observer.observe(document.getElementById(\"player-control-container\"), {\n                    attributes: true,\n                    childList: true,\n                    subtree: true\n                });\n            }).catch();\n        } else {\n            utils.wait(getControls).then(createPreviewBar);\n        }\n    }\n\n    // Notify the popup about the video change\n    chrome.runtime.sendMessage({\n        message: \"videoChanged\",\n        videoID: getVideoID(),\n        channelID: getChannelIDInfo().id,\n        channelAuthor: getChannelIDInfo().author\n    });\n\n    sponsorsLookup();\n\n    // Make sure all player buttons are properly added\n    updateVisibilityOfPlayerControlsButton();\n\n    // Clear unsubmitted segments from the previous video\n    sponsorTimesSubmitting = [];\n    updateSponsorTimesSubmitting();\n\n    tryShowingDeArrowPromotion().catch(logWarn);\n\n    checkPreviewbarState();\n\n    if (getIsInline()) {\n        // Hover preview progress bar can take some time to appear\n        //   and if the miniplayer is also active then it would \n        //   attach to the wrong one\n        setTimeout(checkPreviewbarState, 500);\n        setTimeout(checkPreviewbarState, 1000);\n        setTimeout(checkPreviewbarState, 3000);\n    }\n}\n\nfunction handleMobileControlsMutations(): void {\n    // Don't update while scrubbing\n    if (!chrome.runtime?.id \n            || document.querySelector(\".YtProgressBarProgressBarPlayheadDotInDragging\")) return;\n\n    updateVisibilityOfPlayerControlsButton();\n\n    skipButtonControlBar?.updateMobileControls();\n\n    if (previewBar !== null) {\n        if (!previewBar.parent.contains(previewBar.container) && isMobileControlsOpen()) {\n            previewBar.createElement();\n            updatePreviewBar();\n\n            return;\n        } else if (!previewBar.parent.isConnected) {\n            // The parent does not exist anymore, remove that old preview bar\n            previewBar.remove();\n            previewBar = null;\n        }\n    }\n\n    // Create the preview bar if needed (the function hasn't returned yet)\n    createPreviewBar();\n}\n\nfunction getPreviewBarAttachElement(): HTMLElement | null {\n    const progressElementOptions = [{\n            // For newer mobile YouTube (Sept 2024)\n            selector: \".ytChapteredProgressBarHost, .ytProgressBarLineHost, .YtProgressBarLineHost, .YtChapteredProgressBarHost\",\n            isVisibleCheck: true\n        }, {\n            // For newer mobile YouTube (May 2024)\n            selector: \".YtmProgressBarProgressBarLine\",\n            isVisibleCheck: true\n        }, {\n            // For desktop YouTube hover play\n            // Priority is given to the hover play progress bar over the main progress bar\n            //   for miniplayer + hover preview case\n            // Second is new hover play selector\n            selector: \"#video-preview .ytp-progress-bar, #video-preview .YtProgressBarLineHost\",\n            isVisibleCheck: true\n        }, {\n            // For desktop YouTube\n            selector: \".ytp-progress-bar\",\n            isVisibleCheck: true\n        }, {\n            // For desktop YouTube\n            selector: \".no-model.cue-range-marker\",\n            isVisibleCheck: true\n        }, {\n            // For Invidious/VideoJS\n            selector: \".vjs-progress-holder\",\n            isVisibleCheck: false\n        }, {\n            // For Youtube Music and YTKids\n            // there are two sliders, one for volume and one for progress - both called #progressContainer\n            selector: \"#progress-bar>#sliderContainer>div>#sliderBar>#progressContainer\",\n        }, {\n            // For piped\n            selector: \".shaka-ad-markers\",\n            isVisibleCheck: false\n        }, {\n            // For Vorapis v3\n            selector: \".ytp-progress-bar-container > .html5-progress-bar > .ytp-progress-list\"\n        }, {\n            // For YTTV\n            selector: \".yssi-slider > div.ytu-ss-timeline-container\",\n            isVisibleCheck: false\n        }\n    ];\n\n    for (const option of progressElementOptions) {\n        const allElements = document.querySelectorAll(option.selector) as NodeListOf<HTMLElement>;\n        const el = option.isVisibleCheck ? findValidElement(allElements) : allElements[0];\n\n        if (el) {\n            return el;\n        }\n    }\n\n    return null;\n}\n\n/**\n * Creates a preview bar on the video\n */\nfunction createPreviewBar(): void {\n    if (previewBar !== null) return;\n\n    const el = getPreviewBarAttachElement();\n\n    if (el) {\n        const chapterVote = new ChapterVote(voteAsync);\n        previewBar = new PreviewBar(el, isOnMobileYouTube(), isOnInvidious(), isOnYTTV(), chapterVote, () => importExistingChapters(true));\n\n        updatePreviewBar();\n    }\n}\n\n/**\n * Triggered every time the video duration changes.\n * This happens when the resolution changes or at random time to clear memory.\n */\nfunction durationChangeListener(): void {\n    updateAdFlag();\n    updatePreviewBar();\n}\n\n/**\n * Triggered once the video is ready.\n * This is mainly to attach to embedded players who don't have a video element visible.\n */\nfunction videoOnReadyListener(): void {\n    createPreviewBar();\n    updatePreviewBar();\n    updateVisibilityOfPlayerControlsButton()\n}\n\nfunction cancelSponsorSchedule(): void {\n    logDebug(\"Pausing skipping\");\n\n    if (currentSkipSchedule !== null) {\n        clearTimeout(currentSkipSchedule);\n        currentSkipSchedule = null;\n    }\n\n    if (currentSkipInterval !== null) {\n        clearInterval(currentSkipInterval);\n        currentSkipInterval = null;\n    }\n\n    if (currentUpcomingSchedule !== null) {\n        clearTimeout(currentUpcomingSchedule);\n        currentUpcomingSchedule = null;\n    }\n}\n\n/**\n * @param currentTime Optional if you don't want to use the actual current time\n */\nasync function startSponsorSchedule(includeIntersectingSegments = false, currentTime?: number, includeNonIntersectingSegments = true): Promise<void> {\n    cancelSponsorSchedule();\n\n    // Don't skip if advert playing and reset last checked time\n    if (getIsAdPlaying()) {\n        // Reset lastCheckVideoTime\n        lastCheckVideoTime = -1;\n        lastCheckTime = 0;\n        logDebug(\"[SB] Ad playing, pausing skipping\");\n\n        return;\n    }\n\n    // Give up if video changed, and trigger a videoID change if so\n    if (await checkIfNewVideoID()) {\n        return;\n    }\n\n    logDebug(`Considering to start skipping: ${!getVideo()}, ${getVideo()?.paused}`);\n    if (!getVideo()) return;\n    if (currentTime === undefined || currentTime === null) {\n        currentTime = getVirtualTime();\n    }\n    clearWaitingTime();\n\n    updateActiveSegment(currentTime);\n\n    if ((getVideo().paused && getCurrentTime() !== 0) // Allow autoplay disabled videos to skip before playing\n        || (getCurrentTime() >= getVideoDuration() - 0.01 && getVideoDuration() > 1)) return;\n    const skipInfo = getNextSkipIndex(currentTime, includeIntersectingSegments, includeNonIntersectingSegments);\n\n    const currentSkip = skipInfo.array[skipInfo.index];\n    const skipTime: number[] = [currentSkip?.scheduledTime, skipInfo.array[skipInfo.endIndex]?.segment[1]];\n    const timeUntilSponsor = skipTime?.[0] - currentTime;\n    const videoID = getVideoID();\n\n    if (videoMuted && !inMuteSegment(currentTime, skipInfo.index !== -1\n            && timeUntilSponsor < skipBuffer && shouldAutoSkip(currentSkip))) {\n        getVideo().muted = false;\n        videoMuted = false;\n\n        for (const notice of skipNotices) {\n            // So that the notice can hide buttons\n            notice.unmutedListener(currentTime);\n        }\n    }\n\n    logDebug(`Ready to start skipping: ${skipInfo.index} at ${currentTime}`);\n    if (skipInfo.index === -1) return;\n\n    if (Config.config.disableSkipping || (getChannelIDInfo().status === ChannelIDStatus.Fetching && Config.config.forceChannelCheck)){\n        return;\n    }\n\n    if (incorrectVideoCheck()) return;\n\n    // Find all indexes in between the start and end\n    let skippingSegments = [skipInfo.array[skipInfo.index]];\n    if (skipInfo.index !== skipInfo.endIndex) {\n        skippingSegments = [];\n\n        for (const segment of skipInfo.array) {\n            if (shouldAutoSkip(segment) &&\n                    segment.segment[0] >= skipTime[0] && segment.segment[1] <= skipTime[1]\n                    && segment.segment[0] === segment.scheduledTime) { // Don't include artificial scheduled segments (end times for mutes)\n                skippingSegments.push(segment);\n            }\n        }\n    }\n\n    logDebug(`Next step in starting skipping: ${!shouldSkip(currentSkip)}, ${!sponsorTimesSubmitting?.some((segment) => segment.segment === currentSkip.segment)}`);\n\n    const skippingFunction = (forceVideoTime?: number) => {\n        let forcedSkipTime: number = null;\n        let forcedIncludeIntersectingSegments = false;\n        let forcedIncludeNonIntersectingSegments = true;\n\n        if (incorrectVideoCheck(videoID, currentSkip)) return;\n        forceVideoTime ||= Math.max(getCurrentTime(), getVirtualTime());\n\n        if ((shouldSkip(currentSkip)\n                || sponsorTimesSubmitting?.some((segment) => segment.segment === currentSkip.segment\n                    && segment.actionType !== ActionType.Chapter\n                    && segment.hidden === SponsorHideType.Visible))) {\n            if (forceVideoTime >= skipTime[0] - skipBuffer && (forceVideoTime < skipTime[1] || skipTime[1] < skipTime[0])) {\n                skipToTime({\n                    v: getVideo(),\n                    skipTime,\n                    skippingSegments,\n                    openNotice: skipInfo.openNotice\n                });\n\n                // These are segments that start at the exact same time but need separate notices\n                for (const extra of skipInfo.extraIndexes) {\n                    const extraSkip = skipInfo.array[extra];\n                    if (shouldSkip(extraSkip)) {\n                        skipToTime({\n                            v: getVideo(),\n                            skipTime: [extraSkip.scheduledTime, extraSkip.segment[1]],\n                            skippingSegments: [extraSkip],\n                            openNotice: skipInfo.openNotice\n                        });\n                    }\n                }\n\n                if (getCategorySelection(currentSkip)?.option === CategorySkipOption.ManualSkip\n                        || currentSkip.actionType === ActionType.Mute) {\n                    forcedSkipTime = skipTime[0] + 0.001;\n                } else {\n                    forcedSkipTime = skipTime[1];\n                    forcedIncludeNonIntersectingSegments = false;\n\n                    // Only if not at the end of the video\n                    if (Math.abs(skipTime[1] - getVideoDuration()) > endTimeSkipBuffer) {\n                        forcedIncludeIntersectingSegments = true;\n                    }\n                }\n            } else {\n                forcedSkipTime = forceVideoTime + 0.001;\n            }\n        } else {\n            forcedSkipTime = forceVideoTime + 0.001;\n        }\n\n        // Don't pretend to be earlier than we are, could result in loops\n        if (forcedSkipTime !== null && forceVideoTime > forcedSkipTime && skipTime[1] > skipTime[0]) {\n            forcedSkipTime = forceVideoTime;\n        }\n\n        startSponsorSchedule(forcedIncludeIntersectingSegments, forcedSkipTime, forcedIncludeNonIntersectingSegments);\n    };\n\n    if (timeUntilSponsor < skipBuffer) {\n        skippingFunction(currentTime);\n    } else {\n        let delayTime = timeUntilSponsor * 1000 * (1 / getVideo().playbackRate);\n        if (delayTime < (isFirefoxOrSafari() && !isSafari() ? 750 : 300)\n                && shouldAutoSkip(skippingSegments[0])) {\n            let forceStartIntervalTime: number | null = null;\n            if (isFirefoxOrSafari() && !isSafari() && delayTime > 300) {\n                forceStartIntervalTime = await waitForNextTimeChange();\n            }\n\n            // Use interval instead of timeout near the end to combat imprecise video time\n            const startIntervalTime = forceStartIntervalTime || performance.now();\n            const startVideoTime = Math.max(currentTime, getVirtualTime());\n            delayTime = (skipTime?.[0] - startVideoTime) * 1000 * (1 / getVideo().playbackRate);\n\n            let startWaitingForReportedTimeToChange = true;\n            const reportedVideoTimeAtStart = getCurrentTime();\n            logDebug(`Starting setInterval skipping ${getCurrentTime()} to skip at ${skipTime[0]}`);\n\n            if (currentSkipInterval !== null) clearInterval(currentSkipInterval);\n            currentSkipInterval = setInterval(() => {\n                // Estimate delay, but only take the current time right after a change\n                // Current time remains the same for many \"frames\" on Firefox\n                if (isFirefoxOrSafari() && !lastKnownVideoTime.fromPause && startWaitingForReportedTimeToChange\n                        && reportedVideoTimeAtStart !== getCurrentTime()) {\n                    startWaitingForReportedTimeToChange = false;\n                    const delay = getVirtualTime() - getCurrentTime();\n                    if (delay > 0) lastKnownVideoTime.approximateDelay = delay;\n                }\n\n                const intervalDuration = performance.now() - startIntervalTime;\n                if (intervalDuration + skipBuffer * 1000 >= delayTime || getVirtualTime() + skipBuffer >= skipTime[0]) {\n                    clearInterval(currentSkipInterval);\n                    if (!isFirefoxOrSafari() && !getVideo().muted && !inMuteSegment(getCurrentTime(), true)) {\n                        // Workaround for more accurate skipping on Chromium\n                        getVideo().muted = true;\n                        getVideo().muted = false;\n                    }\n\n                    skippingFunction(Math.max(getVirtualTime(), startVideoTime + getVideo().playbackRate * Math.max(delayTime, intervalDuration) / 1000));\n                }\n            }, 0);\n        } else {\n            logDebug(`Starting timeout to skip ${getCurrentTime()} to skip at ${skipTime[0]}`);\n\n            const offset = (isFirefoxOrSafari() && !isSafari() ? 600 : 150);\n            // Schedule for right before to be more precise than normal timeout\n            const offsetDelayTime = Math.max(0, delayTime - offset);\n            currentSkipSchedule = setTimeout(skippingFunction, offsetDelayTime);\n\n            // Show the notice only if the segment hasn't already started\n            if (Config.config.showUpcomingNotice && getCurrentTime() < skippingSegments[0].segment[0] \n                    && !sponsorTimesSubmitting?.some((segment) => segment.segment === currentSkip.segment)\n                    && [ActionType.Skip, ActionType.Mute].includes(skippingSegments[0].actionType)\n                    && getCategorySelection(skippingSegments[0])?.option > CategorySkipOption.ShowOverlay\n                    && !getVideo()?.paused) {\n                const maxPopupTime = 3000;\n                const timeUntilPopup = Math.max(0, offsetDelayTime - maxPopupTime);\n                const popupTime = offsetDelayTime - timeUntilPopup;\n                const autoSkip = shouldAutoSkip(skippingSegments[0]);\n\n                if (currentUpcomingSchedule) clearTimeout(currentUpcomingSchedule);\n                currentUpcomingSchedule = setTimeout(createUpcomingNotice, timeUntilPopup, [skippingSegments[0]], popupTime, autoSkip);\n            }\n        }\n    }\n}\n\n/**\n * Used on Firefox only, waits for the next animation frame until\n * the video time has changed\n */\nfunction waitForNextTimeChange(): Promise<DOMHighResTimeStamp | null> {\n    return new Promise((resolve) => {\n        getVideo().addEventListener(\"timeupdate\", () => resolve(performance.now()), { once: true });\n    });\n}\n\nfunction getVirtualTime(): number {\n    const virtualTime = lastTimeFromWaitingEvent ?? (lastKnownVideoTime.videoTime !== null ?\n        (performance.now() - lastKnownVideoTime.preciseTime) * (getVideo()?.playbackRate || 1) / 1000 + lastKnownVideoTime.videoTime : null);\n\n    if (Config.config.useVirtualTime && !isSafari() && virtualTime\n            && virtualTime > getCurrentTime() && virtualTime - getCurrentTime() < 0.8 && getCurrentTime() !== 0) {\n        return Math.max(virtualTime, getCurrentTime());\n    } else {\n        return getCurrentTime();\n    }\n}\n\nfunction inMuteSegment(currentTime: number, includeOverlap: boolean): boolean {\n    const checkFunction = (segment) => segment.actionType === ActionType.Mute\n        && segment.hidden === SponsorHideType.Visible\n        && segment.segment[0] <= currentTime\n        && (segment.segment[1] > currentTime || (includeOverlap && segment.segment[1] + 0.02 > currentTime));\n    return sponsorTimes?.some(checkFunction) || sponsorTimesSubmitting.some(checkFunction);\n}\n\n/**\n * This makes sure the videoID is still correct and if the sponsorTime is included\n */\nfunction incorrectVideoCheck(videoID?: string, sponsorTime?: SponsorTime): boolean {\n    if (!onVideoPage()) return false;\n\n    const currentVideoID = getYouTubeVideoID();\n    const recordedVideoID = videoID || getVideoID();\n    if (currentVideoID !== recordedVideoID || (sponsorTime\n            && (!sponsorTimes || !sponsorTimes?.some((time) => time.segment[0] === sponsorTime.segment[0] && time.segment[1] === sponsorTime.segment[1]))\n            && !sponsorTimesSubmitting.some((time) => time.segment[0] === sponsorTime.segment[0] && time.segment[1] === sponsorTime.segment[1])\n            && (!isLoopedChapter(sponsorTime)))) {\n        // Something has really gone wrong\n        console.error(\"[SponsorBlock] The videoID recorded when trying to skip is different than what it should be.\");\n        console.error(\"[SponsorBlock] VideoID recorded: \" + recordedVideoID + \". Actual VideoID: \" + currentVideoID);\n        console.error(\"[SponsorBlock] SponsorTime\", sponsorTime, \"sponsorTimes\", sponsorTimes, \"sponsorTimesSubmitting\", sponsorTimesSubmitting);\n\n        // Video ID change occured\n        checkVideoIDChange();\n\n        return true;\n    } else {\n        return false;\n    }\n}\n\nlet playbackRateCheckInterval: NodeJS.Timeout | null = null;\nlet lastPlaybackSpeed = 1;\nlet setupVideoListenersFirstTime = true;\nfunction setupVideoListeners(video: HTMLVideoElement) {\n    if (!video) return; // Maybe video became invisible\n\n    //wait until it is loaded\n    video.addEventListener('loadstart', videoOnReadyListener)\n    video.addEventListener('durationchange', durationChangeListener);\n\n    if (setupVideoListenersFirstTime) {\n        addCleanupListener(() => {\n            video.removeEventListener('loadstart', videoOnReadyListener);\n            video.removeEventListener('durationchange', durationChangeListener);\n        });\n    }\n\n    if (!Config.config.disableSkipping) {\n        switchingVideos = false;\n\n        let startedWaiting = false;\n        let lastPausedAtZero = true;\n        let lastVideoDataChange = 0;\n\n        const rateChangeListener = () => {\n            updateVirtualTime();\n            clearWaitingTime();\n\n            startSponsorSchedule();\n        };\n        video.addEventListener('ratechange', rateChangeListener);\n        // Used by videospeed extension (https://github.com/igrigorik/videospeed/pull/740)\n        video.addEventListener('videoSpeed_ratechange', rateChangeListener);\n\n        const playListener = () => {\n            // Prevent video resolution changes from causing skips\n            if (!firstPlay && Date.now() - lastVideoDataChange < 200 && video.currentTime === 0) return;\n\n            firstPlay = false;\n            updateVirtualTime();\n            checkForMiniplayerPlaying();\n\n            if (switchingVideos || lastPausedAtZero) {\n                switchingVideos = false;\n                logDebug(\"Setting switching videos to false\");\n\n                // If already segments loaded before video, retry to skip starting segments\n                if (sponsorTimes) startSkipScheduleCheckingForStartSponsors();\n            }\n\n            lastPausedAtZero = false;\n\n            // Check if an ad is playing\n            updateAdFlag();\n\n            // Make sure it doesn't get double called with the playing event\n            if (Math.abs(lastCheckVideoTime - video.currentTime) > 0.3\n                    || (lastCheckVideoTime !== video.currentTime && Date.now() - lastCheckTime > 2000)) {\n                lastCheckTime = Date.now();\n                lastCheckVideoTime = video.currentTime;\n\n                startSponsorSchedule();\n            }\n        };\n        video.addEventListener('play', playListener);\n\n        const playingListener = () => {\n            updateVirtualTime();\n            lastPausedAtZero = false;\n\n            if (startedWaiting) {\n                startedWaiting = false;\n                logDebug(`[SB] Playing event after buffering: ${Math.abs(lastCheckVideoTime - video.currentTime) > 0.3\n                    || (lastCheckVideoTime !== video.currentTime && Date.now() - lastCheckTime > 2000)}`);\n            }\n\n            if (switchingVideos) {\n                switchingVideos = false;\n                logDebug(\"Setting switching videos to false\");\n\n                // If already segments loaded before video, retry to skip starting segments\n                if (sponsorTimes) startSkipScheduleCheckingForStartSponsors();\n            }\n\n            // Make sure it doesn't get double called with the play event\n            if (Math.abs(lastCheckVideoTime - video.currentTime) > 0.3\n                    || (lastCheckVideoTime !== video.currentTime && Date.now() - lastCheckTime > 2000)) {\n                lastCheckTime = Date.now();\n                lastCheckVideoTime = video.currentTime;\n\n                startSponsorSchedule();\n            }\n\n            if (playbackRateCheckInterval) clearInterval(playbackRateCheckInterval);\n            lastPlaybackSpeed = video.playbackRate;\n\n            // Video speed controller compatibility\n            // That extension makes rate change events not propagate\n            if (document.body.classList.contains(\"vsc-initialized\")) {\n                playbackRateCheckInterval = setInterval(() => {\n                    if ((!getVideoID() || video.paused) && playbackRateCheckInterval) {\n                        // Video is gone, stop checking\n                        clearInterval(playbackRateCheckInterval);\n                        return;\n                    }\n    \n                    if (video.playbackRate !== lastPlaybackSpeed) {\n                        lastPlaybackSpeed = video.playbackRate;\n    \n                        rateChangeListener();\n                    }\n                }, 2000);\n            }\n        };\n        video.addEventListener('playing', playingListener);\n        \n        const seekingListener = () => {\n            lastKnownVideoTime.fromPause = false;\n\n            if (!video.paused){\n                // Reset lastCheckVideoTime\n                lastCheckTime = Date.now();\n                lastCheckVideoTime = video.currentTime;\n\n                updateVirtualTime();\n                clearWaitingTime();\n\n                // Sometimes looped videos loop back to almost zero, but not quite\n                if (video.loop && video.currentTime < 0.2 && getCurrentTime() < 0.2) {\n                    startSponsorSchedule(false, 0);\n                } else {\n                    startSponsorSchedule();\n                }\n            } else {\n                updateActiveSegment(getCurrentTime());\n\n                if (getCurrentTime() === 0) {\n                    lastPausedAtZero = true;\n                }\n            }\n        };\n        video.addEventListener('seeking', seekingListener);\n        \n        const stoppedPlayback = () => {\n            // Reset lastCheckVideoTime\n            lastCheckVideoTime = -1;\n            lastCheckTime = 0;\n\n            if (playbackRateCheckInterval) clearInterval(playbackRateCheckInterval);\n\n            lastKnownVideoTime.videoTime = null;\n            lastKnownVideoTime.preciseTime = null;\n            updateWaitingTime(video);\n\n            cancelSponsorSchedule();\n        };\n        const pauseListener = () => {\n            lastKnownVideoTime.fromPause = true;\n\n            stoppedPlayback();\n        };\n        video.addEventListener('pause', pauseListener);\n        const waitingListener = () => {\n            logDebug(\"[SB] Not skipping due to buffering\");\n            startedWaiting = true;\n\n            stoppedPlayback();\n        };\n        video.addEventListener('waiting', waitingListener);\n\n        // When video data is changed\n        const emptyListener = () => {\n            lastVideoDataChange = Date.now();\n\n            if (firstPlay && video.currentTime === 0) {\n                playListener();\n            }\n        }\n        video.addEventListener('emptied', emptyListener);\n\n        // For when autoplay is off to skip before starting playback\n        const metadataLoadedListener = () => {\n            if (firstPlay && getCurrentTime() === 0) {\n                playListener();\n            }\n        }\n        video.addEventListener('loadedmetadata', metadataLoadedListener)\n\n        startSponsorSchedule();\n\n        if (setupVideoListenersFirstTime) {\n            addCleanupListener(() => {\n                video.removeEventListener('play', playListener);\n                video.removeEventListener('playing', playingListener);\n                video.removeEventListener('seeking', seekingListener);\n                video.removeEventListener('ratechange', rateChangeListener);\n                video.removeEventListener('videoSpeed_ratechange', rateChangeListener);\n                video.removeEventListener('pause', pauseListener);\n                video.removeEventListener('waiting', waitingListener);\n                video.removeEventListener('empty', emptyListener);\n                video.removeEventListener('loadedmetadata', metadataLoadedListener);\n\n                if (playbackRateCheckInterval) clearInterval(playbackRateCheckInterval);\n            });\n        }\n    }\n\n    setupVideoListenersFirstTime = false;\n}\n\nfunction updateVirtualTime() {\n    if (currentVirtualTimeInterval) clearInterval(currentVirtualTimeInterval);\n\n    lastKnownVideoTime.videoTime = getCurrentTime();\n    lastKnownVideoTime.preciseTime = performance.now();\n\n    // If on Firefox, wait for the second time change (time remains fixed for many \"frames\" for privacy reasons)\n    if (isFirefoxOrSafari()) {\n        let count = 0;\n        let rawCount = 0;\n        let lastTime = lastKnownVideoTime.videoTime;\n        let lastPerformanceTime = performance.now();\n\n        currentVirtualTimeInterval = setInterval(() => {\n            const frameTime = performance.now() - lastPerformanceTime;\n            if (lastTime !== getCurrentTime()) {\n                rawCount++;\n\n                // If there is lag, give it another shot at finding a good change time\n                if (frameTime < 20 || rawCount > 30) {\n                    count++;\n                }\n                lastTime = getCurrentTime();\n            }\n\n            if (count > 1) {\n                const delay = lastKnownVideoTime.fromPause && lastKnownVideoTime.approximateDelay ?\n                    lastKnownVideoTime.approximateDelay : 0;\n\n                lastKnownVideoTime.videoTime = getCurrentTime() + delay;\n                lastKnownVideoTime.preciseTime = performance.now();\n\n                clearInterval(currentVirtualTimeInterval);\n                currentVirtualTimeInterval = null;\n            }\n\n            lastPerformanceTime = performance.now();\n        }, 1);\n    }\n}\n\nfunction updateWaitingTime(video: HTMLVideoElement): void {\n    lastTimeFromWaitingEvent = video.currentTime;\n}\n\nfunction clearWaitingTime(): void {\n    lastTimeFromWaitingEvent = null;\n}\n\nfunction setupSkipButtonControlBar() {\n    if (!skipButtonControlBar) {\n        skipButtonControlBar = new SkipButtonControlBar({\n            skip: (segment) => skipToTime({\n                v: getVideo(),\n                skipTime: segment.segment,\n                skippingSegments: [segment],\n                openNotice: true,\n                forceAutoSkip: true\n            }),\n            selectSegment,\n            onMobileYouTube: isOnMobileYouTube(),\n            onYTTV: isOnYTTV(),\n        });\n    }\n\n    skipButtonControlBar.attachToPage();\n}\n\nfunction setupCategoryPill() {\n    if (!categoryPill) {\n        categoryPill = new CategoryPill();\n    }\n\n    categoryPill.attachToPage(isOnMobileYouTube(), isOnInvidious(), voteAsync);\n}\n\nasync function sponsorsLookup(keepOldSubmissions = true, ignoreCache = false) {\n    const videoID = getVideoID();\n    if (!videoID) {\n        console.error(\"[SponsorBlock] Attempted to fetch segments with a null/undefined videoID.\");\n        return;\n    }\n\n    const segmentData = await getSegmentsForVideo(videoID, ignoreCache);\n\n    // Make sure an old pending request doesn't get used.\n    if (videoID !== getVideoID()) return;\n\n    // store last response status\n    lastResponseStatus = segmentData.status;\n    if (segmentData.status === 200) {\n        const receivedSegments = segmentData.segments;\n\n        if (receivedSegments && receivedSegments.length) {\n            sponsorDataFound = receivedSegments.findIndex((segment) => getCategorySelection(segment).option !== CategorySkipOption.Disabled) !== -1;\n\n            // Check if any old submissions should be kept\n            if (sponsorTimes !== null && keepOldSubmissions) {\n                for (let i = 0; i < sponsorTimes.length; i++) {\n                    if (sponsorTimes[i].source === SponsorSourceType.Local)  {\n                        // This is a user submission, keep it\n                        receivedSegments.push(sponsorTimes[i]);\n                    }\n                }\n            }\n\n            const oldSegments = sponsorTimes || [];\n            sponsorTimes = receivedSegments;\n            existingChaptersImported = false;\n\n            if (keepOldSubmissions) {\n                for (const segment of oldSegments) {\n                    const otherSegment = sponsorTimes.find((other) => segment.UUID === other.UUID);\n                    if (otherSegment) {\n                        // If they downvoted it, or changed the category, keep it\n                        otherSegment.hidden = segment.hidden;\n                        otherSegment.category = segment.category;\n                    }\n                }\n            }\n\n            // See if some segments should be hidden\n            const hashPrefix = (await getHash(videoID, 1)).slice(0, 4) as VideoID & HashedValue;\n            const downvotedData = Config.local.downvotedSegments[hashPrefix];\n            if (downvotedData) {\n                for (const segment of sponsorTimes) {\n                    const hashedUUID = await getHash(segment.UUID, 1);\n                    const segmentDownvoteData = downvotedData.segments.find((downvote) => downvote.uuid === hashedUUID);\n                    if (segmentDownvoteData) {\n                        segment.hidden = segmentDownvoteData.hidden;\n                    }\n                }\n            }\n\n            hideTooShortSegments(sponsorTimes);\n\n            if (!getVideo()) {\n                //there is still no video here\n                await waitForVideo();\n            }\n\n            startSkipScheduleCheckingForStartSponsors();\n\n            if (!isNaN(getVideoDuration())) {\n                updatePreviewBar();\n            }\n        }\n    }\n\n    notifyPopupOfSegments();\n    importExistingChapters(true);\n\n    if (Config.config.isVip) {\n        lockedCategoriesLookup();\n    }\n}\n\nfunction notifyPopupOfSegments(): void {\n    // notify popup of segment changes\n    chrome.runtime.sendMessage({\n        message: \"infoUpdated\",\n        found: sponsorDataFound,\n        status: lastResponseStatus,\n        sponsorTimes: sponsorTimes.filter((segment) => getCategorySelection(segment).option !== CategorySkipOption.Disabled),\n        time: getCurrentTime() ?? 0,\n        onMobileYouTube: isOnMobileYouTube(),\n        videoID: getVideoID(),\n        loopedChapter: loopedChapter?.UUID,\n        channelID: getChannelIDInfo().id,\n        channelAuthor: getChannelIDInfo().author,\n        currentTabSkipProfileID: getSkipProfileIDForTab()\n    });\n}\n\nfunction importExistingChapters(wait: boolean) {\n    if (!existingChaptersImported && !importingChaptersWaiting && onVideoPage() && !isOnMobileYouTube()) {\n        const waitCondition = () => getVideoDuration() && getExistingChapters(getVideoID(), getVideoDuration());\n\n        if (wait && !document.hasFocus() && !importingChaptersWaitingForFocus && !waitCondition()) {\n            importingChaptersWaitingForFocus = true;\n            const listener = () => {\n                importExistingChapters(wait);\n                window.removeEventListener(\"focus\", listener);\n            };\n            window.addEventListener(\"focus\", listener);\n        } else {\n            importingChaptersWaiting = true;\n            waitFor(waitCondition,\n                wait ? 15000 : 0, 400, (c) => c?.length > 0).then((chapters) => {\n                    importingChaptersWaiting = false;\n\n                    if (!existingChaptersImported && chapters?.length > 0) {\n                        sponsorTimes = (sponsorTimes ?? []).concat(...chapters).sort((a, b) => a.segment[0] - b.segment[0]);\n                        existingChaptersImported = true;\n                        updatePreviewBar();\n                    }\n                }).catch(() => { importingChaptersWaiting = false; });\n\n            if (!getSkipProfileBool(\"showAutogeneratedChapters\")) {\n                waitFor(() => hasAutogeneratedChapters(), wait ? 15000 : 0, 400).then(() => {\n                    updateActiveSegment(getCurrentTime());\n                }).catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function\n            }\n        }\n    }\n}\n\nfunction handleExistingChaptersChannelChange() {\n    if (existingChaptersImported && hasAutogeneratedChapters() && !getSkipProfileBool(\"showAutogeneratedChapters\")) {\n        sponsorTimes = sponsorTimes.filter((segment) => segment.source !== SponsorSourceType.Autogenerated);\n    }\n}\n\nasync function lockedCategoriesLookup(): Promise<void> {\n    const hashPrefix = (await getHash(getVideoID(), 1)).slice(0, 4);\n    try {\n        const response = await asyncRequestToServer(\"GET\", \"/api/lockCategories/\" + hashPrefix);\n\n        if (response.ok) {\n            const categoriesResponse = JSON.parse(response.responseText).filter((lockInfo) => lockInfo.videoID === getVideoID())[0]?.categories;\n            if (Array.isArray(categoriesResponse)) {\n                lockedCategories = categoriesResponse;\n            }\n        } else if (response.status !== 404) {\n            logRequest(response, \"SB\", \"locked categories\")\n        }\n    } catch (e) {\n        console.warn(`[SB] Caught error while looking up category locks for hashprefix ${hashPrefix}`, e)\n    }\n}\n\n/**\n * Only should be used when it is okay to skip a sponsor when in the middle of it\n *\n * Ex. When segments are first loaded\n */\nfunction startSkipScheduleCheckingForStartSponsors() {\n\t// switchingVideos is ignored in Safari due to event fire order. See #1142\n    if ((!switchingVideos || isSafari()) && sponsorTimes) {\n        // See if there are any starting sponsors\n        let startingSegmentTime = getStartTimeFromUrl(document.URL) || -1;\n        let found = false;\n        for (const time of sponsorTimes) {\n            if (time.segment[0] <= getCurrentTime() && time.segment[0] > startingSegmentTime && time.segment[1] > getCurrentTime()\n                    && time.actionType !== ActionType.Poi) {\n                        startingSegmentTime = time.segment[0];\n                        found = true;\n                break;\n            }\n        }\n        if (!found) {\n            for (const time of sponsorTimesSubmitting) {\n                if (time.segment[0] <= getCurrentTime() && time.segment[0] > startingSegmentTime && time.segment[1] > getCurrentTime()\n                        && time.actionType !== ActionType.Poi) {\n                            startingSegmentTime = time.segment[0];\n                            found = true;\n                    break;\n                }\n            }\n        }\n\n        // For highlight category\n        const poiSegments = sponsorTimes\n            .filter((time) => time.segment[1] > getCurrentTime()\n                && time.actionType === ActionType.Poi\n                && time.hidden === SponsorHideType.Visible\n                && getCategorySelection(time).option !== CategorySkipOption.Disabled)\n            .sort((a, b) => b.segment[0] - a.segment[0]);\n        for (const time of poiSegments) {\n            const skipOption = getCategorySelection(time)?.option;\n            if (skipOption !== CategorySkipOption.ShowOverlay) {\n                skipToTime({\n                    v: getVideo(),\n                    skipTime: time.segment,\n                    skippingSegments: [time],\n                    openNotice: true,\n                    unskipTime: getCurrentTime()\n                });\n                if (skipOption === CategorySkipOption.AutoSkip) break;\n            }\n        }\n\n        if (startingSegmentTime !== -1) {\n            startSponsorSchedule(undefined, startingSegmentTime);\n        } else {\n            startSponsorSchedule();\n        }\n    }\n}\n\nfunction selectSegment(UUID: SegmentUUID): void {\n    selectedSegment = UUID;\n    updatePreviewBar();\n}\n\nfunction updatePreviewBar(): void {\n    if (previewBar === null) return;\n\n    if (getIsAdPlaying()) {\n        previewBar.clear();\n        return;\n    }\n\n    if (getVideo() === null) return;\n\n    const hashParams = getHashParams();\n    const requiredSegment = hashParams?.requiredSegment as SegmentUUID || undefined;\n    const previewBarSegments: PreviewBarSegment[] = [];\n    if (sponsorTimes) {\n        sponsorTimes.forEach((segment) => {\n            if (segment.hidden !== SponsorHideType.Visible || getCategorySelection(segment).option === CategorySkipOption.Disabled) return;\n\n            previewBarSegments.push({\n                segment: segment.segment as [number, number],\n                category: segment.category,\n                actionType: segment.actionType,\n                unsubmitted: false,\n                showLarger: segment.actionType === ActionType.Poi,\n                description: segment.description,\n                source: segment.source,\n                requiredSegment: requiredSegment && (segment.UUID === requiredSegment || segment.UUID?.startsWith(requiredSegment) || requiredSegment.startsWith(segment.UUID)),\n                selectedSegment: selectedSegment && segment.UUID === selectedSegment\n            });\n        });\n    }\n\n    sponsorTimesSubmitting.forEach((segment) => {\n        if (segment.hidden === SponsorHideType.Visible\n                && (segment.actionType !== ActionType.Chapter || segment.segment.length > 1)) {\n            previewBarSegments.push({\n                segment: segment.segment as [number, number],\n                category: segment.category,\n                actionType: segment.actionType,\n                unsubmitted: true,\n                showLarger: segment.actionType === ActionType.Poi,\n                description: segment.description,\n                source: segment.source\n            });\n        }\n    });\n\n    previewBar.set(previewBarSegments.filter((segment) => segment.actionType !== ActionType.Full), getVideoDuration())\n    if (getVideo()) updateActiveSegment(getCurrentTime());\n\n    if (Config.config.showTimeWithSkips) {\n        const skippedDuration = utils.getTimestampsDuration(previewBarSegments\n            .filter(({actionType}) => ![ActionType.Mute, ActionType.Chapter].includes(actionType))\n            .map(({segment}) => segment));\n\n        showTimeWithoutSkips(skippedDuration);\n    }\n}\n\nfunction updateCategoryPill() {\n    const fullVideoSegment = sponsorTimes.filter((time) => time.actionType === ActionType.Full)[0];\n    if (fullVideoSegment && getSkipProfileBool(\"fullVideoSegments\")) {\n        categoryPill?.setSegment(fullVideoSegment);\n    } else {\n        categoryPill?.setVisibility(false);\n    }\n}\n\n//checks if this channel is whitelisted, should be done only after the channelID has been loaded\nasync function channelIDChange() {\n    // check if the start of segments were missed\n    if (Config.config.forceChannelCheck && sponsorTimes?.length > 0) startSkipScheduleCheckingForStartSponsors();\n\n    hideTooShortSegments(sponsorTimes);\n    handleExistingChaptersChannelChange();\n    updatePreviewBar();\n    updateCategoryPill();\n    notifyPopupOfSegments();\n}\n\nfunction videoElementChange(newVideo: boolean, video: HTMLVideoElement): void {\n    waitFor(() => Config.isReady()).then(() => {\n        if (newVideo) {\n            setupVideoListeners(video);\n            setupSkipButtonControlBar();\n            setupCategoryPill();\n        }\n        \n        updatePreviewBar();\n        checkPreviewbarState();\n    \n        // Incase the page is still transitioning, check again in a few seconds\n        setTimeout(checkPreviewbarState, 100);\n        setTimeout(checkPreviewbarState, 1000);\n        setTimeout(checkPreviewbarState, 5000);\n    })\n}\n\nlet checkingPreviewbarAgain = false;\nfunction checkPreviewbarState(): void {\n    if (!getPreviewBarAttachElement() && !checkingPreviewbarAgain && getVideo() && getVideoID()) {\n        checkingPreviewbarAgain = true;\n        setTimeout(() => {\n            checkingPreviewbarAgain = false;\n            checkPreviewbarState();\n        }, 500);\n\n        return;\n    }\n\n    if (previewBar && !getPreviewBarAttachElement()?.contains(previewBar.container)) {\n        previewBar.remove();\n        previewBar = null;\n    }\n\n    createPreviewBar();\n}\n\n/**\n * Returns info about the next upcoming sponsor skip\n */\nfunction getNextSkipIndex(currentTime: number, includeIntersectingSegments: boolean, includeNonIntersectingSegments: boolean):\n        {array: ScheduledTime[]; index: number; endIndex: number; extraIndexes: number[]; openNotice: boolean} {\n\n    const autoSkipSorter = (segment: ScheduledTime) => {\n        const skipOption = getCategorySelection(segment)?.option;\n        if (segment.hidden !== SponsorHideType.Visible) {\n            // Hidden segments sometimes end up here if another segment is at the same time, use them last\n            return 3;\n        } else if ((skipOption === CategorySkipOption.AutoSkip || shouldAutoSkip(segment))\n                && (segment.actionType === ActionType.Skip || segment.actionType === ActionType.Chapter)) {\n            return 0;\n        } else if (skipOption !== CategorySkipOption.ShowOverlay) {\n            return 1;\n        } else {\n            return 2;\n        }\n    }\n\n    const { includedTimes: submittedArray, scheduledTimes: sponsorStartTimes } =\n        getStartTimes(sponsorTimes, includeIntersectingSegments, includeNonIntersectingSegments);\n    const { scheduledTimes: sponsorStartTimesAfterCurrentTime } = getStartTimes(sponsorTimes, includeIntersectingSegments, includeNonIntersectingSegments, currentTime, true);\n\n    // This is an array in-case multiple segments have the exact same start time\n    const minSponsorTimeIndexes = GenericUtils.indexesOf(sponsorStartTimes, Math.min(...sponsorStartTimesAfterCurrentTime));\n    // Find auto skipping segments if possible, sort by duration otherwise\n    const minSponsorTimeIndex = minSponsorTimeIndexes.sort(\n        (a, b) => ((autoSkipSorter(submittedArray[a]) - autoSkipSorter(submittedArray[b]))\n        || (submittedArray[a].segment[1] - submittedArray[a].segment[0]) - (submittedArray[b].segment[1] - submittedArray[b].segment[0])))[0] ?? -1;\n    // Store extra indexes for the non-auto skipping segments if others occur at the exact same start time\n    const extraIndexes = minSponsorTimeIndexes.filter((i) => i !== minSponsorTimeIndex && autoSkipSorter(submittedArray[i]) !== 0);\n\n    const endTimeIndex = getLatestEndTimeIndex(submittedArray, minSponsorTimeIndex);\n\n    const { includedTimes: unsubmittedArray, scheduledTimes: unsubmittedSponsorStartTimes } =\n        getStartTimes(sponsorTimesSubmitting, includeIntersectingSegments, includeNonIntersectingSegments);\n    const { scheduledTimes: unsubmittedSponsorStartTimesAfterCurrentTime } = getStartTimes(sponsorTimesSubmitting, includeIntersectingSegments, includeNonIntersectingSegments, currentTime, false);\n\n    const minUnsubmittedSponsorTimeIndex = unsubmittedSponsorStartTimes.indexOf(Math.min(...unsubmittedSponsorStartTimesAfterCurrentTime));\n    const previewEndTimeIndex = getLatestEndTimeIndex(unsubmittedArray, minUnsubmittedSponsorTimeIndex);\n\n    if ((minUnsubmittedSponsorTimeIndex === -1 && minSponsorTimeIndex !== -1) ||\n            sponsorStartTimes[minSponsorTimeIndex] < unsubmittedSponsorStartTimes[minUnsubmittedSponsorTimeIndex]) {\n        return {\n            array: submittedArray,\n            index: minSponsorTimeIndex,\n            endIndex: endTimeIndex,\n            extraIndexes, // Segments at same time that need separate notices\n            openNotice: true\n        };\n    } else {\n        return {\n            array: unsubmittedArray,\n            index: minUnsubmittedSponsorTimeIndex,\n            endIndex: previewEndTimeIndex,\n            extraIndexes: [], // No manual things for unsubmitted\n            openNotice: false\n        };\n    }\n}\n\n/**\n * This returns index if the skip option is not AutoSkip\n *\n * Finds the last endTime that occurs in a segment that the given\n * segment skips into that is part of an AutoSkip category.\n *\n * Used to find where a segment should truely skip to if there are intersecting submissions due to\n * them having different categories.\n *\n * @param sponsorTimes\n * @param index Index of the given sponsor\n * @param hideHiddenSponsors\n */\nfunction getLatestEndTimeIndex(sponsorTimes: SponsorTime[], index: number, hideHiddenSponsors = true): number {\n    // Only combine segments for AutoSkip\n    if (index == -1 ||\n            !shouldAutoSkip(sponsorTimes[index])\n            || sponsorTimes[index].actionType !== ActionType.Skip) {\n        return index;\n    }\n\n    let latestEndTimeIndex = -1;\n    // Default to looped chapter if its end would have been skipped\n    if (loopedChapter\n        && (loopedChapter.segment[0] > sponsorTimes[index].segment[0]\n                && loopedChapter.segment[0] <= sponsorTimes[index]?.segment[1])){\n        latestEndTimeIndex = sponsorTimes.length - 1;\n    } else {\n        // or the normal end time otherwise \n        latestEndTimeIndex = index;\n    }\n\n    for (let i = 0; i < sponsorTimes?.length; i++) {\n        const currentSegment = sponsorTimes[i].segment;\n        const latestEndTime = sponsorTimes[latestEndTimeIndex].segment[1];\n\n        if (currentSegment[0] - skipBuffer <= latestEndTime && currentSegment[1] > latestEndTime\n            && (!hideHiddenSponsors || sponsorTimes[i].hidden === SponsorHideType.Visible)\n            && shouldAutoSkip(sponsorTimes[i])\n            && sponsorTimes[i].actionType === ActionType.Skip) {\n                // Overlapping segment\n                latestEndTimeIndex = i;\n        }\n    }\n\n    // Keep going if required\n    if (latestEndTimeIndex !== index) {\n        latestEndTimeIndex = getLatestEndTimeIndex(sponsorTimes, latestEndTimeIndex, hideHiddenSponsors);\n    }\n\n    return latestEndTimeIndex;\n}\n\n/**\n * Gets just the start times from a sponsor times array.\n * Optionally specify a minimum\n *\n * @param sponsorTimes\n * @param minimum\n * @param hideHiddenSponsors\n * @param includeIntersectingSegments If true, it will include segments that start before\n *  the current time, but end after\n */\nfunction getStartTimes(sponsorTimes: SponsorTime[], includeIntersectingSegments: boolean, includeNonIntersectingSegments: boolean,\n    minimum?: number, hideHiddenSponsors = false): {includedTimes: ScheduledTime[]; scheduledTimes: number[]} {\n    if (!sponsorTimes) return {includedTimes: [], scheduledTimes: []};\n\n    const includedTimes: ScheduledTime[] = [];\n    const scheduledTimes: number[] = [];\n\n    const shouldIncludeTime = (segment: ScheduledTime ) => (minimum === undefined\n        || ((includeNonIntersectingSegments && segment.scheduledTime >= minimum)\n            || (includeIntersectingSegments && segment.scheduledTime < minimum\n                    && ((segment.segment[1] > minimum && shouldSkip(segment)) // Only include intersecting skippable segments\n                        || isLoopedChapter(segment)))))\n        && (!hideHiddenSponsors || segment.hidden === SponsorHideType.Visible)\n        && segment.segment.length === 2\n        && segment.actionType !== ActionType.Poi\n        && segment.actionType !== ActionType.Full;\n\n    const possibleTimes = sponsorTimes.map((sponsorTime) => ({\n        ...sponsorTime,\n        scheduledTime: sponsorTime.segment[0]\n    }));\n\n    // Schedule at the end time to know when to unmute and remove title from seek bar\n    sponsorTimes.forEach(sponsorTime => {\n        if (!possibleTimes.some((time) => sponsorTime.segment[1] === time.scheduledTime && shouldIncludeTime(time))\n            && (minimum === undefined || sponsorTime.segment[1] > minimum)) {\n            possibleTimes.push({\n                ...sponsorTime,\n                scheduledTime: sponsorTime.segment[1]\n            });\n        }\n    });\n\n    if (loopedChapter){\n        possibleTimes.push({\n            ...loopedChapter,\n            scheduledTime: loopedChapter.segment[0]})\n    }\n\n    for (let i = 0; i < possibleTimes.length; i++) {\n        if (shouldIncludeTime(possibleTimes[i])) {\n            scheduledTimes.push(possibleTimes[i].scheduledTime);\n            includedTimes.push(possibleTimes[i]);\n        }\n    }\n\n    return { includedTimes, scheduledTimes };\n}\n\n/**\n * Skip to exact time in a video and autoskips\n *\n * @param time\n */\nfunction previewTime(time: number, unpause = true) {\n    previewedSegment = true;\n    setCurrentTime(time);\n\n    // Unpause the video if needed\n    if (unpause && getVideo().paused){\n        getVideo().play();\n    }\n}\n\n//send telemetry and count skip\nfunction sendTelemetryAndCount(skippingSegments: SponsorTime[], secondsSkipped: number, fullSkip: boolean) {\n    for (const segment of skippingSegments) {\n        if (!previewedSegment && sponsorTimesSubmitting.some((s) => s.segment === segment.segment)) {\n            // Count that as a previewed segment\n            previewedSegment = true;\n        }\n    }\n\n    if (!Config.config.trackViewCount || (!Config.config.trackViewCountInPrivate && chrome.extension.inIncognitoContext)) return;\n\n    let counted = false;\n    for (const segment of skippingSegments) {\n        const index = sponsorTimes?.findIndex((s) => s.segment === segment.segment);\n        if (index !== -1 && !sponsorSkipped[index]) {\n            sponsorSkipped[index] = true;\n            if (!counted) {\n                Config.config.minutesSaved = Config.config.minutesSaved + secondsSkipped / 60;\n                if (segment.actionType !== ActionType.Chapter) {\n                    Config.config.skipCount = Config.config.skipCount + 1;\n                }\n                counted = true;\n            }\n\n            if (fullSkip) asyncRequestToServer(\"POST\", \"/api/viewedVideoSponsorTime?UUID=\" + segment.UUID + \"&videoID=\" + getVideoID())\n                .then(r => {\n                    if (!r.ok) logRequest(r, \"SB\", \"segment skip log\");\n                })\n                .catch(e => console.warn(\"[SB] Caught error while attempting to log segment skip\", e));\n        }\n    }\n}\n\n//skip from the start time to the end time for a certain index sponsor time\nfunction skipToTime({v, skipTime, skippingSegments, openNotice, forceAutoSkip, unskipTime}: SkipToTimeParams): void {\n    if (Config.config.disableSkipping) return;\n\n    // There will only be one submission if it is manual skip\n    const autoSkip: boolean = forceAutoSkip || shouldAutoSkip(skippingSegments[0]);\n    const isSubmittingSegment = sponsorTimesSubmitting.some((time) => time.segment === skippingSegments[0].segment);\n\n    if ((autoSkip || isSubmittingSegment)\n            && getCurrentTime() !== skipTime[1]) {\n        switch(skippingSegments[0].actionType) {\n            case ActionType.Poi:\n            case ActionType.Chapter:\n            case ActionType.Skip: {\n                // Fix for looped videos not working when skipping to the end #426\n                // for some reason you also can't skip to 1 second before the end\n                if (v.loop && getVideoDuration() > 1 && skipTime[1] >= getVideoDuration() - 1) {\n                    setCurrentTime(0);\n                } else if (getVideoDuration() > 1 && skipTime[1] >= getVideoDuration()\n                        && (navigator.vendor === \"Apple Computer, Inc.\" || isPlayingPlaylist())) {\n                    // MacOS will loop otherwise #1027\n                    // Sometimes playlists loop too #1804\n                    setCurrentTime(getVideoDuration() - 0.001);\n                } else if (getVideoDuration() > 1 && Math.abs(skipTime[1] - getVideoDuration()) < endTimeSkipBuffer\n                    && isFirefoxOrSafari() && !isSafari()) {\n                    setCurrentTime(getVideoDuration());\n                } else {\n                    if (inMuteSegment(skipTime[1], true)) {\n                        // Make sure not to mute if skipping into a mute segment\n                        v.muted = true;\n                        videoMuted = true;\n                    }\n\n                    setCurrentTime(skipTime[1]);\n                }\n\n                break;\n            }\n            case ActionType.Mute: {\n                if (!v.muted) {\n                    v.muted = true;\n                    videoMuted = true;\n                }\n                break;\n            }\n        }\n    }\n\n    if (autoSkip && Config.config.audioNotificationOnSkip\n            && !isSubmittingSegment && !getVideo()?.muted) {\n        const beep = new Audio(chrome.runtime.getURL(\"icons/beep.oga\"));\n        beep.volume = getVideo().volume * 0.1;\n        const oldMetadata = navigator.mediaSession.metadata\n        beep.play();\n        beep.addEventListener(\"ended\", () => {\n            navigator.mediaSession.metadata = null;\n            setTimeout(() => {\n                navigator.mediaSession.metadata = oldMetadata;\n                beep.remove();\n            });\n        })\n    }\n\n    if (!autoSkip\n            && skippingSegments.length === 1\n            && skippingSegments[0].actionType === ActionType.Poi) {\n        waitFor(() => skipButtonControlBar).then(() => {\n            skipButtonControlBar.enable(skippingSegments[0]);\n            if (isOnMobileYouTube() || Config.config.skipKeybind == null) skipButtonControlBar.setShowKeybindHint(false);\n    \n            activeSkipKeybindElement?.setShowKeybindHint(false);\n            activeSkipKeybindElement = skipButtonControlBar;\n        })\n    } else {\n        if (openNotice) {\n            //send out the message saying that a sponsor message was skipped\n            if (!Config.config.dontShowNotice || !autoSkip) {\n                createSkipNotice(skippingSegments, autoSkip, unskipTime, false);\n            } else if (autoSkip) {\n                activeSkipKeybindElement?.setShowKeybindHint(false);\n                activeSkipKeybindElement = {\n                    setShowKeybindHint: () => {}, //eslint-disable-line @typescript-eslint/no-empty-function\n                    toggleSkip: () => {\n                        unskipSponsorTime(skippingSegments[0], unskipTime);\n\n                        createSkipNotice(skippingSegments, autoSkip, unskipTime, true);\n                    }\n                };\n            }\n        }\n    }\n\n    //send telemetry that a this sponsor was skipped\n    if (autoSkip || isSubmittingSegment) sendTelemetryAndCount(skippingSegments, skipTime[1] - skipTime[0], true);\n}\n\nfunction createSkipNotice(skippingSegments: SponsorTime[], autoSkip: boolean, unskipTime: number, startReskip: boolean, voteNotice = false) {\n    for (const skipNotice of skipNotices) {\n        if (skippingSegments.length === skipNotice.segments.length\n                && skippingSegments.every((segment) => skipNotice.segments.some((s) => s.UUID === segment.UUID))) {\n            // Skip notice already exists\n            return;\n        }\n    }\n\n    const upcomingNoticeShown = !!upcomingNotice && !upcomingNotice.closed;\n\n    const newSkipNotice = new SkipNotice(skippingSegments, autoSkip, skipNoticeContentContainer, () => {\n        upcomingNotice?.close();\n        upcomingNotice = null;\n    }, unskipTime, startReskip, upcomingNoticeShown, voteNotice);\n    if (isOnMobileYouTube() || Config.config.skipKeybind == null) newSkipNotice.setShowKeybindHint(false);\n    skipNotices.push(newSkipNotice);\n\n    activeSkipKeybindElement?.setShowKeybindHint(false);\n    activeSkipKeybindElement = newSkipNotice;\n}\n\nfunction createUpcomingNotice(skippingSegments: SponsorTime[], timeLeft: number, autoSkip: boolean): void {\n    if (upcomingNotice \n            && !upcomingNotice.closed\n            && upcomingNotice.sameNotice(skippingSegments)) {\n        return;\n    }\n\n    upcomingNotice?.close();\n    upcomingNotice = new UpcomingNotice(skippingSegments, skipNoticeContentContainer, timeLeft / 1000, autoSkip);\n}\n\nfunction unskipSponsorTime(segment: SponsorTime, unskipTime: number = null, forceSeek = false, voteNotice = false) {\n    if (segment.actionType === ActionType.Mute) {\n        getVideo().muted = false;\n        videoMuted = false;\n    }\n\n    if (forceSeek || segment.actionType === ActionType.Skip || segment.actionType === ActionType.Chapter || voteNotice) {\n        //add a tiny bit of time to make sure it is not skipped again\n        setCurrentTime(unskipTime ?? segment.segment[0] + 0.001);\n    }\n\n}\n\nfunction reskipSponsorTime(segment: SponsorTime, forceSeek = false) {\n    if (segment.actionType === ActionType.Mute && !forceSeek) {\n        getVideo().muted = true;\n        videoMuted = true;\n    } else {\n        const skippedTime = Math.max(segment.segment[1] - getCurrentTime(), 0);\n        const segmentDuration = segment.segment[1] - segment.segment[0];\n        const fullSkip = skippedTime / segmentDuration > manualSkipPercentCount;\n\n        setCurrentTime(segment.segment[1]);\n        sendTelemetryAndCount([segment], segment.actionType !== ActionType.Chapter ? skippedTime : 0, fullSkip);\n        startSponsorSchedule(true, segment.segment[1], false);\n    }\n}\n\nfunction createButton(baseID: string, title: string, callback: () => void, imageName: string, isDraggable = false): HTMLElement {\n    const existingElement = document.getElementById(baseID + \"Button\");\n    if (existingElement !== null) return existingElement;\n\n    // Button HTML\n    const newButton = document.createElement(\"button\");\n    newButton.draggable = isDraggable;\n    newButton.id = baseID + \"Button\";\n    newButton.classList.add(\"playerButton\");\n    newButton.classList.add(\"ytp-button\");\n    if (Config.config.prideTheme) newButton.classList.add(\"prideTheme\");\n    if (isOnYTTV()) {\n        // Some style needs to be set here, but the numbers don't matter \n        newButton.setAttribute(\"style\", \"width: 40px; height: 40px\");\n    }\n    newButton.setAttribute(\"title\", chrome.i18n.getMessage(title));\n    newButton.addEventListener(\"click\", () => {\n        callback();\n    });\n\n    // Image HTML\n    const newButtonImage = document.createElement(\"img\");\n    newButton.draggable = isDraggable;\n    newButtonImage.id = baseID + \"Image\";\n    newButtonImage.className = \"playerButtonImage\";\n    newButtonImage.src = chrome.runtime.getURL(\"icons/\" + imageName);\n\n    // Append image to button\n    newButton.appendChild(newButtonImage);\n\n    // Add the button to player\n    if (controls) controls.prepend(newButton);\n\n    // Store the elements to prevent unnecessary querying\n    playerButtons[baseID] = {\n        button: newButton,\n        image: newButtonImage,\n        setupListener: false\n    };\n\n    return newButton;\n}\n\nfunction shouldAutoSkip(segment: SponsorTime): boolean {\n    const canSkipNonMusic = !Config.config.skipNonMusicOnlyOnYoutubeMusic || isOnYouTubeMusic();\n    if (segment.category === \"music_offtopic\" && !canSkipNonMusic) {\n        return false;\n    }\n\n    return (!getSkipProfileBool(\"manualSkipOnFullVideo\") || !sponsorTimes?.some((s) => s.category === segment.category && s.actionType === ActionType.Full))\n        && (getCategorySelection(segment)?.option === CategorySkipOption.AutoSkip ||\n            (getSkipProfileBool(\"autoSkipOnMusicVideos\") && canSkipNonMusic\n                && sponsorTimes?.some((s) => s.category === \"music_offtopic\" && getCategorySelection(segment)?.option === CategorySkipOption.AutoSkip)\n                && segment.actionType === ActionType.Skip)\n            || sponsorTimesSubmitting.some((s) => s.segment === segment.segment))\n        || isLoopedChapter(segment);\n}\n\nfunction shouldSkip(segment: SponsorTime): boolean {\n    return segment.hidden === SponsorHideType.Visible && (segment.actionType !== ActionType.Full\n            && getCategorySelection(segment)?.option > CategorySkipOption.ShowOverlay)\n            || (getSkipProfileBool(\"autoSkipOnMusicVideos\")\n                && sponsorTimes?.some((s) => s.category === \"music_offtopic\" && getCategorySelection(segment)?.option === CategorySkipOption.AutoSkip)\n                && segment.actionType === ActionType.Skip)\n            || isLoopedChapter(segment);\n}\n\nfunction isLoopedChapter(segment: SponsorTime): boolean{\n    return !!segment && !!loopedChapter && segment.segment[1] != undefined\n        && segment.segment[0] === loopedChapter.segment[0] && segment.segment[1] === loopedChapter.segment[1];\n}\n\n/** Creates any missing buttons on the YouTube player if possible. */\nasync function createButtons(): Promise<void> {\n    controls = await utils.wait(getControls).catch();\n\n    // Add button if does not already exist in html\n    createButton(\"startSegment\", \"sponsorStart\", () => startOrEndTimingNewSegment(), \"PlayerStartIconSponsorBlocker.svg\");\n    createButton(\"cancelSegment\", \"sponsorCancel\", () => cancelCreatingSegment(), \"PlayerCancelSegmentIconSponsorBlocker.svg\");\n    createButton(\"delete\", \"clearTimes\", () => clearSponsorTimes(), \"PlayerDeleteIconSponsorBlocker.svg\");\n    createButton(\"submit\", \"OpenSubmissionMenu\", () => openSubmissionMenu(), \"PlayerUploadIconSponsorBlocker.svg\");\n    createButton(\"info\", \"openPopup\", () => openInfoMenu(), \"PlayerInfoIconSponsorBlocker.svg\");\n\n    const controlsContainer = getControls();\n    if (Config.config.autoHideInfoButton && !isOnInvidious() && controlsContainer\n            && playerButtons[\"info\"]?.button && !controlsWithEventListeners.includes(controlsContainer)) {\n        controlsWithEventListeners.push(controlsContainer);\n\n        AnimationUtils.setupAutoHideAnimation(playerButtons[\"info\"].button, controlsContainer);\n    }\n}\n\n/** Creates any missing buttons on the player and updates their visiblity. */\nasync function updateVisibilityOfPlayerControlsButton(): Promise<void> {\n    // Not on a proper video yet\n    if (!getVideoID() || isOnMobileYouTube()) return;\n\n    await createButtons();\n\n    updateEditButtonsOnPlayer();\n\n    // Don't show the info button on embeds\n    if (Config.config.hideInfoButtonPlayerControls || document.URL.includes(\"/embed/\") || isOnInvidious() || isOnYTTV()\n        || document.getElementById(\"sponsorBlockPopupContainer\") != null) {\n        playerButtons.info.button.style.display = \"none\";\n    } else {\n        playerButtons.info.button.style.removeProperty(\"display\");\n    }\n}\n\n/** Updates the visibility of buttons on the player related to creating segments. */\nfunction updateEditButtonsOnPlayer(): void {\n    // Don't try to update the buttons if we aren't on a YouTube video page\n    if (!getVideoID() || isOnMobileYouTube()) return;\n\n    const buttonsEnabled = !(Config.config.hideVideoPlayerControls || isOnInvidious());\n\n    let creatingSegment = false;\n    let submitButtonVisible = false;\n    let deleteButtonVisible = false;\n\n    // Only check if buttons should be visible if they're enabled\n    if (buttonsEnabled) {\n        creatingSegment = isSegmentCreationInProgress();\n\n        // Show only if there are any segments to submit\n        submitButtonVisible = sponsorTimesSubmitting.length > 0;\n\n        // Show only if there are any segments to delete\n        deleteButtonVisible = sponsorTimesSubmitting.length > 1 || (sponsorTimesSubmitting.length > 0 && !creatingSegment);\n    }\n\n    // Update the elements\n    playerButtons.startSegment.button.style.display = buttonsEnabled ? \"unset\" : \"none\";\n    playerButtons.cancelSegment.button.style.display = buttonsEnabled && creatingSegment ? \"unset\" : \"none\";\n\n    if (buttonsEnabled) {\n        if (creatingSegment) {\n            playerButtons.startSegment.image.src = chrome.runtime.getURL(\"icons/PlayerStopIconSponsorBlocker.svg\");\n            playerButtons.startSegment.button.setAttribute(\"title\", chrome.i18n.getMessage(\"sponsorEnd\"));\n        } else {\n            playerButtons.startSegment.image.src = chrome.runtime.getURL(\"icons/PlayerStartIconSponsorBlocker.svg\");\n            playerButtons.startSegment.button.setAttribute(\"title\", chrome.i18n.getMessage(\"sponsorStart\"));\n        }\n    }\n\n    playerButtons.submit.button.style.display = submitButtonVisible && !Config.config.hideUploadButtonPlayerControls ? \"unset\" : \"none\";\n    playerButtons.delete.button.style.display = deleteButtonVisible && !Config.config.hideDeleteButtonPlayerControls ? \"unset\" : \"none\";\n}\n\n/**\n * Used for submitting. This will use the HTML displayed number when required as the video's\n * current time is out of date while scrubbing or at the end of the getVideo(). This is not needed\n * for sponsor skipping as the video is not playing during these times.\n */\nfunction getRealCurrentTime(): number {\n    // Used to check if replay button\n    const playButtonSVGData = document.querySelector(\".ytp-play-button\")?.querySelector(\".ytp-svg-fill\")?.getAttribute(\"d\");\n    const playButtonSVGDataNew = document.querySelector(\".ytp-play-button\")?.querySelector(\"path\")?.getAttribute(\"d\");\n    const replaceSVGData = \"M 18,11 V 7 l -5,5 5,5 v -4 c 3.3,0 6,2.7 6,6 0,3.3 -2.7,6 -6,6 -3.3,0 -6,-2.7 -6,-6 h -2 c 0,4.4 3.6,8 8,8 4.4,0 8,-3.6 8,-8 0,-4.4 -3.6,-8 -8,-8 z\";\n    const replaceSVGDataNew = \"M11.29 2.92C14.85 1.33 18.87 1.06 22\";\n\n    if (playButtonSVGData === replaceSVGData || playButtonSVGDataNew.startsWith(replaceSVGDataNew)) {\n        // At the end of the video\n        return getVideoDuration();\n    } else {\n        return getCurrentTime();\n    }\n}\n\nfunction startOrEndTimingNewSegment() {\n    if (isOnYTTV() && getIsLivePremiere()) {\n        alert(chrome.i18n.getMessage(\"yttvLiveContentWarning\"));\n        return;\n    }\n\n    verifyCurrentTime(getRealCurrentTime());\n    const roundedTime = Math.round((getRealCurrentTime() + Number.EPSILON) * 1000) / 1000;\n    if (!isSegmentCreationInProgress()) {\n        sponsorTimesSubmitting.push({\n            segment: [roundedTime],\n            UUID: generateUserID() as SegmentUUID,\n            category: Config.config.defaultCategory,\n            actionType: ActionType.Skip,\n            source: SponsorSourceType.Local\n        });\n    } else {\n        // Finish creating the new segment\n        const existingSegment = getIncompleteSegment();\n        const existingTime = existingSegment.segment[0];\n        const currentTime = roundedTime;\n\n        // Swap timestamps if the user put the segment end before the start\n        existingSegment.segment = [Math.min(existingTime, currentTime), Math.max(existingTime, currentTime)];\n    }\n\n    // Save the newly created segment\n    Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;\n    Config.forceLocalUpdate(\"unsubmittedSegments\");\n\n    // Make sure they know if someone has already submitted something it while they were watching\n    sponsorsLookup(true, true);\n\n    updateEditButtonsOnPlayer();\n    updateSponsorTimesSubmitting(false);\n\n    importExistingChapters(false);\n\n    if (lastResponseStatus !== 200 && lastResponseStatus !== 404\n            && !shownSegmentFailedToFetchWarning && Config.config.showSegmentFailedToFetchWarning) {\n        alert(chrome.i18n.getMessage(\"segmentFetchFailureWarning\"));\n\n        shownSegmentFailedToFetchWarning = true;\n    }\n}\n\nfunction getIncompleteSegment(): SponsorTime {\n    return sponsorTimesSubmitting[sponsorTimesSubmitting.length - 1];\n}\n\n/** Is the latest submitting segment incomplete */\nfunction isSegmentCreationInProgress(): boolean {\n    const segment = getIncompleteSegment();\n    return segment && segment?.segment?.length !== 2;\n}\n\nfunction cancelCreatingSegment() {\n    if (isSegmentCreationInProgress()) {\n        if (sponsorTimesSubmitting.length > 1) {  // If there's more than one segment: remove last\n            sponsorTimesSubmitting.pop();\n            Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;\n        } else {  // Otherwise delete the video entry & close submission menu\n            resetSponsorSubmissionNotice();\n            sponsorTimesSubmitting = [];\n            delete Config.local.unsubmittedSegments[getVideoID()];\n        }\n        Config.forceLocalUpdate(\"unsubmittedSegments\");\n    }\n\n    updateEditButtonsOnPlayer();\n    updateSponsorTimesSubmitting(false);\n}\n\nfunction updateSponsorTimesSubmitting(getFromConfig = true) {\n    const segmentTimes = Config.local.unsubmittedSegments[getVideoID()];\n\n    //see if this data should be saved in the sponsorTimesSubmitting variable\n    if (getFromConfig && segmentTimes != undefined) {\n        sponsorTimesSubmitting = [];\n\n        for (const segmentTime of segmentTimes) {\n            sponsorTimesSubmitting.push({\n                segment: segmentTime.segment,\n                UUID: segmentTime.UUID,\n                category: segmentTime.category,\n                actionType: segmentTime.actionType,\n                description: segmentTime.description,\n                hidden: segmentTime.hidden,\n                source: segmentTime.source\n            });\n        }\n\n        if (sponsorTimesSubmitting.length > 0) {\n            // Assume they already previewed a segment\n            previewedSegment = true;\n\n            importExistingChapters(true);\n        }\n    }\n\n    updatePreviewBar();\n\n    // Restart skipping schedule\n    if (getVideo() !== null) startSponsorSchedule();\n\n    if (submissionNotice !== null) {\n        submissionNotice.update();\n    }\n\n    checkForPreloadedSegment();\n}\n\nfunction openInfoMenu() {\n    if (document.getElementById(\"sponsorBlockPopupContainer\") != null) {\n        //it's already added\n        return;\n    }\n\n    popupInitialised = false;\n\n    //hide info button\n    if (playerButtons.info) playerButtons.info.button.style.display = \"none\";\n\n    const popup = document.createElement(\"div\");\n    popup.id = \"sponsorBlockPopupContainer\";\n\n    const frame = document.createElement(\"iframe\");\n    frame.allow = \"clipboard-write\";\n    frame.width = \"374\";\n    frame.height = \"500\";\n    frame.style.borderRadius = \"12px\";\n    frame.addEventListener(\"load\", async () => {\n        frame.contentWindow.postMessage(\"\", \"*\");\n\n        // To support userstyles applying to the popup\n        const stylusStyle = document.querySelector(\".stylus\");\n        if (stylusStyle) {\n            frame.contentWindow.postMessage({\n                type: \"style\",\n                css: stylusStyle.textContent\n            }, \"*\");\n        }\n\n        const enhancerStyle = document.getElementById(\"efyt-theme\");\n        if (enhancerStyle) {\n            const enhancerStyleVariables = document.getElementById(\"efyt-theme-variables\");\n            if (enhancerStyleVariables) {\n                const enhancerCss = await fetch(enhancerStyle.getAttribute(\"href\")).then((response) => response.text());\n                const enhancerVariablesCss = await fetch(enhancerStyleVariables.getAttribute(\"href\")).then((response) => response.text());\n\n                if (enhancerCss && enhancerVariablesCss) {\n                    frame.contentWindow.postMessage({\n                        type: \"style\",\n                        // Image needs needs to reference the full url now\n                        css: enhancerCss.replace(\"./images/youtube-deep-dark/IconSponsorBlocker256px.png\",\n                            \"https://raw.githubusercontent.com/RaitaroH/YouTube-DeepDark/master/YT_Images/IconSponsorBlocker256px.png\")\n                            + enhancerVariablesCss\n                    }, \"*\");\n                }\n            }\n        }\n    });\n    frame.src = chrome.runtime.getURL(\"popup.html\");\n    popup.appendChild(frame);\n\n    const elemHasChild = (elements: NodeListOf<HTMLElement>): Element => {\n        let parentNode: Element;\n        for (const node of elements) {\n            if (node.firstElementChild !== null) {\n                parentNode = node;\n            }\n        }\n        return parentNode\n    }\n\n    const parentNodeOptions = [{\n        // YouTube\n        selector: \"#secondary-inner\",\n        hasChildCheck: true\n    }, {\n        // old youtube theme\n        selector: \"#watch7-sidebar-contents\",\n    }];\n    for (const option of parentNodeOptions) {\n        const allElements = document.querySelectorAll(option.selector) as NodeListOf<HTMLElement>;\n        const el = option.hasChildCheck ? elemHasChild(allElements) : allElements[0];\n\n        if (el) {\n            if (option.hasChildCheck) el.insertBefore(popup, el.firstChild);\n            break;\n        }\n    }\n}\n\nfunction closeInfoMenu() {\n    const popup = document.getElementById(\"sponsorBlockPopupContainer\");\n    if (popup === null) return;\n\n    popup.remove();\n\n    // Show info button if it's not an embed\n    if (!document.URL.includes(\"/embed/\") && playerButtons.info) {\n        playerButtons.info.button.style.display = \"unset\";\n    }\n}\n\nfunction clearSponsorTimes() {\n    const currentVideoID = getVideoID();\n\n    const sponsorTimes = Config.local.unsubmittedSegments[currentVideoID];\n\n    if (sponsorTimes != undefined && sponsorTimes.length > 0) {\n        const confirmMessage = chrome.i18n.getMessage(\"clearThis\") + getSegmentsMessage(sponsorTimes)\n                                + \"\\n\" + chrome.i18n.getMessage(\"confirmMSG\")\n        if(!confirm(confirmMessage)) return;\n\n        resetSponsorSubmissionNotice();\n\n        //clear the sponsor times\n        delete Config.local.unsubmittedSegments[currentVideoID];\n        Config.forceLocalUpdate(\"unsubmittedSegments\");\n\n        //clear sponsor times submitting\n        sponsorTimesSubmitting = [];\n\n        updatePreviewBar();\n        updateEditButtonsOnPlayer();\n    }\n}\n\n//if skipNotice is null, it will not affect the UI\nasync function vote(type: number, UUID: SegmentUUID, category?: Category, skipNotice?: SkipNoticeComponent): Promise<VoteResponse> {\n    if (skipNotice !== null && skipNotice !== undefined) {\n        //add loading info\n        skipNotice.addVoteButtonInfo(chrome.i18n.getMessage(\"Loading\"))\n        skipNotice.setNoticeInfoMessage();\n    }\n\n    const response = await voteAsync(type, UUID, category);\n    if (response != undefined) {\n        //see if it was a success or failure\n        if (skipNotice != null) {\n            if (\"error\" in response) {\n                skipNotice.setNoticeInfoMessage(formatJSErrorMessage(response.error))\n                skipNotice.resetVoteButtonInfo();\n            } else if (response.ok || response.status === 429) {\n                //success (treat rate limits as a success)\n                skipNotice.afterVote(utils.getSponsorTimeFromUUID(sponsorTimes, UUID), type, category);\n            } else {\n                logRequest({headers: null, ...response}, \"SB\", \"vote on segment\");\n                if (response.status === 403 && response.responseText.startsWith(\"Vote rejected due to a tip from a moderator.\")) {\n                    openWarningDialog(skipNoticeContentContainer);\n                } else {\n                    skipNotice.setNoticeInfoMessage(getLongErrorMessage(response.status, response.responseText))\n                }\n\n                skipNotice.resetVoteButtonInfo();\n            }\n        }\n    }\n\n    return response;\n}\n\nasync function voteAsync(type: number, UUID: SegmentUUID, category?: Category): Promise<VoteResponse | undefined> {\n    const sponsorIndex = utils.getSponsorIndexFromUUID(sponsorTimes, UUID);\n\n    // Don't vote for preview sponsors\n    if (sponsorIndex == -1 || sponsorTimes[sponsorIndex].source !== SponsorSourceType.Server) return Promise.resolve(undefined);\n\n    // See if the local time saved count and skip count should be saved\n    if (type === 0 && sponsorSkipped[sponsorIndex] || type === 1 && !sponsorSkipped[sponsorIndex]) {\n        let factor = 1;\n        if (type == 0) {\n            factor = -1;\n\n            sponsorSkipped[sponsorIndex] = false;\n        }\n\n        // Count this as a skip\n        Config.config.minutesSaved = Config.config.minutesSaved + factor * (sponsorTimes[sponsorIndex].segment[1] - sponsorTimes[sponsorIndex].segment[0]) / 60;\n\n        Config.config.skipCount = Config.config.skipCount + factor;\n    }\n\n    return new Promise((resolve) => {\n        chrome.runtime.sendMessage({\n            message: \"submitVote\",\n            type: type,\n            UUID: UUID,\n            category: category,\n            videoID: getVideoID()\n        }, (response) => {\n            if (response.ok === true) {\n                // Change the sponsor locally\n                const segment = utils.getSponsorTimeFromUUID(sponsorTimes, UUID);\n                if (segment) {\n                    if (type === 0) {\n                        segment.hidden = SponsorHideType.Downvoted;\n                    } else if (category) {\n                        segment.category = category;\n                    } else if (type === 1) {\n                        segment.hidden = SponsorHideType.Visible;\n                    }\n\n                    if (!category && !Config.config.isVip) {\n                        utils.addHiddenSegment(getVideoID(), segment.UUID, segment.hidden);\n                    }\n\n                    updatePreviewBar();\n                }\n            }\n\n            resolve(response);\n        });\n    });\n}\n\n//Closes all notices that tell the user that a sponsor was just skipped\nfunction closeAllSkipNotices(){\n    const notices = document.getElementsByClassName(\"sponsorSkipNotice\");\n    for (let i = 0; i < notices.length; i++) {\n        notices[i].remove();\n    }\n}\n\nfunction dontShowNoticeAgain() {\n    Config.config.dontShowNotice = true;\n    closeAllSkipNotices();\n}\n\n/**\n * Helper method for the submission notice to clear itself when it closes\n */\nfunction resetSponsorSubmissionNotice(callRef = true) {\n    submissionNotice?.close(callRef);\n    submissionNotice = null;\n}\n\nfunction closeSubmissionMenu() {\n    submissionNotice?.close();\n    submissionNotice = null;\n}\n\nfunction openSubmissionMenu() {\n    if (submissionNotice !== null){\n        closeSubmissionMenu();\n        return;\n    }\n\n    if (sponsorTimesSubmitting !== undefined && sponsorTimesSubmitting.length > 0) {\n        submissionNotice = new SubmissionNotice(skipNoticeContentContainer, sendSubmitMessage);\n    }\n}\n\nfunction previewRecentSegment() {\n    if (sponsorTimesSubmitting !== undefined && sponsorTimesSubmitting.length > 0) {\n        previewTime(sponsorTimesSubmitting[sponsorTimesSubmitting.length - 1].segment[0] - defaultPreviewTime);\n        \n        if (submissionNotice) {\n            submissionNotice.scrollToBottom();\n        }\n    }\n}\n\nfunction submitSegments() {\n    if (sponsorTimesSubmitting !== undefined\n            && sponsorTimesSubmitting.length > 0\n            && submissionNotice !== null) {\n        submissionNotice.submit();\n    }\n\n}\n\n//send the message to the background js\n//called after all the checks have been made that it's okay to do so\nasync function sendSubmitMessage(): Promise<boolean> {\n    // check if all segments are full video\n    const onlyFullVideo = sponsorTimesSubmitting.every((segment) => segment.actionType === ActionType.Full);\n    // Block if submitting on a running livestream or premiere\n    if (!onlyFullVideo && (getIsLivePremiere() || isVisible(document.querySelector(\".ytp-live-badge\")))) {\n        alert(chrome.i18n.getMessage(\"liveOrPremiere\"));\n        return false;\n    }\n\n    if (!previewedSegment \n            && !sponsorTimesSubmitting.every((segment) => \n                [ActionType.Full, ActionType.Chapter, ActionType.Poi].includes(segment.actionType) \n                    || segment.segment[1] >= getVideoDuration()\n                    || segment.segment[0] === 0)) {\n        alert(`${chrome.i18n.getMessage(\"previewSegmentRequired\")} ${keybindToString(Config.config.previewKeybind)}`);\n        return false;\n    }\n\n    // Add loading animation\n    playerButtons.submit.image.src = chrome.runtime.getURL(\"icons/PlayerUploadIconSponsorBlocker.svg\");\n    const stopAnimation = AnimationUtils.applyLoadingAnimation(playerButtons.submit.button, 1, () => updateEditButtonsOnPlayer());\n\n    //check if a sponsor exceeds the duration of the video\n    for (let i = 0; i < sponsorTimesSubmitting.length; i++) {\n        if (sponsorTimesSubmitting[i].segment[1] > getVideoDuration()) {\n            sponsorTimesSubmitting[i].segment[1] = getVideoDuration();\n        }\n    }\n\n    //update sponsorTimes\n    Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;\n    Config.forceLocalUpdate(\"unsubmittedSegments\");\n\n    // Check to see if any of the submissions are below the minimum duration set\n    if (Config.config.minDuration > 0) {\n        for (let i = 0; i < sponsorTimesSubmitting.length; i++) {\n            const duration = sponsorTimesSubmitting[i].segment[1] - sponsorTimesSubmitting[i].segment[0];\n            if (duration > 0 && duration < Config.config.minDuration) {\n                const confirmShort = chrome.i18n.getMessage(\"shortCheck\") + \"\\n\\n\" +\n                    getSegmentsMessage(sponsorTimesSubmitting);\n\n                if(!confirm(confirmShort)) return false;\n            }\n        }\n    }\n\n    let response: FetchResponse;\n    try {\n        response = await asyncRequestToServer(\"POST\", \"/api/skipSegments\", {\n            videoID: getVideoID(),\n            userID: Config.config.userID,\n            segments: sponsorTimesSubmitting,\n            videoDuration: getVideoDuration(),\n            userAgent: extensionUserAgent(),\n        });\n    } catch (e) {\n        console.error(\"[SB] Caught error while attempting to submit segments\", e);\n        // Show that the upload failed\n        playerButtons.submit.button.style.animation = \"unset\";\n        playerButtons.submit.image.src = chrome.runtime.getURL(\"icons/PlayerUploadFailedIconSponsorBlocker.svg\");\n        alert(formatJSErrorMessage(e));\n        return false;\n    }\n\n    if (response.status === 200) {\n        stopAnimation();\n\n        // Remove segments from storage since they've already been submitted\n        delete Config.local.unsubmittedSegments[getVideoID()];\n        Config.forceLocalUpdate(\"unsubmittedSegments\");\n\n        const newSegments = sponsorTimesSubmitting;\n        try {\n            const receivedNewSegments = JSON.parse(response.responseText);\n            if (receivedNewSegments?.length === newSegments.length) {\n                for (let i = 0; i < receivedNewSegments.length; i++) {\n                    newSegments[i].UUID = receivedNewSegments[i].UUID;\n                    newSegments[i].source = SponsorSourceType.Server;\n                }\n            }\n        } catch(e) {} // eslint-disable-line no-empty\n\n        // Add submissions to current sponsors list\n        sponsorTimes = (sponsorTimes || []).concat(newSegments).sort((a, b) => a.segment[0] - b.segment[0]);\n\n        // Increase contribution count\n        Config.config.sponsorTimesContributed = Config.config.sponsorTimesContributed + sponsorTimesSubmitting.length;\n\n        // New count just used to see if a warning \"Read The Guidelines!!\" message needs to be shown\n        // One per time submitting\n        Config.config.submissionCountSinceCategories = Config.config.submissionCountSinceCategories + 1;\n\n        // Empty the submitting times\n        sponsorTimesSubmitting = [];\n\n        updatePreviewBar();\n        updateCategoryPill();\n\n        return true;\n    } else {\n        // Show that the upload failed\n        playerButtons.submit.button.style.animation = \"unset\";\n        playerButtons.submit.image.src = chrome.runtime.getURL(\"icons/PlayerUploadFailedIconSponsorBlocker.svg\");\n\n        if (response.status === 403 && response.responseText.startsWith(\"Submission rejected due to a tip from a moderator.\")) {\n            openWarningDialog(skipNoticeContentContainer);\n        } else {\n            logRequest(response, \"SB\", \"segment submission\");\n            alert(getLongErrorMessage(response.status, response.responseText));\n        }\n    }\n\n    return false;\n}\n\n//get the message that visually displays the video times\nfunction getSegmentsMessage(sponsorTimes: SponsorTime[]): string {\n    let sponsorTimesMessage = \"\";\n\n    for (let i = 0; i < sponsorTimes.length; i++) {\n        for (let s = 0; s < sponsorTimes[i].segment.length; s++) {\n            let timeMessage = getFormattedTime(sponsorTimes[i].segment[s]);\n            //if this is an end time\n            if (s == 1) {\n                timeMessage = \" \" + chrome.i18n.getMessage(\"to\") + \" \" + timeMessage;\n            } else if (i > 0) {\n                //add commas if necessary\n                timeMessage = \", \" + timeMessage;\n            }\n\n            sponsorTimesMessage += timeMessage;\n        }\n    }\n\n    return sponsorTimesMessage;\n}\n\nfunction updateActiveSegment(currentTime: number): void {\n    if (!chrome.runtime?.id) return;\n\n    previewBar?.updateChapterText(sponsorTimes, sponsorTimesSubmitting, currentTime);\n\n    chrome.runtime.sendMessage({\n        message: \"time\",\n        time: currentTime\n    });\n}\n\nfunction nextChapter(): void {\n    const chapters = previewBar.unfilteredChapterGroups?.filter((time) => [ActionType.Chapter, null].includes(time.actionType));\n    if (!chapters || chapters.length <= 0) return;\n\n    lastNextChapterKeybind.time = getCurrentTime();\n    lastNextChapterKeybind.date = Date.now();\n\n    const nextChapter = chapters.findIndex((time) => time.segment[0] > getCurrentTime());\n    if (nextChapter !== -1) {\n        setCurrentTime(chapters[nextChapter].segment[0]);\n    } else {\n        setCurrentTime(getVideoDuration());\n    }\n}\n\nfunction previousChapter(): void {\n    if (Date.now() - lastNextChapterKeybind.date < 3000) {\n        setCurrentTime(lastNextChapterKeybind.time);\n        lastNextChapterKeybind.date = 0;\n        return;\n    }\n\n    const chapters = previewBar.unfilteredChapterGroups?.filter((time) => [ActionType.Chapter, null].includes(time.actionType));\n    if (!chapters || chapters.length <= 0) {\n        setCurrentTime(0);\n        return;\n    }\n\n    // subtract 5 seconds to allow skipping back to the previous chapter if close to start of\n    // the current one\n    const nextChapter = chapters.findIndex((time) => time.segment[0] > getCurrentTime() - Math.min(5, time.segment[1] - time.segment[0]));\n    const previousChapter = nextChapter !== -1 ? (nextChapter - 1) : (chapters.length - 1);\n    if (previousChapter !== -1) {\n        setCurrentTime(chapters[previousChapter].segment[0]);\n    } else {\n        setCurrentTime(0);\n    }\n}\n\nasync function handleKeybindVote(type: number): Promise<void>{\n    let lastSkipNotice = skipNotices[0]?.skipNoticeRef.current;\n    lastSkipNotice?.onMouseEnter();\n\n    if (!lastSkipNotice) {\n        const lastSegment = [...sponsorTimes].reverse()?.find((s) => s.source == SponsorSourceType.Server && (s.segment[0] <= getCurrentTime() && getCurrentTime() - (s.segment[1] || s.segment[0]) <= Config.config.skipNoticeDuration));\n        if (!lastSegment) return;\n\n        createSkipNotice([lastSegment], shouldAutoSkip(lastSegment), lastSegment?.segment[0] + 0.001,false, true);\n        lastSkipNotice = await skipNotices[0].waitForSkipNoticeRef();\n        lastSkipNotice?.reskippedMode(0);\n    }\n\n    vote(type,lastSkipNotice?.segments[0]?.UUID, undefined, lastSkipNotice);\n    return;\n}\n\nfunction addHotkeyListener(): void {\n    document.addEventListener(\"keydown\", hotkeyListener, true);\n    document.addEventListener(\"keyup\", hotkeyPropagationListener, true);\n\n    addCleanupListener(() => {\n        document.body.removeEventListener(\"keydown\", hotkeyListener, true);\n        document.body.removeEventListener(\"keyup\", hotkeyPropagationListener, true);\n    });\n}\n\nfunction hotkeyListener(e: KeyboardEvent): void {\n    if (([\"textarea\", \"input\"].includes(document.activeElement?.tagName?.toLowerCase())\n        || (document.activeElement as HTMLElement)?.isContentEditable\n        || document.activeElement?.id?.toLowerCase()?.match(/editable|input/))\n            && document.hasFocus()) return;\n\n    const key: Keybind = {\n        key: e.key,\n        code: e.code,\n        alt: e.altKey,\n        ctrl: e.ctrlKey,\n        shift: e.shiftKey\n    };\n\n    const skipKey = Config.config.skipKeybind;\n    const skipToHighlightKey = Config.config.skipToHighlightKeybind;\n    const closeSkipNoticeKey = Config.config.closeSkipNoticeKeybind;\n    const startSponsorKey = Config.config.startSponsorKeybind;\n    const submitKey = Config.config.actuallySubmitKeybind;\n    const previewKey = Config.config.previewKeybind;\n    const openSubmissionMenuKey = Config.config.submitKeybind;\n    const nextChapterKey = Config.config.nextChapterKeybind;\n    const previousChapterKey = Config.config.previousChapterKeybind;\n    const upvoteKey = Config.config.upvoteKeybind;\n    const downvoteKey = Config.config.downvoteKeybind;\n\n    if (keybindEquals(key, skipKey)) {\n        if (activeSkipKeybindElement && !(activeSkipKeybindElement instanceof SkipButtonControlBar)) {\n            activeSkipKeybindElement.toggleSkip.call(activeSkipKeybindElement);\n        }\n\n        return;\n    } else if (keybindEquals(key, skipToHighlightKey)) {\n        if (skipButtonControlBar) {\n            skipButtonControlBar.toggleSkip.call(skipButtonControlBar);\n        }\n\n        return;\n    } else if (keybindEquals(key, closeSkipNoticeKey)) {\n        for (let i = 0; i < skipNotices.length; i++) {\n            skipNotices.pop().close();\n        }\n        \n        upcomingNotice?.close();\n        upcomingNotice = null;\n        return;\n    } else if (keybindEquals(key, startSponsorKey)) {\n        startOrEndTimingNewSegment();\n        return;\n    } else if (keybindEquals(key, submitKey)) {\n        submitSegments();\n        return;\n    } else if (keybindEquals(key, openSubmissionMenuKey)) {\n        e.preventDefault();\n\n        openSubmissionMenu();\n        return;\n    } else if (keybindEquals(key, previewKey)) {\n        previewRecentSegment();\n        return;\n    } else if (keybindEquals(key, nextChapterKey)) {\n        if (sponsorTimes.length > 0) e.stopPropagation();\n        nextChapter();\n        return;\n    } else if (keybindEquals(key, previousChapterKey)) {\n        if (sponsorTimes.length > 0) e.stopPropagation();\n        previousChapter();\n        return;\n    } else if (keybindEquals(key, upvoteKey)) {\n        handleKeybindVote(1);\n        return;\n    } else if (keybindEquals(key, downvoteKey)) {\n        handleKeybindVote(0);\n        return;\n    }\n}\n\nfunction hotkeyPropagationListener(e: KeyboardEvent): void {\n    if (([\"textarea\", \"input\"].includes(document.activeElement?.tagName?.toLowerCase())\n        || (document.activeElement as HTMLElement)?.isContentEditable\n        || document.activeElement?.id?.toLowerCase()?.match(/editable|input/))\n            && document.hasFocus()) return;\n\n    const key: Keybind = {\n        key: e.key,\n        code: e.code,\n        alt: e.altKey,\n        ctrl: e.ctrlKey,\n        shift: e.shiftKey\n    };\n\n    const nextChapterKey = Config.config.nextChapterKeybind;\n    const previousChapterKey = Config.config.previousChapterKeybind;\n\n    if (keybindEquals(key, nextChapterKey)) {\n        if (sponsorTimes.length > 0) e.stopPropagation();\n        return;\n    } else if (keybindEquals(key, previousChapterKey)) {\n        if (sponsorTimes.length > 0) e.stopPropagation();\n        return;\n    }\n}\n\n/**\n * Adds the CSS to the page if needed. Required on optional sites with Chrome.\n */\nfunction addCSS() {\n    if (!isFirefoxOrSafari() && Config.config.invidiousInstances.includes(new URL(document.URL).hostname)) {\n        const onLoad = () => {\n            const head = document.getElementsByTagName(\"head\")[0];\n\n            for (const file of utils.css) {\n                const fileref = document.createElement(\"link\");\n\n                fileref.rel = \"stylesheet\";\n                fileref.type = \"text/css\";\n                fileref.href = chrome.runtime.getURL(file);\n\n                head.appendChild(fileref);\n            }\n        };\n\n        if (document.readyState === \"complete\") {\n            onLoad();\n        } else {\n            document.addEventListener(\"DOMContentLoaded\", onLoad);\n        }\n    }\n}\n\n/**\n * Update the isAdPlaying flag and hide preview bar/controls if ad is playing\n */\nfunction updateAdFlag(): void {\n    const wasAdPlaying = getIsAdPlaying();\n    setIsAdPlaying(document.getElementsByClassName('ad-showing').length > 0);\n    if(wasAdPlaying != getIsAdPlaying()) {\n        updatePreviewBar();\n        updateVisibilityOfPlayerControlsButton();\n    }\n}\n\nfunction showTimeWithoutSkips(skippedDuration: number): void {\n    if (isNaN(skippedDuration) || skippedDuration < 0) {\n        skippedDuration = 0;\n    }\n\n    // YouTube player time display\n    const selector =\n        isOnInvidious()     ? \".vjs-duration\" :\n        isOnYTTV()          ? \".ypl-full-controls .ypmcs-control .time-info-bar\" :\n        isOnMobileYouTube() ? \".ytwPlayerTimeDisplayContent\" :\n                              \".ytp-time-display.notranslate .ytp-time-wrapper .ytp-time-contents\";\n    const display = document.querySelector(selector);\n    if (!display) return;\n\n    const durationID = \"sponsorBlockDurationAfterSkips\";\n    let duration = document.getElementById(durationID);\n\n    // Create span if needed\n    if (duration === null) {\n        duration = document.createElement('span');\n        duration.id = durationID;\n\n        if (isOnMobileYouTube()) {\n            duration.style.paddingLeft = \"4px\";\n            display.insertBefore(duration, display.lastChild);\n        } else {\n            display.appendChild(duration);\n        }\n    }\n\n    const durationAfterSkips = getFormattedTime(getVideoDuration() - skippedDuration);\n\n    duration.innerText = (durationAfterSkips == null || skippedDuration <= 0) ? \"\" : \" (\" + durationAfterSkips + \")\";\n}\n\nfunction checkForPreloadedSegment() {\n    if (loadedPreloadedSegment) return;\n\n    loadedPreloadedSegment = true;\n    const hashParams = getHashParams();\n\n    let pushed = false;\n    const segments = hashParams.segments;\n    if (Array.isArray(segments)) {\n        for (const segment of segments) {\n            if (Array.isArray(segment.segment)) {\n                if (!sponsorTimesSubmitting.some((s) => s.segment[0] === segment.segment[0] && s.segment[1] === s.segment[1])) {\n                    sponsorTimesSubmitting.push({\n                        segment: segment.segment,\n                        UUID: generateUserID() as SegmentUUID,\n                        category: segment.category ? segment.category : Config.config.defaultCategory,\n                        actionType: segment.actionType ? segment.actionType : ActionType.Skip,\n                        description: segment.description ?? \"\",\n                        source: SponsorSourceType.Local\n                    });\n\n                    pushed = true;\n                }\n            }\n        }\n    }\n\n    if (pushed) {\n        Config.local.unsubmittedSegments[getVideoID()] = sponsorTimesSubmitting;\n        Config.forceLocalUpdate(\"unsubmittedSegments\");\n    }\n}\n\n// Generate and inject a stylesheet that creates CSS variables with configured category colors\nfunction setCategoryColorCSSVariables() {\n    let styleContainer = document.getElementById(\"sbCategoryColorStyle\");\n    if (!styleContainer) {\n        styleContainer = document.createElement(\"style\");\n        styleContainer.id = \"sbCategoryColorStyle\";\n        if (isVorapisInstalled()) {\n            // Vorapi deletes styles\n            styleContainer.className = 'stylus';\n        }\n\n        const head = (document.head || document.documentElement);\n        head.appendChild(styleContainer)\n    }\n\n    let css = \":root {\"\n    for (const [category, config] of Object.entries(Config.config.barTypes)) {\n        css += `--sb-category-${category}: ${config.color};`;\n        css += `--darkreader-bg--sb-category-${category}: ${config.color};`;\n\n        const luminance = GenericUtils.getLuminance(config.color);\n        css += `--sb-category-text-${category}: ${luminance > 128 ? \"black\" : \"white\"};`;\n        css += `--darkreader-text--sb-category-text-${category}: ${luminance > 128 ? \"black\" : \"white\"};`;\n    }\n    css += \"}\";\n\n    styleContainer.innerText = css;\n}\n\n/**\n * If mini player starts playing, then videoID change might have to be called\n */\nfunction checkForMiniplayerPlaying() {\n    const miniPlayerUI = document.querySelector(\".miniplayer\") as HTMLElement;\n    if (!onVideoPage() && isVisible(miniPlayerUI)) {\n        const videoID = getLastNonInlineVideoID();\n        if (videoID) {\n            triggerVideoIDChange(videoID);\n\n            // treat as if video element has changed\n            const video = miniPlayerUI.querySelector(\"video\") as HTMLVideoElement;\n            if (video && getVideo() !== video) {\n                triggerVideoElementChange(video);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/dearrowPromotion.ts",
    "content": "import { waitFor } from \"../maze-utils/src\";\nimport { getYouTubeTitleNode } from \"../maze-utils/src/elements\";\nimport { getHash } from \"../maze-utils/src/hash\";\nimport { getVideoID, isOnInvidious, isOnMobileYouTube } from \"../maze-utils/src/video\";\nimport Config from \"./config\";\nimport { Tooltip } from \"./render/Tooltip\";\nimport { isDeArrowInstalled } from \"./utils/crossExtension\";\nimport { isVisible } from \"./utils/pageUtils\";\nimport { asyncRequestToServer } from \"./utils/requests\";\n\nlet tooltip: Tooltip = null;\nconst showDeArrowPromotion = false;\nexport async function tryShowingDeArrowPromotion() {\n    if (showDeArrowPromotion\n            && Config.config.showDeArrowPromotion\n            && !isOnMobileYouTube()\n            && !isOnInvidious()\n            && document.URL.includes(\"watch\")\n            && Config.config.showUpsells \n            && Config.config.showNewFeaturePopups\n            && (Config.config.skipCount > 30 || !Config.config.trackViewCount)) {\n\n            if (!await isDeArrowInstalled()) {\n                try {\n                    const element = await waitFor(() => getYouTubeTitleNode(), 5000, 500, (e) => isVisible(e)) as HTMLElement;\n                    if (element && element.innerText && badTitle(element.innerText)) {\n                        const hashPrefix = (await getHash(getVideoID(), 1)).slice(0, 4);\n                        const deArrowData = await asyncRequestToServer(\"GET\", \"/api/branding/\" + hashPrefix);\n                        if (!deArrowData.ok) return;\n\n                        const deArrowDataJson = JSON.parse(deArrowData.responseText);\n                        const title = deArrowDataJson?.[getVideoID()]?.titles?.[0];\n                        if (title && title.title && (title.locked || title.votes > 0)) {\n                            Config.config.showDeArrowPromotion = false;\n\n                            tooltip = new Tooltip({\n                                text: chrome.i18n.getMessage(\"DeArrowTitleReplacementSuggestion\") + \"\\n\\n\" + title.title,\n                                linkOnClick: () => {\n                                    window.open(\"https://dearrow.ajay.app\");\n                                    Config.config.shownDeArrowPromotion = true;\n                                },\n                                secondButtonText: chrome.i18n.getMessage(\"hideNewFeatureUpdates\"),\n                                referenceNode: element,\n                                prependElement: element.firstElementChild as HTMLElement,\n                                timeout: 15000,\n                                positionRealtive: false,\n                                containerAbsolute: true,\n                                bottomOffset: \"inherit\",\n                                topOffset: \"55px\",\n                                leftOffset: \"0\",\n                                rightOffset: \"0\",\n                                topTriangle: true,\n                                center: true,\n                                opacity: 1\n                            });\n                        }\n                    }\n                } catch { } // eslint-disable-line no-empty\n            } else {\n                Config.config.showDeArrowPromotion = false;\n            }\n        }\n}\n\n/**\n * Two upper case words (at least 2 letters long)\n */\nfunction badTitle(title: string): boolean {\n    return !!title.match(/\\p{Lu}{2,} \\p{Lu}{2,}[.!? ]/u);\n}\n\nexport function hideDeArrowPromotion(): void {\n    if (tooltip) tooltip.close();\n}\n"
  },
  {
    "path": "src/document.ts",
    "content": "import { init } from \"../maze-utils/src/injected/document\";\n\ninit();"
  },
  {
    "path": "src/globals.d.ts",
    "content": "import { SBObject } from \"./config\";\ndeclare global {\n    interface Window { SB: SBObject }\n}\n"
  },
  {
    "path": "src/help.ts",
    "content": "import { localizeHtmlPage } from \"../maze-utils/src/setup\";\nimport Config from \"./config\";\nimport { showDonationLink } from \"./utils/configUtils\";\n\nimport { waitFor } from \"../maze-utils/src\";\nimport { isDeArrowInstalled } from \"./utils/crossExtension\";\n\nif (document.readyState === \"complete\") {\n    init();\n} else {\n    document.addEventListener(\"DOMContentLoaded\", init);\n}\n\n// DeArrow promotion\nwaitFor(() => Config.isReady()).then(() => {\n    if (Config.config.showNewFeaturePopups && Config.config.showUpsells) {\n        isDeArrowInstalled().then((installed) => {\n            if (!installed) {\n                const deArrowPromotion = document.getElementById(\"dearrow-link\");\n                deArrowPromotion.classList.remove(\"hidden\");\n\n                deArrowPromotion.addEventListener(\"click\", () => Config.config.showDeArrowPromotion = false);\n\n                const text = deArrowPromotion.querySelector(\"#dearrow-link-text\");\n                text.textContent = `${chrome.i18n.getMessage(\"DeArrowPromotionMessage2\").split(\"?\")[0]}? ${chrome.i18n.getMessage(\"DeArrowPromotionMessage3\")}`;\n\n                const closeButton = deArrowPromotion.querySelector(\".close-button\");\n                closeButton.addEventListener(\"click\", (e) => {\n                    e.preventDefault();\n\n                    deArrowPromotion.classList.add(\"hidden\");\n                    Config.config.showDeArrowPromotion = false;\n                    Config.config.showDeArrowInSettings = false;\n                });\n            }\n        });\n    }\n});\n\nasync function init() {\n    localizeHtmlPage();\n\n    await waitFor(() => Config.config !== null);\n\n    if (!Config.config.darkMode) {\n        document.documentElement.setAttribute(\"data-theme\", \"light\");\n    }\n\n    if (!showDonationLink()) {\n        document.getElementById(\"donate-component\").style.display = \"none\";\n    }\n}"
  },
  {
    "path": "src/js-components/previewBar.ts",
    "content": "/*\nBased on code from\nhttps://github.com/videosegments/videosegments/commits/f1e111bdfe231947800c6efdd51f62a4e7fef4d4/segmentsbar/segmentsbar.js\n*/\n\nimport Config from \"../config\";\nimport { ChapterVote } from \"../render/ChapterVote\";\nimport { ActionType, Category, CategorySkipOption, SegmentContainer, SponsorHideType, SponsorSourceType, SponsorTime } from \"../types\";\nimport { partition } from \"../utils/arrayUtils\";\nimport { DEFAULT_CATEGORY, shortCategoryName } from \"../utils/categoryUtils\";\nimport { normalizeChapterName } from \"../utils/exporter\";\nimport { findNonEmptyElement, findValidElement } from \"../../maze-utils/src/dom\";\nimport { addCleanupListener } from \"../../maze-utils/src/cleanup\";\nimport { hasAutogeneratedChapters, isVisible } from \"../utils/pageUtils\";\nimport { isVorapisInstalled } from \"../utils/compatibility\";\nimport { isOnYTTV } from \"../../maze-utils/src/video\";\nimport { getCategorySelection } from \"../utils/skipRule\";\nimport { getSkipProfileBool } from \"../utils/skipProfiles\";\n\nconst TOOLTIP_VISIBLE_CLASS = 'sponsorCategoryTooltipVisible';\nconst MIN_CHAPTER_SIZE = 0.003;\n\nexport interface PreviewBarSegment {\n    segment: [number, number];\n    category: Category;\n    actionType: ActionType;\n    unsubmitted: boolean;\n    showLarger: boolean;\n    description: string;\n    source: SponsorSourceType;\n    requiredSegment?: boolean;\n    selectedSegment?: boolean;\n}\n\ninterface ChapterGroup extends SegmentContainer {\n    originalDuration: number;\n    actionType: ActionType;\n}\n\nclass PreviewBar {\n    container: HTMLUListElement;\n    categoryTooltip?: HTMLDivElement;\n    categoryTooltipContainer?: HTMLElement;\n    chapterTooltip?: HTMLDivElement;\n\n    // ScrubTooltips for YTTV only\n    categoryScrubTooltip?: HTMLDivElement;\n    categoryScrubTooltipContainer?: HTMLElement;\n    chapterScrubTooltip?: HTMLDivElement;\n\n    lastSmallestSegment: Record<string, {\n        index: number;\n        segment: PreviewBarSegment;\n    }> = {};\n\n    parent: HTMLElement;\n    onMobileYouTube: boolean;\n    onInvidious: boolean;\n    onYTTV: boolean;\n    progressBar: HTMLElement;\n\n    segments: PreviewBarSegment[] = [];\n    hasYouTubeChapters = false;\n    existingChapters: PreviewBarSegment[] = [];\n    videoDuration = 0;\n    updateExistingChapters: () => void;\n    lastChapterUpdate = 0;\n\n    // For chapter bar\n    hoveredSection: HTMLElement;\n    customChaptersBar: HTMLElement;\n    chaptersBarSegments: PreviewBarSegment[];\n    chapterVote: ChapterVote;\n    originalChapterBar: HTMLElement;\n    originalChapterBarBlocks: NodeListOf<HTMLElement>;\n    chapterMargin: number;\n    lastRenderedSegments: PreviewBarSegment[];\n    unfilteredChapterGroups: ChapterGroup[];\n    chapterGroups: ChapterGroup[];\n\n    constructor(parent: HTMLElement, onMobileYouTube: boolean, onInvidious: boolean, onYTTV: boolean, chapterVote: ChapterVote, updateExistingChapters: () => void, test=false) {\n        if (test) return;\n        this.container = document.createElement('ul');\n        this.container.id = 'previewbar';\n\n        if (onYTTV) {\n            this.container.classList.add(\"sponsorblock-yttv-container\");\n        }\n\n        this.parent = parent;\n        this.onMobileYouTube = onMobileYouTube;\n        this.onInvidious = onInvidious;\n        this.onYTTV = onYTTV;\n        this.chapterVote = chapterVote;\n        this.updateExistingChapters = updateExistingChapters;\n\n        this.updatePageElements();\n        this.createElement(parent);\n        this.createChapterMutationObservers();\n\n        this.setupHoverText();\n    }\n\n    setupHoverText(): void {\n        if (this.onMobileYouTube || this.onInvidious) return;\n\n        // delete old ones\n        document.querySelectorAll(`.sponsorCategoryTooltip`)\n            .forEach((e) => e.remove());\n\n        // Create label placeholder\n        this.categoryTooltip = document.createElement(\"div\");\n        if (isOnYTTV()) {\n            this.categoryTooltip.className = \"sponsorCategoryTooltip\";\n        } else {\n            this.categoryTooltip.className = \"ytp-tooltip-title sponsorCategoryTooltip\";\n        }\n        this.chapterTooltip = document.createElement(\"div\");\n        if (isOnYTTV()) {\n            this.chapterTooltip.className = \"sponsorCategoryTooltip\";\n        } else {\n            this.chapterTooltip.className = \"ytp-tooltip-title sponsorCategoryTooltip\";\n        }\n\n        if (isOnYTTV()) {\n            this.categoryScrubTooltip = document.createElement(\"div\");\n            this.categoryScrubTooltip.className = \"sponsorCategoryTooltip\";\n            this.chapterScrubTooltip = document.createElement(\"div\");\n            this.chapterScrubTooltip.className = \"sponsorCategoryTooltip\";\n        }\n\n        // global chapter tooltip or duration tooltip\n        // YT, Vorapis, unknown, YTTV\n        const tooltipTextWrapper = document.querySelector(\".ytp-tooltip-text-wrapper, .ytp-progress-tooltip-text-container, .yssi-slider .ys-seek-details .time-info-bar\") ?? document.querySelector(\"#progress-bar-container.ytk-player > #hover-time-info\");\n        const defaultTooltipSelector = \".ytp-tooltip-title:not(.sponsorCategoryTooltip), .ytp-tooltip-title:not(.sponsorCategoryTooltip) span, .ytp-progress-tooltip-text:not(.sponsorCategoryTooltip), .current-time:not(.sponsorCategoryTooltip)\";\n        const originalTooltip = findNonEmptyElement([\n            defaultTooltipSelector,\n            \".ytp-tooltip-progress-bar-pill-title\"\n        ]) ?? document.querySelector(defaultTooltipSelector);\n        if (!tooltipTextWrapper || !tooltipTextWrapper.parentElement) return;\n\n        // Grab the tooltip from the text wrapper as the tooltip doesn't have its classes on init\n        this.categoryTooltipContainer = tooltipTextWrapper.parentElement;\n        // YT, Vorapis, YTTV\n        const titleTooltip = tooltipTextWrapper.querySelector(\".ytp-tooltip-title, .ytp-progress-tooltip-text, .current-time\") as HTMLElement;\n        if (!this.categoryTooltipContainer || !titleTooltip) return;\n\n        tooltipTextWrapper.insertBefore(this.categoryTooltip, titleTooltip.nextSibling);\n        tooltipTextWrapper.insertBefore(this.chapterTooltip, titleTooltip.nextSibling);\n\n        if (isOnYTTV()) {\n            const scrubTooltipTextWrapper = document.querySelector(\".yssi-slider .ysl-filmstrip-lens .time-info-bar\")\n            if (!this.categoryTooltipContainer) return;\n    \n            scrubTooltipTextWrapper.appendChild(this.categoryScrubTooltip);\n            scrubTooltipTextWrapper.appendChild(this.chapterScrubTooltip);\n        }\n\n        const seekBar = (document.querySelector(\".ytp-progress-bar-container, .ypcs-scrub-slider-slot.ytu-player-controls\"));\n        if (!seekBar) return;\n\n        let mouseOnSeekBar = false;\n\n        seekBar.addEventListener(\"mouseenter\", () => {\n            mouseOnSeekBar = true;\n        });\n\n        seekBar.addEventListener(\"mouseleave\", () => {\n            mouseOnSeekBar = false;\n        });\n\n        seekBar.addEventListener(\"mousemove\", (e: MouseEvent) => {\n            if (!mouseOnSeekBar || !this.categoryTooltip || !this.categoryTooltipContainer || !chrome.runtime?.id) return;\n\n            let noYoutubeChapters = !!tooltipTextWrapper.querySelector(\".ytp-tooltip-text.ytp-tooltip-text-no-title, .ytp-progress-tooltip-timestamp\");\n            const timeInSeconds = this.decimalToTime((e.clientX - seekBar.getBoundingClientRect().x) / seekBar.clientWidth);\n\n            // Find the segment at that location, using the shortest if multiple found\n            const [normalSegments, chapterSegments] =\n                partition(this.segments,\n                    (segment) => segment.actionType !== ActionType.Chapter);\n            let mainSegment = this.getSmallestSegment(timeInSeconds, normalSegments, \"normal\");\n            let secondarySegment = this.getSmallestSegment(timeInSeconds, chapterSegments, \"chapter\");\n            if (mainSegment === null && secondarySegment !== null) {\n                mainSegment = secondarySegment;\n                secondarySegment = this.getSmallestSegment(timeInSeconds, chapterSegments.filter((s) => s !== secondarySegment));\n            }\n\n            const hasAYouTubeChapterRemoved = this.hasYouTubeChapters\n                || (!getSkipProfileBool(\"showAutogeneratedChapters\") && hasAutogeneratedChapters());\n            if (hasAYouTubeChapterRemoved) {\n                // Hide original tooltip if some chapter has been filtered out\n                originalTooltip.style.display = \"none\";\n                noYoutubeChapters = true;\n            }\n\n            if (mainSegment === null && secondarySegment === null) {\n                if (!hasAYouTubeChapterRemoved) {\n                    this.categoryTooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS);\n                    originalTooltip.style.removeProperty(\"display\");\n                }\n                if (this.onYTTV) {\n                    this.setTooltipTitle(mainSegment, this.categoryTooltip);\n                    this.setTooltipTitle(secondarySegment, this.chapterTooltip);\n                    this.setTooltipTitle(mainSegment, this.categoryScrubTooltip);\n                    this.setTooltipTitle(secondarySegment, this.chapterScrubTooltip);\n                }\n\n                this.categoryTooltipContainer.classList.remove(\"sponsorHasOriginalTooltip\");\n            } else {\n                this.categoryTooltipContainer.classList.add(TOOLTIP_VISIBLE_CLASS);\n                const hasTwoTooltips = mainSegment !== null && secondarySegment !== null;\n                if (hasTwoTooltips) {\n                    this.categoryTooltipContainer.classList.add(\"sponsorTwoTooltips\");\n                } else {\n                    this.categoryTooltipContainer.classList.remove(\"sponsorTwoTooltips\");\n                }\n\n                this.setTooltipTitle(mainSegment, this.categoryTooltip);\n                this.setTooltipTitle(secondarySegment, this.chapterTooltip);\n                if (this.onYTTV) {\n                    this.setTooltipTitle(mainSegment, this.categoryScrubTooltip);\n                    this.setTooltipTitle(secondarySegment, this.chapterScrubTooltip);\n                }\n\n                if (isVorapisInstalled()) {\n                    const tooltipParent = tooltipTextWrapper.parentElement!;\n                    tooltipParent.classList.add(\"with-text\");\n                }\n\n                if (normalizeChapterName(originalTooltip.textContent) === normalizeChapterName(this.categoryTooltip.textContent)\n                        || normalizeChapterName(originalTooltip.textContent) === normalizeChapterName(this.chapterTooltip.textContent)\n                        || !originalTooltip.textContent) {\n                    if (originalTooltip.style.display !== \"none\") originalTooltip.style.display = \"none\";\n                    this.categoryTooltipContainer.classList.remove(\"sponsorHasOriginalTooltip\");\n                    noYoutubeChapters = true;\n                } else if (originalTooltip.style.display === \"none\") {\n                    originalTooltip.style.removeProperty(\"display\");\n                    this.categoryTooltipContainer.classList.add(\"sponsorHasOriginalTooltip\");\n                    noYoutubeChapters = false;\n                }\n\n                // To prevent offset issue\n                this.categoryTooltip.style.right = titleTooltip.style.right;\n                this.chapterTooltip.style.right = titleTooltip.style.right;\n                this.categoryTooltip.style.textAlign = titleTooltip.style.textAlign;\n                this.chapterTooltip.style.textAlign = titleTooltip.style.textAlign;\n            }\n\n            // Used to prevent overlapping\n            this.categoryTooltip.classList.toggle(\"ytp-tooltip-text-no-title\", noYoutubeChapters);\n            this.chapterTooltip.classList.toggle(\"ytp-tooltip-text-no-title\", noYoutubeChapters);\n        });\n    }\n\n    private setTooltipTitle(segment: PreviewBarSegment, tooltip: HTMLElement): void {\n        if (segment) {\n            const name = segment.description || shortCategoryName(segment.category);\n            if (segment.unsubmitted) {\n                tooltip.textContent = chrome.i18n.getMessage(\"unsubmitted\") + \" \" + name;\n            } else {\n                tooltip.textContent = name;\n            }\n\n            tooltip.style.removeProperty(\"display\");\n\n            // For July 2025 test layout\n            if (document.querySelector(\".ytp-delhi-modern\")) {\n                tooltip.style.display = \"inline-block\";\n\n                // Class gets added back, so grab the top value for when the class is removed\n                tooltip.style.removeProperty(\"top\");\n                tooltip.classList.remove(\"ytp-tooltip-text-no-title\");\n\n                if (tooltip === this.chapterTooltip) {\n                    tooltip.style.top = `calc(${window.getComputedStyle(tooltip).getPropertyValue(\"top\")} + 5px)`;\n                } else {\n                    tooltip.style.top = window.getComputedStyle(tooltip).getPropertyValue(\"top\");\n                }\n            }\n        } else {\n            tooltip.style.display = \"none\";\n        }\n    }\n\n    createElement(parent?: HTMLElement): void {\n        if (parent) this.parent = parent;\n\n        if (this.onMobileYouTube) {\n            this.container.style.transform = \"none\";\n        } else if (!this.onInvidious) {\n            this.container.classList.add(\"sbNotInvidious\");\n        }\n\n        // On the seek bar\n        if (this.onYTTV) {\n            // order of sibling elements matters on YTTV\n            this.parent.insertBefore(this.container, this.parent.firstChild.nextSibling.nextSibling);\n        } else {\n            this.parent.prepend(this.container);\n        }\n    }\n\n    clear(): void {\n        while (this.container.firstChild) {\n            this.container.removeChild(this.container.firstChild);\n        }\n\n        if (this.customChaptersBar) this.customChaptersBar.style.display = \"none\";\n        this.originalChapterBar?.style?.removeProperty(\"display\");\n        this.chapterVote?.setVisibility(false);\n\n        document.querySelectorAll(`.sponsorBlockChapterBar`).forEach((e) => {\n            if (e !== this.customChaptersBar) {\n                e.remove();\n            }\n        });\n    }\n\n    set(segments: PreviewBarSegment[], videoDuration: number): void {\n        this.segments = segments ?? [];\n        this.videoDuration = videoDuration ?? 0;\n        this.hasYouTubeChapters = segments.some((segment) => [SponsorSourceType.YouTube, SponsorSourceType.Autogenerated].includes(segment.source));\n\n        // Remove unnecessary original chapters if submitted replacements exist\n        for (const chapter of this.segments.filter((s) => s.actionType === ActionType.Chapter && s.source === SponsorSourceType.Server)) {\n            const segmentDuration = chapter.segment[1] - chapter.segment[0];\n            \n            const duplicate = this.segments.find((s) => s.actionType === ActionType.Chapter \n                && [SponsorSourceType.YouTube, SponsorSourceType.Autogenerated].includes(s.source) \n                && Math.abs(s.segment[0] - chapter.segment[0]) < Math.min(3, segmentDuration / 3)\n                && Math.abs(s.segment[1] - chapter.segment[1]) < Math.min(3, segmentDuration / 3));\n            \n            if (duplicate) {\n                const index = this.segments.indexOf(duplicate);\n                this.segments.splice(index, 1);\n            }\n        }\n\n        this.updatePageElements();\n        // Sometimes video duration is inaccurate, pull from accessibility info\n        const ariaDuration = parseInt(this.progressBar?.getAttribute('aria-valuemax')) ?? 0;\n        const multipleActiveVideos = [...document.querySelectorAll(\"video\")].filter((v) => isVisible(v)).length > 1;\n        if (!multipleActiveVideos && ariaDuration && Math.abs(ariaDuration - this.videoDuration) > 3) {\n            this.videoDuration = ariaDuration;\n        }\n\n        this.update();\n    }\n\n    private updatePageElements(): void {\n        // YT, Vorapis v3\n        const allProgressBars = document.querySelectorAll(\".ytp-progress-bar, .ytp-progress-bar-container > .html5-progress-bar > .ytp-progress-list\") as NodeListOf<HTMLElement>;\n        this.progressBar = findValidElement(allProgressBars) ?? allProgressBars?.[0];\n\n        if (this.progressBar) {\n            const newChapterBar = this.progressBar.querySelector(\".ytp-chapters-container:not(.sponsorBlockChapterBar)\") as HTMLElement;\n            if (this.originalChapterBar !== newChapterBar) {\n                // Make sure changes are undone on old bar\n                this.originalChapterBar?.style?.removeProperty(\"display\");\n\n                this.originalChapterBar = newChapterBar;\n            }\n        }\n    }\n\n    private update(): void {\n        this.clear();\n        const chapterChevron = this.getChapterChevron();\n\n        if (!this.segments) {\n            chapterChevron?.style?.removeProperty(\"display\");\n        }\n\n        this.chapterMargin = 2;\n        if (this.originalChapterBar) {\n            this.originalChapterBarBlocks = this.originalChapterBar.querySelectorAll(\":scope > div\") as NodeListOf<HTMLElement>\n            this.existingChapters = this.segments.filter((s) => [SponsorSourceType.YouTube, SponsorSourceType.Autogenerated].includes(s.source)).sort((a, b) => a.segment[0] - b.segment[0]);\n\n            if (this.existingChapters?.length > 0) {\n                const margin = parseFloat(this.originalChapterBarBlocks?.[0]?.style?.marginRight?.replace(\"px\", \"\"));\n                if (margin) this.chapterMargin = margin;\n            }\n        }\n\n        const sortedSegments = this.segments.sort(({ segment: a }, { segment: b }) => {\n            // Sort longer segments before short segments to make shorter segments render later\n            return (b[1] - b[0]) - (a[1] - a[0]);\n        });\n        for (const segment of sortedSegments) {\n            if (segment.actionType === ActionType.Chapter) continue;\n            const bar = this.createBar(segment);\n\n            this.container.appendChild(bar);\n        }\n\n        this.createChaptersBar(this.segments.sort((a, b) => a.segment[0] - b.segment[0]));\n\n        if (chapterChevron) {\n            if (this.segments.some((segment) => [SponsorSourceType.YouTube, SponsorSourceType.Autogenerated].includes(segment.source))) {\n                chapterChevron.style.removeProperty(\"display\");\n            } else if (this.segments) {\n                chapterChevron.style.display = \"none\";\n            }\n        }\n    }\n\n    createBar(barSegment: PreviewBarSegment): HTMLLIElement {\n        const { category, unsubmitted, segment, showLarger } = barSegment;\n\n        const bar = document.createElement('li');\n        bar.classList.add('previewbar');\n        if (barSegment.requiredSegment) bar.classList.add(\"requiredSegment\");\n        if (barSegment.selectedSegment) bar.classList.add(\"selectedSegment\");\n        bar.innerHTML = showLarger ? '&nbsp;&nbsp;' : '&nbsp;';\n\n        const fullCategoryName = (unsubmitted ? 'preview-' : '') + category;\n        bar.setAttribute('sponsorblock-category', fullCategoryName);\n\n        // Handled by setCategoryColorCSSVariables() of content.ts\n        bar.style.backgroundColor = `var(--sb-category-${fullCategoryName})`;\n        if (!this.onMobileYouTube) bar.style.opacity = Config.config.barTypes[fullCategoryName]?.opacity;\n\n        bar.style.position = \"absolute\";\n        const duration = Math.min(segment[1], this.videoDuration) - segment[0];\n        const startTime = segment[1] ? Math.min(this.videoDuration, segment[0]) : segment[0];\n        const endTime = Math.min(this.videoDuration, segment[1]);\n        bar.style.left = this.timeToPercentage(startTime);\n\n        if (duration > 0) {\n            bar.style.right = this.timeToRightPercentage(endTime);\n        }\n        if (this.chapterFilter(barSegment) && segment[1] < this.videoDuration) {\n            bar.style.marginRight = `${this.chapterMargin}px`;\n        }\n\n        if (this.onYTTV) {\n            bar.classList.add(\"previewbar-yttv\");\n        }\n\n        return bar;\n    }\n\n    createChaptersBar(segments: PreviewBarSegment[]): void {\n        if (!this.progressBar || !this.originalChapterBar || this.originalChapterBar.childElementCount <= 0) {\n            if (this.originalChapterBar) this.originalChapterBar.style.removeProperty(\"display\");\n\n            // Make sure other video types lose their chapter bar\n            document.querySelectorAll(\".sponsorBlockChapterBar\").forEach((element) => element.remove());\n            this.customChaptersBar = null;\n            return;\n        }\n\n        const remakingBar = segments !== this.lastRenderedSegments;\n        if (remakingBar) {\n            this.lastRenderedSegments = segments;\n\n            // Merge overlapping chapters\n            this.unfilteredChapterGroups = this.createChapterRenderGroups(segments);\n        }\n        \n        if ((segments.every((segment) => [SponsorSourceType.YouTube, SponsorSourceType.Autogenerated].includes(segment.source))\n            || (!Config.config.renderSegmentsAsChapters\n                && segments.every((segment) => segment.actionType !== ActionType.Chapter\n                    || [SponsorSourceType.YouTube, SponsorSourceType.Autogenerated].includes(segment.source))))\n            && !(hasAutogeneratedChapters() && !getSkipProfileBool(\"showAutogeneratedChapters\"))) {\n\n            if (this.customChaptersBar) this.customChaptersBar.style.display = \"none\";\n            this.originalChapterBar.style.removeProperty(\"display\");\n            return;\n        }\n\n        const filteredSegments = segments?.filter((segment) => this.chapterFilter(segment));\n        if (filteredSegments) {\n            let groups = this.unfilteredChapterGroups;\n            if (filteredSegments.length !== segments.length) {\n                groups = this.createChapterRenderGroups(filteredSegments);\n            }\n            this.chapterGroups = groups.filter((segment) => this.chapterGroupFilter(segment));\n\n            if (groups.length !== this.chapterGroups.length) {\n                // Fix missing sections due to filtered segments\n                for (let i = 1; i < this.chapterGroups.length; i++) {\n                    if (this.chapterGroups[i].segment[0] !== this.chapterGroups[i - 1].segment[1]) {\n                        this.chapterGroups[i - 1].segment[1] = this.chapterGroups[i].segment[0]\n                    }\n                }\n            }\n        } else {\n            this.chapterGroups = this.unfilteredChapterGroups;\n        }\n\n        if (this.chapterGroups.length === 0 && !getSkipProfileBool(\"showAutogeneratedChapters\") && hasAutogeneratedChapters()) {\n            // Add placeholder chapter group for whole video\n            this.chapterGroups = [{\n                segment: [0, this.videoDuration],\n                originalDuration: 0,\n                actionType: null\n            }];\n        }\n\n        if (!this.chapterGroups || this.chapterGroups.length <= 0) {\n            if (this.customChaptersBar) this.customChaptersBar.style.display = \"none\";\n            this.originalChapterBar.style.removeProperty(\"display\");\n            return;\n        }\n\n        // Create it from cloning\n        let createFromScratch = false;\n        if (!this.customChaptersBar || !this.progressBar.contains(this.customChaptersBar)) {\n            // Clear anything remaining\n            document.querySelectorAll(\".sponsorBlockChapterBar\").forEach((element) => element.remove());\n\n            createFromScratch = true;\n            this.customChaptersBar = this.originalChapterBar.cloneNode(true) as HTMLElement;\n            this.customChaptersBar.classList.add(\"sponsorBlockChapterBar\");\n        }\n\n        this.customChaptersBar.style.display = \"none\";\n        const originalSections = this.customChaptersBar.querySelectorAll(\".ytp-chapter-hover-container\");\n        const originalSection = originalSections[0];\n\n        // For switching to a video with less chapters\n        if (originalSections.length > this.chapterGroups.length) {\n            for (let i = originalSections.length - 1; i >= this.chapterGroups.length; i--) {\n                this.customChaptersBar.removeChild(originalSections[i]);\n            }\n        }\n\n        // Modify it to have sections for each segment\n        for (let i = 0; i < this.chapterGroups.length; i++) {\n            const chapter = this.chapterGroups[i].segment;\n            let newSection = originalSections[i] as HTMLElement;\n            if (!newSection) {\n                newSection = originalSection.cloneNode(true) as HTMLElement;\n\n                this.firstTimeSetupChapterSection(newSection);\n                this.customChaptersBar.appendChild(newSection);\n            } else if (createFromScratch) {\n                this.firstTimeSetupChapterSection(newSection);\n            }\n\n            this.setupChapterSection(newSection, chapter[0], chapter[1], i !== this.chapterGroups.length - 1);\n        }\n\n        // Hide old bar\n        this.originalChapterBar.style.display = \"none\";\n        this.customChaptersBar.style.removeProperty(\"display\");\n\n        if (createFromScratch) {\n            if (this.container?.parentElement === this.progressBar) {\n                this.progressBar.insertBefore(this.customChaptersBar, this.container.nextSibling);\n            } else {\n                this.progressBar.prepend(this.customChaptersBar);\n            }\n        }\n\n        if (remakingBar) {\n            this.updateChapterAllMutation(this.originalChapterBar, this.progressBar, true);\n        }\n    }\n\n    createChapterRenderGroups(segments: PreviewBarSegment[]): ChapterGroup[] {\n        const result: ChapterGroup[] = [];\n\n        segments?.forEach((segment, index) => {\n            const latestChapter = result[result.length - 1];\n            if (latestChapter && latestChapter.segment[1] > segment.segment[0]) {\n                const segmentDuration = segment.segment[1] - segment.segment[0];\n                if (segment.segment[0] < latestChapter.segment[0]\n                        || segmentDuration < latestChapter.originalDuration) {\n                    // Remove latest if it starts too late\n                    let latestValidChapter = latestChapter;\n                    const chaptersToAddBack: ChapterGroup[] = []\n                    while (latestValidChapter?.segment[0] >= segment.segment[0]) {\n                        const invalidChapter = result.pop();\n                        if (invalidChapter.segment[1] > segment.segment[1]) {\n                            if (invalidChapter.segment[0] === segment.segment[0]) {\n                                invalidChapter.segment[0] = segment.segment[1];\n                            }\n\n                            chaptersToAddBack.push(invalidChapter);\n                        }\n                        latestValidChapter = result[result.length - 1];\n                    }\n\n                    const priorityActionType = this.getActionTypePrioritized([segment.actionType, latestChapter?.actionType]);\n\n                    // Split the latest chapter if smaller\n                    result.push({\n                        segment: [segment.segment[0], segment.segment[1]],\n                        originalDuration: segmentDuration,\n                        actionType: priorityActionType\n                    });\n                    if (latestValidChapter?.segment[1] > segment.segment[1]) {\n                        result.push({\n                            segment: [segment.segment[1], latestValidChapter.segment[1]],\n                            originalDuration: latestValidChapter.originalDuration,\n                            actionType: latestValidChapter.actionType\n                        });\n                    }\n\n                    chaptersToAddBack.reverse();\n                    let lastChapterChecked: number[] = segment.segment;\n                    for (const chapter of chaptersToAddBack) {\n                        if (chapter.segment[0] < lastChapterChecked[1]) {\n                            chapter.segment[0] = lastChapterChecked[1];\n                        }\n\n                        lastChapterChecked = chapter.segment;\n                    }\n                    result.push(...chaptersToAddBack);\n                    if (latestValidChapter) latestValidChapter.segment[1] = segment.segment[0];\n                } else {\n                    // Start at end of old one otherwise\n                    result.push({\n                        segment: [latestChapter.segment[1], segment.segment[1]],\n                        originalDuration: segmentDuration,\n                        actionType: segment.actionType\n                    });\n                }\n            } else {\n                // Add empty buffer before segment if needed\n                const lastTime = latestChapter?.segment[1] || 0;\n                if (segment.segment[0] > lastTime) {\n                    result.push({\n                        segment: [lastTime, segment.segment[0]],\n                        originalDuration: 0,\n                        actionType: null\n                    });\n                }\n\n                // Normal case\n                const endTime = Math.min(segment.segment[1], this.videoDuration);\n                result.push({\n                    segment: [segment.segment[0], endTime],\n                    originalDuration: endTime - segment.segment[0],\n                    actionType: segment.actionType\n                });\n            }\n\n            // Add empty buffer after segment if needed\n            if (index === segments.length - 1) {\n                const nextSegment = segments[index + 1];\n                const nextTime = nextSegment ? nextSegment.segment[0] : this.videoDuration;\n                const lastTime = result[result.length - 1]?.segment[1] || segment.segment[1];\n                if (this.intervalToDecimal(lastTime, nextTime) > MIN_CHAPTER_SIZE) {\n                    result.push({\n                        segment: [lastTime, nextTime],\n                        originalDuration: 0,\n                        actionType: null\n                    });\n                }\n            }\n        });\n\n        return result;\n    }\n\n    private getActionTypePrioritized(actionTypes: ActionType[]): ActionType {\n        if (actionTypes.includes(ActionType.Skip)) {\n            return ActionType.Skip;\n        } else if (actionTypes.includes(ActionType.Mute)) {\n            return ActionType.Mute;\n        } else {\n            return actionTypes.find(a => a) ?? actionTypes[0];\n        }\n    }\n\n    private setupChapterSection(section: HTMLElement, startTime: number, endTime: number, addMargin: boolean): void {\n        const sizePercent = this.intervalToPercentage(startTime, endTime);\n        if (addMargin) {\n            section.style.marginRight = `${this.chapterMargin}px`;\n            section.style.width = `calc(${sizePercent} - ${this.chapterMargin}px)`;\n        } else {\n            section.style.marginRight = \"0\";\n            section.style.width = sizePercent;\n        }\n\n        section.setAttribute(\"decimal-width\", String(this.intervalToDecimal(startTime, endTime)));\n    }\n\n    private firstTimeSetupChapterSection(section: HTMLElement): void {\n        section.addEventListener(\"mouseenter\", () => {\n            this.hoveredSection?.classList.remove(\"ytp-exp-chapter-hover-effect\");\n            section.classList.add(\"ytp-exp-chapter-hover-effect\");\n            this.hoveredSection = section;\n        });\n    }\n\n    private createChapterMutationObservers(): void {\n        if (!this.progressBar || !this.originalChapterBar) return;\n\n        const attributeObserver = new MutationObserver((mutations) => {\n            const changes: Record<string, HTMLElement> = {};\n            for (const mutation of mutations) {\n                const currentElement = mutation.target as HTMLElement;\n                if (mutation.type === \"attributes\"\n                    && currentElement.parentElement?.classList.contains(\"ytp-progress-list\")) {\n                    changes[currentElement.classList[0]] = mutation.target as HTMLElement;\n                }\n            }\n\n            this.updateChapterMutation(changes, this.progressBar);\n        });\n\n        attributeObserver.observe(this.originalChapterBar, {\n            subtree: true,\n            attributes: true,\n            attributeFilter: [\"style\", \"class\"]\n        });\n\n        const childListObserver = new MutationObserver((mutations) => {\n            const changes: Record<string, HTMLElement> = {};\n            for (const mutation of mutations) {\n                if (mutation.type === \"childList\") {\n                    this.update();\n                    break;\n                }\n            }\n\n            this.updateChapterMutation(changes, this.progressBar);\n        });\n\n        // Only direct children, no subtree\n        childListObserver.observe(this.originalChapterBar, {\n            childList: true\n        });\n\n        addCleanupListener(() => {\n            attributeObserver.disconnect();\n            childListObserver.disconnect();\n        });\n    }\n\n    private updateChapterAllMutation(originalChapterBar: HTMLElement, progressBar: HTMLElement, firstUpdate = false): void {\n        const elements = originalChapterBar.querySelectorAll(\".ytp-progress-list > *\");\n        const changes: Record<string, HTMLElement> = {};\n        for (const element of elements) {\n            changes[element.classList[0]] = element as HTMLElement;\n        }\n\n        this.updateChapterMutation(changes, progressBar, firstUpdate);\n    }\n\n    private updateChapterMutation(changes: Record<string, HTMLElement>, progressBar: HTMLElement, firstUpdate = false): void {\n        // Go through each newly generated chapter bar and update the width based on changes array\n        if (this.customChaptersBar) {\n            // Width reached so far in decimal percent\n            let cursor = 0;\n\n            const sections = this.customChaptersBar.querySelectorAll(\".ytp-chapter-hover-container\") as NodeListOf<HTMLElement>;\n            for (let i = 0; i < sections.length; i++) {\n                const section = sections[i];\n\n                const sectionWidthDecimal = parseFloat(section.getAttribute(\"decimal-width\"));\n                const sectionWidthDecimalNoMargin = sectionWidthDecimal - this.chapterMargin / progressBar.clientWidth;\n\n                for (const className in changes) {\n                    const selector = `.${className}`\n                    const customChangedElement = section.querySelector(selector) as HTMLElement;\n                    if (customChangedElement) {\n                        const fullSectionWidth = i === sections.length - 1 ? sectionWidthDecimal : sectionWidthDecimalNoMargin;\n                        const changedElement = changes[className];\n                        const changedData = this.findLeftAndScale(selector, changedElement, progressBar);\n\n                        const left = (changedData.left) / progressBar.clientWidth;\n                        const calculatedLeft = Math.max(0, Math.min(1, (left - cursor) / fullSectionWidth));\n                        if (!isNaN(left) && !isNaN(calculatedLeft)) {\n                            customChangedElement.style.left = `${calculatedLeft * 100}%`;\n                            customChangedElement.style.removeProperty(\"display\");\n                        }\n\n                        if (changedData.scale !== null) {\n                            const transformScale = (changedData.scale) / progressBar.clientWidth;\n\n                            const scale = Math.max(0, Math.min(1 - calculatedLeft, (transformScale - cursor) / fullSectionWidth - calculatedLeft));\n                            customChangedElement.style.transform =\n                                `scaleX(${scale})`;\n                            if (customChangedElement.style.backgroundSize) {\n                                const backgroundSize = Math.max(changedData.scale / scale, fullSectionWidth * progressBar.clientWidth);\n                                customChangedElement.style.backgroundSize = `${backgroundSize}px`;\n\n                                if (changedData.scale < (cursor + fullSectionWidth) * progressBar.clientWidth) {\n                                    customChangedElement.style.backgroundPosition = `-${backgroundSize - fullSectionWidth * progressBar.clientWidth}px`;\n                                } else {\n                                    // Passed this section\n                                    customChangedElement.style.backgroundPosition = `-${cursor * progressBar.clientWidth}px`;\n                                }\n                            }\n\n                            if (firstUpdate) {\n                                customChangedElement.style.transition = \"none\";\n                                setTimeout(() => customChangedElement.style.removeProperty(\"transition\"), 50);\n                            }\n                        }\n\n                        if (customChangedElement.className !== changedElement.className) {\n                            customChangedElement.className = changedElement.className;\n                        }\n                    }\n                }\n\n                cursor += sectionWidthDecimal;\n            }\n\n            if (sections.length !== 0 && sections.length !== this.existingChapters?.length\n                    && Date.now() - this.lastChapterUpdate > 3000) {\n                this.lastChapterUpdate = Date.now();\n                this.updateExistingChapters();\n            }\n        }\n    }\n\n    private findLeftAndScale(selector: string, currentElement: HTMLElement, progressBar: HTMLElement):\n            { left: number; scale: number } {\n        const sections = currentElement.parentElement.parentElement.parentElement.children;\n        let currentWidth = 0;\n        let lastWidth = 0;\n\n        let left = 0;\n        let leftPosition = 0;\n\n        let scale = null;\n        let scalePosition = 0;\n        let scaleWidth = 0;\n        let lastScalePosition = 0;\n\n        for (let i = 0; i < sections.length; i++) {\n            const section = sections[i] as HTMLElement;\n            const checkElement = section.querySelector(selector) as HTMLElement;\n            const currentSectionWidthNoMargin = this.getPartialChapterSectionStyle(section, \"width\") ?? progressBar.clientWidth;\n            const currentSectionWidth = currentSectionWidthNoMargin\n                + this.getPartialChapterSectionStyle(section, \"marginRight\");\n\n            // First check for left\n            const checkLeft = parseFloat(checkElement.style.left.replace(\"px\", \"\"));\n            if (checkLeft !== 0) {\n                left = checkLeft;\n                leftPosition = currentWidth;\n            }\n\n            // Then check for scale\n            const transformMatch = checkElement.style.transform.match(/scaleX\\(([0-9.]+?)\\)/);\n            if (transformMatch) {\n                const transformScale = parseFloat(transformMatch[1]);\n                const endPosition = transformScale + checkLeft / currentSectionWidthNoMargin;\n\n                if (lastScalePosition > 0.99999 && endPosition === 0) {\n                    // Last one was an end section that was fully filled\n                    scalePosition = currentWidth - lastWidth;\n                    break;\n                }\n\n                lastScalePosition = endPosition;\n\n                scale = transformScale;\n                scaleWidth = currentSectionWidthNoMargin;\n\n                if ((i === sections.length - 1 || endPosition < 0.99999) && endPosition > 0) {\n                    // reached the end of this section for sure\n                    // if the scale is always zero, then it will go through all sections but still return 0\n\n                    scalePosition = currentWidth;\n                    if (checkLeft !== 0) {\n                        scalePosition += left;\n                    }\n                    break;\n                }\n            }\n\n            lastWidth = currentSectionWidth;\n            currentWidth += lastWidth;\n        }\n\n        return {\n            left: left + leftPosition,\n            scale: scale !== null ? scale * scaleWidth + scalePosition : null\n        };\n    }\n\n    private getPartialChapterSectionStyle(element: HTMLElement, param: string): number {\n        const data = element.style[param];\n        if (data?.includes(\"%\")) {\n            return this.customChaptersBar.clientWidth * (parseFloat(data.replace(\"%\", \"\")) / 100);\n        } else {\n            return parseInt(element.style[param].match(/\\d+/g)?.[0]) || 0;\n        }\n    }\n\n    updateChapterText(segments: SponsorTime[], submittingSegments: SponsorTime[], currentTime: number): SponsorTime[] {\n        if (!Config.config.showSegmentNameInChapterBar\n                || Config.config.disableSkipping\n                || ((!segments || segments.length <= 0) && submittingSegments?.length <= 0 \n                    && (getSkipProfileBool(\"showAutogeneratedChapters\") || !hasAutogeneratedChapters()))) {\n            const chaptersContainer = this.getChaptersContainer();\n            if (chaptersContainer) {\n                chaptersContainer.querySelector(\".sponsorChapterText\")?.remove();\n                const chapterTitle = chaptersContainer.querySelector(\".ytp-chapter-title-content\") as HTMLDivElement;\n    \n                chapterTitle.style.removeProperty(\"display\");\n                chaptersContainer.classList.remove(\"sponsorblock-chapter-visible\");\n            }\n\n            return [];\n        }\n\n        segments ??= [];\n        if (submittingSegments?.length > 0) segments = segments.concat(submittingSegments);\n        const activeSegments = segments.filter((segment) => {\n            return segment.hidden === SponsorHideType.Visible\n                && segment.segment[0] <= currentTime && segment.segment[1] > currentTime\n                && segment.category !== DEFAULT_CATEGORY\n                && getCategorySelection(segment).option !== CategorySkipOption.Disabled\n        });\n\n        this.setActiveSegments(activeSegments);\n        return activeSegments;\n    }\n\n    /**\n     * Adds the text to the chapters slot if not filled by default\n     */\n    private setActiveSegments(segments: SponsorTime[]): void {\n        const chaptersContainer = this.getChaptersContainer();\n\n        if (chaptersContainer) {\n            if (segments.length > 0) {\n                chaptersContainer.classList.add(\"sponsorblock-chapter-visible\");\n\n                const chosenSegment = segments.sort((a, b) => {\n                    if (a.actionType === ActionType.Chapter && b.actionType !== ActionType.Chapter) {\n                        return -1;\n                    } else if (a.actionType !== ActionType.Chapter && b.actionType === ActionType.Chapter) {\n                        return 1;\n                    } else if (a.actionType === ActionType.Chapter && b.actionType === ActionType.Chapter\n                                && a.source === SponsorSourceType.Server && b.source !== SponsorSourceType.Server) {\n                        return -0.5;\n                    } else if (a.actionType === ActionType.Chapter && b.actionType === ActionType.Chapter\n                                && a.source !== SponsorSourceType.Server && b.source === SponsorSourceType.Server) {\n                        return 0.5;\n                    } else {\n                        return (b.segment[0] - a.segment[0]) * 4;\n                    }\n                })[0];\n\n                const chapterButton = this.getChapterButton(chaptersContainer);\n                if (chapterButton) {\n                    chapterButton.classList.remove(\"ytp-chapter-container-disabled\");\n                    chapterButton.disabled = false;\n                }\n\n                const chapterTitle = chaptersContainer.querySelector(\".ytp-chapter-title-content:not(.sponsorChapterText)\") as HTMLDivElement;\n                chapterTitle.style.display = \"none\";\n\n                const chapterCustomText = (chapterTitle.parentElement.querySelector(\".sponsorChapterText\") || (() => {\n                    const elem = document.createElement(\"div\");\n                    chapterTitle.parentElement.insertBefore(elem, chapterTitle);\n                    elem.classList.add(\"sponsorChapterText\");\n                    elem.classList.add(\"ytp-chapter-title-content\");\n                    if (document.location.host === \"tv.youtube.com\") {\n                        elem.style.lineHeight = \"initial\";\n                    }\n                    return elem;\n                })()) as HTMLDivElement;\n                chapterCustomText.innerText = chosenSegment.description || shortCategoryName(chosenSegment.category);\n\n                if (chosenSegment.actionType !== ActionType.Chapter) {\n                    chapterTitle.classList.add(\"sponsorBlock-segment-title\");\n                } else {\n                    chapterTitle.classList.remove(\"sponsorBlock-segment-title\");\n                }\n\n                if (chosenSegment.source === SponsorSourceType.Server) {\n                    const chapterVoteContainer = this.chapterVote.getContainer();\n                    if (document.location.host === \"tv.youtube.com\") {\n                        if (!chaptersContainer.contains(chapterVoteContainer)) {\n                            const oldVoteContainers = document.querySelectorAll(\"#chapterVote\");\n                            if (oldVoteContainers.length > 0) {\n                                oldVoteContainers.forEach((oldVoteContainer) => oldVoteContainer.remove());\n                            }\n                            chaptersContainer.appendChild(chapterVoteContainer);\n                        }\n                    } else if (!chapterButton.contains(chapterVoteContainer)) {\n                        const oldVoteContainers = document.querySelectorAll(\"#chapterVote\");\n                        if (oldVoteContainers.length > 0) {\n                            oldVoteContainers.forEach((oldVoteContainer) => oldVoteContainer.remove());\n                        }\n\n                        chapterButton.insertBefore(chapterVoteContainer, this.getChapterChevron());\n                    }\n\n                    this.chapterVote.setVisibility(true);\n                    this.chapterVote.setSegment(chosenSegment);\n                } else {\n                    this.chapterVote.setVisibility(false);\n                }\n            } else if (!getSkipProfileBool(\"showAutogeneratedChapters\") && hasAutogeneratedChapters()) {\n                // Keep original hidden\n                chaptersContainer.querySelector(\".sponsorChapterText\")?.remove();\n                const chapterTitle = chaptersContainer.querySelector(\".ytp-chapter-title-content\") as HTMLDivElement;\n\n                chapterTitle.style.display = \"none\";\n                chaptersContainer.classList.remove(\"sponsorblock-chapter-visible\");\n\n                const titlePrefixDot = chaptersContainer.querySelector(\".ytp-chapter-title-prefix\") as HTMLElement;\n                if (titlePrefixDot) titlePrefixDot.style.display = \"none\";\n                \n                this.chapterVote.setVisibility(false);\n            } else {\n                chaptersContainer.querySelector(\".sponsorChapterText\")?.remove();\n                const chapterTitle = chaptersContainer.querySelector(\".ytp-chapter-title-content\") as HTMLDivElement;\n\n                chapterTitle.style.removeProperty(\"display\");\n                chaptersContainer.classList.remove(\"sponsorblock-chapter-visible\");\n                \n                this.chapterVote.setVisibility(false);\n            }\n        }\n    }\n\n    private getChaptersContainer(): HTMLElement {\n        if (document.location.host === \"tv.youtube.com\") {\n            if (!document.querySelector(\".ytp-chapter-container\")) {\n                const dest = document.querySelector(\".ypcs-control-buttons-left\");\n                if (!dest) return null;\n                const sbChapterContainer = document.createElement(\"div\");\n                sbChapterContainer.className = \"ytp-chapter-container\";\n                const sbChapterTitleContent = document.createElement(\"div\");\n                sbChapterTitleContent.className = \"ytp-chapter-title-content\";\n                sbChapterContainer.appendChild(sbChapterTitleContent);\n                dest.appendChild(sbChapterContainer);\n            }\n        }\n        return document.querySelector(\".ytp-chapter-container\") as HTMLElement;\n    }\n\n    private getChapterButton(chaptersContainer: HTMLElement): HTMLButtonElement {\n        return (chaptersContainer ?? this.getChaptersContainer())\n            ?.querySelector(\"button.ytp-chapter-title\") as HTMLButtonElement;\n    }\n\n    remove(): void {\n        this.container.remove();\n\n        if (this.categoryTooltip) {\n            this.categoryTooltip.remove();\n            this.categoryTooltip = undefined;\n        }\n\n        if (this.categoryTooltipContainer) {\n            this.categoryTooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS);\n            this.categoryTooltipContainer = undefined;\n        }\n    }\n\n    private chapterFilter(segment: PreviewBarSegment): boolean {\n        return (Config.config.renderSegmentsAsChapters || segment.actionType === ActionType.Chapter)\n                && segment.actionType !== ActionType.Poi\n                && this.chapterGroupFilter(segment);\n    }\n\n    private chapterGroupFilter(segment: SegmentContainer): boolean {\n        return segment.segment.length === 2 && this.intervalToDecimal(segment.segment[0], segment.segment[1]) > MIN_CHAPTER_SIZE;\n    }\n\n    intervalToPercentage(startTime: number, endTime: number) {\n        return `${this.intervalToDecimal(startTime, endTime) * 100}%`;\n    }\n\n    intervalToDecimal(startTime: number, endTime: number) {\n        return (this.timeToDecimal(endTime) - this.timeToDecimal(startTime));\n    }\n\n    timeToPercentage(time: number): string {\n        return `${this.timeToDecimal(time) * 100}%`\n    }\n\n    timeToRightPercentage(time: number): string {\n        return `${(1 - this.timeToDecimal(time)) * 100}%`\n    }\n\n    timeToDecimal(time: number): number {\n        return this.decimalTimeConverter(time, true);\n    }\n\n    decimalToTime(decimal: number): number {\n        return this.decimalTimeConverter(decimal, false);\n    }\n\n    /**\n     * Decimal to time or time to decimal\n     */\n    decimalTimeConverter(value: number, isTime: boolean): number {\n        if (this.originalChapterBarBlocks?.length > 1 && this.existingChapters.length === this.originalChapterBarBlocks?.length) {\n            // Parent element to still work when display: none\n            const totalPixels = this.originalChapterBar.parentElement.clientWidth;\n            let pixelOffset = 0;\n            let lastCheckedChapter = -1;\n            for (let i = 0; i < this.originalChapterBarBlocks.length; i++) {\n                const chapterElement = this.originalChapterBarBlocks[i];\n                const widthPixels = parseFloat(chapterElement.style.width.replace(\"px\", \"\"));\n\n                const marginPixels = chapterElement.style.marginRight ? parseFloat(chapterElement.style.marginRight.replace(\"px\", \"\")) : 0;\n                if ((isTime && value >= this.existingChapters[i].segment[1])\n                        || (!isTime && value >= (pixelOffset + widthPixels + marginPixels) / totalPixels)) {\n                    pixelOffset += widthPixels + marginPixels;\n                    lastCheckedChapter = i;\n                } else {\n                    break;\n                }\n            }\n\n            // The next chapter is the one we are currently inside of\n            const latestChapter = this.existingChapters[lastCheckedChapter + 1];\n            if (latestChapter) {\n                const latestWidth = parseFloat(this.originalChapterBarBlocks[lastCheckedChapter + 1].style.width.replace(\"px\", \"\"));\n                const latestChapterDuration = latestChapter.segment[1] - latestChapter.segment[0];\n\n                if (isTime) {\n                    const percentageInCurrentChapter = (value - latestChapter.segment[0]) / latestChapterDuration;\n                    const sizeOfCurrentChapter = latestWidth / totalPixels;\n                    return Math.min(1, ((pixelOffset / totalPixels) + (percentageInCurrentChapter * sizeOfCurrentChapter)));\n                } else {\n                    const percentageInCurrentChapter = (value * totalPixels - pixelOffset) / latestWidth;\n                    return Math.max(0, latestChapter.segment[0] + (percentageInCurrentChapter * latestChapterDuration));\n                }\n            }\n        }\n\n        if (isTime) {\n            return Math.min(1, value / this.videoDuration);\n        } else {\n            return Math.max(0, value * this.videoDuration);\n        }\n    }\n\n    /*\n    * Approximate size on preview bar for smallest element (due to &nbsp)\n    */\n    getMinimumSize(showLarger = false): number {\n        return this.videoDuration * (showLarger ? 0.006 : 0.003);\n    }\n\n    // Name parameter used for cache\n    private getSmallestSegment(timeInSeconds: number, segments: PreviewBarSegment[], name?: string): PreviewBarSegment | null {\n        const proposedIndex = name ? this.lastSmallestSegment[name]?.index : null;\n        const startSearchIndex = proposedIndex && segments[proposedIndex] === this.lastSmallestSegment[name].segment ? proposedIndex : 0;\n        const direction = startSearchIndex > 0 && timeInSeconds < this.lastSmallestSegment[name].segment.segment[0] ? -1 : 1;\n\n        let segment: PreviewBarSegment | null = null;\n        let index = -1;\n        let currentSegmentLength = Infinity;\n\n        for (let i = startSearchIndex; i < segments.length && i >= 0; i += direction) {\n            const seg = segments[i];\n            const segmentLength = seg.segment[1] - seg.segment[0];\n            const minSize = this.getMinimumSize(seg.showLarger);\n\n            const startTime = segmentLength !== 0 ? seg.segment[0] : Math.floor(seg.segment[0]);\n            const endTime = segmentLength > minSize ? seg.segment[1] : Math.ceil(seg.segment[0] + minSize);\n            if (startTime <= timeInSeconds && endTime >= timeInSeconds) {\n                if (segmentLength < currentSegmentLength) {\n                    currentSegmentLength = segmentLength;\n                    segment = seg;\n                    index = i;\n                }\n            }\n\n            if (direction === 1 && seg.segment[0] > timeInSeconds) {\n                break;\n            }\n        }\n\n        if (segment) {\n            this.lastSmallestSegment[name] = {\n                index: index,\n                segment: segment\n            };\n        }\n\n        return segment;\n    }\n\n    private getChapterChevron(): HTMLElement {\n        return document.querySelector(\".ytp-chapter-title-chevron\");\n    }\n}\n\nexport default PreviewBar;\n"
  },
  {
    "path": "src/js-components/skipButtonControlBar.ts",
    "content": "import Config from \"../config\";\nimport { SegmentUUID, SponsorTime } from \"../types\";\nimport { getSkippingText } from \"../utils/categoryUtils\";\nimport { AnimationUtils } from \"../../maze-utils/src/animationUtils\";\nimport { keybindToString } from \"../../maze-utils/src/config\";\nimport { isMobileControlsOpen } from \"../utils/mobileUtils\";\n\nexport interface SkipButtonControlBarProps {\n    skip: (segment: SponsorTime) => void;\n    selectSegment: (UUID: SegmentUUID) => void;\n    onMobileYouTube: boolean;\n    onYTTV: boolean;\n}\n\nexport class SkipButtonControlBar {\n\n    container: HTMLElement;\n    skipIcon: HTMLImageElement;\n    textContainer: HTMLElement;\n    chapterText: HTMLElement;\n    segment: SponsorTime;\n\n    showKeybindHint = true;\n    onMobileYouTube: boolean;\n    onYTTV: boolean;\n\n    enabled = false;\n\n    timeout: NodeJS.Timeout;\n    duration = 0;\n\n    skip: (segment: SponsorTime) => void;\n\n    // Used if on mobile page\n    hideButton: () => void;\n    showButton: () => void;\n\n    // Used by mobile only for swiping away\n    left = 0;\n    swipeStart = 0;\n\n    constructor(props: SkipButtonControlBarProps) {\n        this.skip = props.skip;\n        this.onMobileYouTube = props.onMobileYouTube;\n        this.onYTTV = props.onYTTV;\n\n        this.container = document.createElement(\"div\");\n        this.container.classList.add(\"skipButtonControlBarContainer\");\n        this.container.classList.add(\"sbhidden\");\n        if (this.onMobileYouTube) this.container.classList.add(\"mobile\");\n\n        this.skipIcon = document.createElement(\"img\");\n        this.skipIcon.src = chrome.runtime.getURL(\"icons/skipIcon.svg\");\n        this.skipIcon.classList.add(\"ytp-button\");\n        this.skipIcon.id = \"sbSkipIconControlBarImage\";\n        if (this.onYTTV) {\n            this.skipIcon.style.width = \"24px\";\n            this.skipIcon.style.height = \"24px\";\n        }\n\n        this.textContainer = document.createElement(\"div\");\n\n        this.container.appendChild(this.skipIcon);\n        this.container.appendChild(this.textContainer);\n        this.container.addEventListener(\"click\", () => this.toggleSkip());\n        this.container.addEventListener(\"mouseenter\", () => {\n            this.stopTimer();\n\n            if (this.segment) {\n                props.selectSegment(this.segment.UUID);\n            }\n        });\n        this.container.addEventListener(\"mouseleave\", () => {\n            this.startTimer();\n\n            props.selectSegment(null);\n        });\n        if (this.onMobileYouTube) {\n            this.container.addEventListener(\"touchstart\", (e) => this.handleTouchStart(e));\n            this.container.addEventListener(\"touchmove\", (e) => this.handleTouchMove(e));\n            this.container.addEventListener(\"touchend\", () => this.handleTouchEnd());\n        }\n    }\n\n    getElement(): HTMLElement {\n        return this.container;\n    }\n\n    attachToPage(): void {\n        const mountingContainer = this.getMountingContainer();\n        this.chapterText = document.querySelector(\".ytp-chapter-container\");\n\n        if (mountingContainer && !mountingContainer.contains(this.container)) {\n            if (this.onMobileYouTube || this.onYTTV) {\n                mountingContainer.appendChild(this.container);\n            } else {\n                mountingContainer.insertBefore(this.container, this.chapterText);\n            }\n\n            if (!this.onMobileYouTube) {\n                AnimationUtils.setupAutoHideAnimation(this.skipIcon, mountingContainer, false, false);\n            } else {\n                const { hide, show } = AnimationUtils.setupCustomHideAnimation(this.skipIcon, mountingContainer, false, false);\n                this.hideButton = hide;\n                this.showButton = show;\n            }\n        }\n    }\n\n    private getMountingContainer(): HTMLElement {\n        if (!this.onMobileYouTube && !this.onYTTV) {\n            return document.querySelector(\".ytp-left-controls\");\n        } else if (this.onYTTV) {\n            return document.querySelector(\".ypcs-control-buttons-left\");\n        } else {\n            return document.getElementById(\"player-container-id\");\n        }\n    }\n\n    enable(segment: SponsorTime, duration?: number): void {\n        if (duration) this.duration = duration;\n        this.segment = segment;\n        this.enabled = true;\n\n        this.refreshText();\n        this.container?.classList?.remove(\"textDisabled\");\n        this.textContainer?.classList?.remove(\"sbhidden\");\n        AnimationUtils.disableAutoHideAnimation(this.skipIcon);\n\n        this.startTimer();\n    }\n\n    refreshText(): void {\n        if (this.segment) {\n            this.chapterText?.classList?.add(\"sbhidden\");\n            this.container.classList.remove(\"sbhidden\");\n            this.textContainer.innerText = this.getTitle();\n            this.skipIcon.setAttribute(\"title\", this.getTitle());\n        }\n    }\n\n    setShowKeybindHint(show: boolean): void {\n        this.showKeybindHint = show;\n\n        this.refreshText();\n    }\n\n    stopTimer(): void {\n        if (this.timeout) clearTimeout(this.timeout);\n    }\n\n    startTimer(): void {\n        this.stopTimer();\n        this.timeout = setTimeout(() => this.disableText(), Math.max(Config.config.skipNoticeDuration, this.duration) * 1000);\n    }\n\n    disable(): void {\n        this.container.classList.add(\"sbhidden\");\n\n        this.chapterText?.classList?.remove(\"sbhidden\");\n        this.getChapterPrefix()?.classList?.remove(\"sbhidden\");\n\n        this.enabled = false;\n    }\n\n    isEnabled(): boolean {\n        return this.enabled;\n    }\n\n    toggleSkip(): void {\n        if (this.segment && this.enabled) {\n            this.skip(this.segment);\n            this.disableText();\n        }\n    }\n\n    disableText(): void {\n        if (Config.config.hideSkipButtonPlayerControls) {\n            this.disable();\n            return;\n        }\n\n        this.container.classList.add(\"textDisabled\");\n        this.textContainer?.classList?.add(\"sbhidden\");\n        this.chapterText?.classList?.remove(\"sbhidden\");\n\n        this.getChapterPrefix()?.classList?.add(\"sbhidden\");\n\n        AnimationUtils.enableAutoHideAnimation(this.skipIcon);\n        if (this.onMobileYouTube) {\n            this.hideButton();\n        }\n    }\n\n    updateMobileControls(): void {\n        if (this.enabled) {\n            if (isMobileControlsOpen()) {\n                this.showButton();\n            } else {\n                this.hideButton();\n            }\n        }\n    }\n\n    private getTitle(): string {\n        return getSkippingText([this.segment], false) + (this.showKeybindHint ? \" (\" + keybindToString(Config.config.skipToHighlightKeybind) + \")\" : \"\");\n    }\n\n    private getChapterPrefix(): HTMLElement {\n        return document.querySelector(\".ytp-chapter-title-prefix\");\n    }\n\n    // Swiping away on mobile\n    private handleTouchStart(event: TouchEvent): void {\n        this.swipeStart = event.touches[0].clientX;\n    }\n\n    // Swiping away on mobile\n    private handleTouchMove(event: TouchEvent): void {\n        const distanceMoved = this.swipeStart - event.touches[0].clientX;\n        this.left = Math.min(-distanceMoved, 0);\n\n        this.updateLeftStyle();\n    }\n\n    // Swiping away on mobile\n    private handleTouchEnd(): void {\n        if (this.left < -this.container.offsetWidth / 2) {\n            this.disableText();\n\n            // Don't let animation play\n            this.skipIcon.style.display = \"none\";\n            setTimeout(() => this.skipIcon.style.removeProperty(\"display\"), 200);\n        }\n\n        this.left = 0;\n        this.updateLeftStyle();\n    }\n\n    // Swiping away on mobile\n    private updateLeftStyle() {\n        this.container.style.left = this.left + \"px\";\n    }\n}\n"
  },
  {
    "path": "src/messageTypes.ts",
    "content": "//\n// Message and Response Types\n//\n\nimport { ConfigurationID } from \"./config\";\nimport { SegmentUUID, SponsorHideType, SponsorTime, VideoID } from \"./types\";\n\ninterface BaseMessage {\n    from?: string;\n}\n\ninterface DefaultMessage {\n    message:\n        \"update\"\n        | \"sponsorStart\"\n        | \"getChannelID\"\n        | \"submitTimes\"\n        | \"refreshSegments\"\n        | \"closePopup\"\n        | \"getLogs\";\n}\n\ninterface IsInfoFoundMessage {\n    message: \"isInfoFound\";\n    updating: boolean;\n}\n\ninterface SkipMessage {\n    message: \"unskip\" | \"reskip\" | \"selectSegment\";\n    UUID: SegmentUUID;\n}\n\ninterface SubmitVoteMessage {\n    message: \"submitVote\";\n    type: number;\n    UUID: SegmentUUID;\n}\n\ninterface HideSegmentMessage {\n    message: \"hideSegment\";\n    type: SponsorHideType;\n    UUID: SegmentUUID;\n}\n\ninterface CopyToClipboardMessage {\n    message: \"copyToClipboard\";\n    text: string;\n}\n\ninterface ImportSegmentsMessage {\n    message: \"importSegments\";\n    data: string;\n}\n\ninterface LoopChapterMessage {\n    message: \"loopChapter\";\n    UUID: SegmentUUID;\n}\n\ninterface KeyDownMessage {\n    message: \"keydown\";\n    key: string;\n    keyCode: number;\n    code: string;\n    which: number;\n    shiftKey: boolean;\n    ctrlKey: boolean;\n    altKey: boolean;\n    metaKey: boolean;\n}\n\ninterface SetCurrentTabSkipProfileResponse {\n    message: \"setCurrentTabSkipProfile\";\n    configID: ConfigurationID | null;\n}\n\nexport type Message = BaseMessage & (DefaultMessage | IsInfoFoundMessage | SkipMessage | SubmitVoteMessage | HideSegmentMessage | CopyToClipboardMessage | ImportSegmentsMessage | KeyDownMessage | LoopChapterMessage | SetCurrentTabSkipProfileResponse);\n\nexport interface IsInfoFoundMessageResponse {\n    found: boolean;\n    status: number | string | Error;\n    sponsorTimes: SponsorTime[];\n    time: number;\n    onMobileYouTube: boolean;\n    videoID: VideoID;\n    loopedChapter: SegmentUUID | null;\n    channelID: string;\n    channelAuthor: string;\n    currentTabSkipProfileID: ConfigurationID | null;\n}\n\ninterface GetVideoIdResponse {\n    videoID: string;\n}\n\nexport interface GetChannelIDResponse {\n    channelID: string;\n    isYTTV: boolean;\n}\n\nexport interface SponsorStartResponse {\n    creatingSegment: boolean;\n}\n\nexport interface IsChannelWhitelistedResponse {\n    value: boolean;\n}\n\nexport interface LoopedChapterResponse {\n    UUID: SegmentUUID;\n}\n\nexport type MessageResponse =\n    IsInfoFoundMessageResponse\n    | GetVideoIdResponse\n    | GetChannelIDResponse\n    | SponsorStartResponse\n    | IsChannelWhitelistedResponse\n    | Record<string, never> // empty object response {}\n    | VoteResponse\n    | ImportSegmentsResponse\n    | RefreshSegmentsResponse\n    | LogResponse\n    | LoopedChapterResponse;\n\nexport type VoteResponse = {\n    status: number;\n    ok: boolean;\n    responseText: string;\n} | {\n    error: Error | string;\n};\n\ninterface ImportSegmentsResponse {\n    importedSegments: SponsorTime[];\n}\n\nexport interface RefreshSegmentsResponse {\n    hasVideo: boolean;\n}\n\nexport interface LogResponse {\n    debug: string[];\n    warn: string[];\n}\n\nexport interface TimeUpdateMessage {\n    message: \"time\";\n    time: number;\n}\n\nexport type InfoUpdatedMessage = IsInfoFoundMessageResponse & {\n    message: \"infoUpdated\";\n}\n\nexport interface VideoChangedPopupMessage {\n    message: \"videoChanged\";\n    videoID: string;\n    channelID: string;\n    channelAuthor: string;\n}\n\nexport type PopupMessage = TimeUpdateMessage | InfoUpdatedMessage | VideoChangedPopupMessage;\n"
  },
  {
    "path": "src/options.ts",
    "content": "import * as React from \"react\";\nimport { createRoot } from 'react-dom/client';\n\nimport Config, { generateDebugDetails } from \"./config\";\nimport * as invidiousList from \"../ci/invidiouslist.json\";\n\n// Make the config public for debugging purposes\nwindow.SB = Config;\n\nimport Utils from \"./utils\";\nimport CategoryChooser from \"./render/CategoryChooser\";\nimport UnsubmittedVideos from \"./render/UnsubmittedVideos\";\nimport KeybindComponent from \"./components/options/KeybindComponent\";\nimport { showDonationLink } from \"./utils/configUtils\";\nimport { localizeHtmlPage } from \"../maze-utils/src/setup\";\nimport { StorageChangesObject } from \"../maze-utils/src/config\";\nimport { getHash } from \"../maze-utils/src/hash\";\nimport { isFirefoxOrSafari } from \"../maze-utils/src\";\nimport { isDeArrowInstalled } from \"./utils/crossExtension\";\nimport { asyncRequestToServer } from \"./utils/requests\";\nimport AdvancedSkipOptions from \"./render/AdvancedSkipOptions\";\nconst utils = new Utils();\nlet embed = false;\n\nconst categoryChoosers: CategoryChooser[] = [];\nconst unsubmittedVideos: UnsubmittedVideos[] = [];\n\nif (document.readyState === \"complete\") {\n    init();\n} else {\n    document.addEventListener(\"DOMContentLoaded\", init);\n}\n\nasync function init() {\n    localizeHtmlPage();\n\n    // selected tab\n    if (location.hash != \"\") {\n        const substr = location.hash.slice(1);\n        let menuItem = document.querySelector(`[data-for='${substr}']`);\n        if (menuItem == null)\n            menuItem = document.querySelector(`[data-for='behavior']`);\n        menuItem.classList.add(\"selected\");\n    } else {\n        document.querySelector(`[data-for='behavior']`).classList.add(\"selected\");\n    }\n\n    document.getElementById(\"version\").innerText = \"v. \" + chrome.runtime.getManifest().version;\n\n    // Remove header if needed\n    if (window.location.hash === \"#embed\") {\n        embed = true;\n        for (const element of document.getElementsByClassName(\"titleBar\")) {\n            element.classList.add(\"hidden\");\n        }\n\n        document.getElementById(\"options\").classList.add(\"embed\");\n        createStickyHeader();\n    }\n\n    if (!Config.configSyncListeners.includes(optionsConfigUpdateListener)) {\n        Config.configSyncListeners.push(optionsConfigUpdateListener);\n    }\n\n    if (!Config.configLocalListeners.includes(optionsLocalConfigUpdateListener)) {\n        Config.configLocalListeners.push(optionsLocalConfigUpdateListener);\n    }\n\n    await utils.wait(() => Config.config !== null);\n\n    if (!Config.config.darkMode) {\n        document.documentElement.setAttribute(\"data-theme\", \"light\");\n    }\n\n    if (Config.config.prideTheme) {\n        document.documentElement.setAttribute(\"data-theme\", \"pride\");\n\n        (document.getElementById(\"title-bar-logo\") as HTMLImageElement).src = \"../icons/sb-pride.png\";\n    }\n\n    const donate = document.getElementById(\"sbDonate\");\n    donate.addEventListener(\"click\", () => Config.config.donateClicked = Config.config.donateClicked + 1);\n    if (!showDonationLink()) {\n        donate.classList.add(\"hidden\");\n    }\n\n    // DeArrow promotion\n    if (Config.config.showNewFeaturePopups && Config.config.showUpsells && Config.config.showDeArrowInSettings) {\n        isDeArrowInstalled().then((installed) => {\n            if (!installed) {\n                const deArrowPromotion = document.getElementById(\"deArrowPromotion\");\n                deArrowPromotion.classList.remove(\"hidden\");\n\n                deArrowPromotion.addEventListener(\"click\", () => Config.config.showDeArrowPromotion = false);\n\n                const closeButton = deArrowPromotion.querySelector(\".close-button\");\n                closeButton.addEventListener(\"click\", (e) => {\n                    e.preventDefault();\n                    \n                    deArrowPromotion.classList.add(\"hidden\");\n                    Config.config.showDeArrowPromotion = false;\n                    Config.config.showDeArrowInSettings = false;\n                });\n            }\n        });\n    }\n\n    const skipToHighlightKeybind = document.querySelector(`[data-sync=\"skipToHighlightKeybind\"] .optionLabel`) as HTMLElement;\n    skipToHighlightKeybind.innerText = `${chrome.i18n.getMessage(\"skip_to_category\").replace(\"{0}\", chrome.i18n.getMessage(\"category_poi_highlight\")).replace(\"?\", \"\")}:`;\n\n    // Set all of the toggle options to the correct option\n    const optionsContainer = document.getElementById(\"options\");\n    const optionsElements = optionsContainer.querySelectorAll(\"*\");\n\n    for (let i = 0; i < optionsElements.length; i++) {\n        const dependentOnName = optionsElements[i].getAttribute(\"data-dependent-on\");\n        const dependentOn = optionsContainer.querySelector(`[data-sync='${dependentOnName}']`);\n        let isDependentOnReversed = false;\n        if (dependentOn)\n            isDependentOnReversed = dependentOn.getAttribute(\"data-toggle-type\") === \"reverse\" || optionsElements[i].getAttribute(\"data-dependent-on-inverted\") === \"true\";\n\n        if (await shouldHideOption(optionsElements[i]) || (dependentOn && (isDependentOnReversed ? Config.config[dependentOnName] : !Config.config[dependentOnName]))) {\n            optionsElements[i].classList.add(\"hidden\", \"hiding\");\n            if (!dependentOn) {\n                if (optionsElements[i].getAttribute(\"data-no-safari\") === \"true\" && optionsElements[i].id === \"support-invidious\") {\n                    // Put message about being disabled on safari\n                    const infoBox = document.createElement(\"div\");\n                    infoBox.innerText = chrome.i18n.getMessage(\"invidiousDisabledSafari\");\n                    \n                    const link = document.createElement(\"a\");\n                    link.style.display = \"block\";\n                    const url = \"https://bugs.webkit.org/show_bug.cgi?id=290508\";\n                    link.href = url;\n                    link.innerText = url;\n\n                    infoBox.appendChild(link);\n\n                    optionsElements[i].parentElement.insertBefore(infoBox, optionsElements[i].nextSibling);\n                }\n\n                continue;\n            }\n        }\n\n        const option = optionsElements[i].getAttribute(\"data-sync\");\n\n        switch (optionsElements[i].getAttribute(\"data-type\")) {\n            case \"toggle\": {\n                const optionResult = Config.config[option];\n\n                const checkbox = optionsElements[i].querySelector(\"input\");\n                const reverse = optionsElements[i].getAttribute(\"data-toggle-type\") === \"reverse\";\n\n                const confirmMessage = optionsElements[i].getAttribute(\"data-confirm-message\");\n                const confirmOnTrue = optionsElements[i].getAttribute(\"data-confirm-on\") !== \"false\";\n\n                if (optionResult != undefined)\n                    checkbox.checked =  reverse ? !optionResult : optionResult;\n\n                // See if anything extra should be run first time\n                switch (option) {\n                    case \"supportInvidious\":\n                        invidiousInit(checkbox, option);\n                        break;\n                }\n\n                // Add click listener\n                checkbox.addEventListener(\"click\", async () => {\n                    // Confirm if required\n                    if (confirmMessage && ((confirmOnTrue && checkbox.checked) || (!confirmOnTrue && !checkbox.checked))\n                            && !confirm(chrome.i18n.getMessage(confirmMessage))){\n                        checkbox.checked = !checkbox.checked;\n                        return;\n                    }\n\n                    Config.config[option] = reverse ? !checkbox.checked : checkbox.checked;\n\n                    // See if anything extra must be run\n                    switch (option) {\n                        case \"supportInvidious\":\n                            invidiousOnClick(checkbox, option);\n                            break;\n                        case \"disableAutoSkip\":\n                            if (!checkbox.checked) {\n                                // Enable the notice\n                                Config.config[\"dontShowNotice\"] = false;\n\n                                const showNoticeSwitch = <HTMLInputElement> document.querySelector(\"[data-sync='dontShowNotice'] > div > label > input\");\n                                showNoticeSwitch.checked = true;\n                            }\n                            break;\n                        case \"showDonationLink\":\n                            if (checkbox.checked)\n                                document.getElementById(\"sbDonate\").classList.add(\"hidden\");\n                            else\n                                document.getElementById(\"sbDonate\").classList.remove(\"hidden\");\n                            break;\n                        case \"darkMode\":\n                            if (checkbox.checked) {\n                                document.documentElement.setAttribute(\"data-theme\", \"dark\");\n                            } else {\n                                document.documentElement.setAttribute(\"data-theme\", \"light\");\n                            }\n                            break;\n                        case \"prideTheme\":\n                            if (checkbox.checked) {\n                                document.documentElement.setAttribute(\"data-theme\", \"pride\");\n                            } else {\n                                if (Config.config.darkMode) {\n                                    document.documentElement.setAttribute(\"data-theme\", \"dark\");\n                                } else {\n                                    document.documentElement.setAttribute(\"data-theme\", \"light\");\n                                }\n                            }\n                            break;\n                        case \"trackDownvotes\":\n                            if (!checkbox.checked) {\n                                Config.local.downvotedSegments = {};\n                            }\n                            break;\n                    }\n\n                    // If other options depend on this, hide/show them\n                    const dependents = optionsContainer.querySelectorAll(`[data-dependent-on='${option}']`);\n                    for (let j = 0; j < dependents.length; j++) {\n                        const disableWhenChecked = dependents[j].getAttribute(\"data-dependent-on-inverted\") === \"true\";\n                        if (!await shouldHideOption(dependents[j]) && (!disableWhenChecked && checkbox.checked || disableWhenChecked && !checkbox.checked)) {\n                            dependents[j].classList.remove(\"hidden\");\n                            setTimeout(() => dependents[j].classList.remove(\"hiding\"), 1);\n                        } else {\n                            dependents[j].classList.add(\"hiding\");\n                            setTimeout(() => dependents[j].classList.add(\"hidden\"), 400);\n                        }\n                    }\n                });\n                break;\n            }\n            case \"text-change\": {\n                const textChangeInput = <HTMLInputElement> optionsElements[i].querySelector(\".option-text-box\");\n\n                const textChangeSetButton = <HTMLElement> optionsElements[i].querySelector(\".text-change-set\");\n\n                textChangeInput.value = Config.config[option];\n\n                textChangeSetButton.addEventListener(\"click\", async () => {\n                    // See if anything extra must be done\n                    switch (option) {\n                        case \"serverAddress\": {\n                            const result = validateServerAddress(textChangeInput.value);\n\n                            if (result !== null) {\n                                textChangeInput.value = result;\n                            } else {\n                                return;\n                            }\n\n                            // Permission needed on Firefox\n                            if (isFirefoxOrSafari()) {\n                                const permissionSuccess = await new Promise((resolve) => {\n                                    chrome.permissions.request({\n                                        origins: [textChangeInput.value + \"/\"],\n                                        permissions: []\n                                    }, resolve);\n                                });\n\n                                if (!permissionSuccess) return;\n                            }\n\n                            break;\n                        }\n                    }\n\n                    Config.config[option] = textChangeInput.value;\n                });\n\n                // Reset to the default if needed\n                const textChangeResetButton = <HTMLElement> optionsElements[i].querySelector(\".text-change-reset\");\n                textChangeResetButton.addEventListener(\"click\", () => {\n                    if (!confirm(chrome.i18n.getMessage(\"areYouSureReset\"))) return;\n\n                    Config.config[option] = Config.syncDefaults[option];\n\n                    textChangeInput.value = Config.config[option];\n                });\n\n                break;\n            }\n            case \"private-text-change\": {\n                const button = optionsElements[i].querySelector(\".trigger-button\");\n                button.addEventListener(\"click\", () => activatePrivateTextChange(<HTMLElement> optionsElements[i]));\n\n                if (option == \"*\")  {\n                    const downloadButton = optionsElements[i].querySelector(\".download-button\");\n                    downloadButton.addEventListener(\"click\", () => downloadConfig(optionsElements[i]));\n\n                    const uploadButton = optionsElements[i].querySelector(\".upload-button\");\n                    uploadButton.addEventListener(\"change\", (e) => uploadConfig(e, optionsElements[i] as HTMLElement));\n                }\n\n                const privateTextChangeOption = optionsElements[i].getAttribute(\"data-sync\");\n                // See if anything extra must be done\n                switch (privateTextChangeOption) {\n                    case \"invidiousInstances\":\n                        invidiousInstanceAddInit(<HTMLElement> optionsElements[i], privateTextChangeOption);\n                }\n\n                break;\n            }\n            case \"button-press\": {\n                const actionButton = optionsElements[i].querySelector(\".trigger-button\");\n                const confirmMessage = optionsElements[i].getAttribute(\"data-confirm-message\");\n\n                actionButton.addEventListener(\"click\", () => {\n                    if (confirmMessage !== null && !confirm(chrome.i18n.getMessage(confirmMessage))) {\n                        return;\n                    }\n                    switch (optionsElements[i].getAttribute(\"data-sync\")) {\n                        case \"copyDebugInformation\":\n                            copyDebugOutputToClipboard();\n                            break;\n                        case \"resetToDefault\":\n                            Config.resetToDefault();\n                            setTimeout(() => window.location.reload(), 200);\n                            break;\n                    }\n                });\n\n                break;\n            }\n            case \"keybind-change\": {\n                const root = createRoot(optionsElements[i].querySelector(\"div\"));\n                root.render(React.createElement(KeybindComponent, {option: option}));\n                break;\n            }\n            case \"display\": {\n                updateDisplayElement(<HTMLElement> optionsElements[i])\n                break;\n            }\n            case \"number-change\": {\n                const configValue = Config.config[option];\n                const numberInput = optionsElements[i].querySelector(\"input\");\n\n                if (isNaN(configValue) || configValue < 0) {\n                    numberInput.value = Config.syncDefaults[option];\n                } else {\n                    numberInput.value = configValue;\n                }\n\n                numberInput.addEventListener(\"input\", () => {\n                    Config.config[option] = numberInput.value;\n                });\n\n                break;\n            }\n            case \"selector\": {\n                const configValue = Config.config[option];\n                const selectorElement = optionsElements[i].querySelector(\".selector-element\") as HTMLSelectElement;\n                selectorElement.value = configValue;\n\n                selectorElement.addEventListener(\"change\", () => {\n                    let value: string | number = selectorElement.value;\n                    if (!isNaN(Number(value))) value = Number(value);\n\n                    Config.config[option] = value;\n                });\n                break;\n            }\n            case \"react-CategoryChooserComponent\":\n                categoryChoosers.push(new CategoryChooser(optionsElements[i]));\n                break;\n            case \"react-AdvancedSkipOptionsComponent\":\n                new AdvancedSkipOptions(optionsElements[i]);\n                break;\n            case \"react-UnsubmittedVideosComponent\":\n                unsubmittedVideos.push(new UnsubmittedVideos(optionsElements[i]));\n                break;\n        }\n    }\n\n    // Tab interaction\n    const tabElements = document.getElementsByClassName(\"tab-heading\");\n    for (let i = 0; i < tabElements.length; i++) {\n        const tabFor = tabElements[i].getAttribute(\"data-for\");\n\n        if (tabElements[i].classList.contains(\"selected\"))\n            document.getElementById(tabFor).classList.remove(\"hidden\");\n\n        tabElements[i].addEventListener(\"click\", () => {\n            if (!embed) location.hash = tabFor;\n\n            createStickyHeader();\n\n            document.querySelectorAll(\".tab-heading\").forEach(element => { element.classList.remove(\"selected\"); });\n            optionsContainer.querySelectorAll(\".option-group\").forEach(element => { element.classList.add(\"hidden\"); });\n\n            tabElements[i].classList.add(\"selected\");\n            document.getElementById(tabFor).classList.remove(\"hidden\");\n        });\n    }\n\n    window.addEventListener(\"scroll\", () => createStickyHeader());\n\n    optionsContainer.classList.add(\"animated\");\n}\n\nfunction createStickyHeader() {\n    const container = document.getElementById(\"options-container\");\n    const options = document.getElementById(\"options\");\n\n    if (!embed && window.pageYOffset > 90 && (window.innerHeight <= 770 || window.innerWidth <= 1200)) {\n        if (!container.classList.contains(\"sticky\")) {\n            options.style.marginTop = options.offsetTop.toString()+\"px\";\n            container.classList.add(\"sticky\");\n        }\n    } else {\n        options.style.marginTop = \"unset\";\n        container.classList.remove(\"sticky\");\n    }\n}\n\n/**\n * Handle special cases where an option shouldn't show\n *\n * @param {String} element\n */\nasync function shouldHideOption(element: Element): Promise<boolean> {\n    return (element.getAttribute(\"data-private-only\") === \"true\" && !(await isIncognitoAllowed()))\n            || (element.getAttribute(\"data-no-safari\") === \"true\" && navigator.vendor === \"Apple Computer, Inc.\");\n}\n\n/**\n * Called when the config is updated\n */\nfunction optionsConfigUpdateListener() {\n    const optionsContainer = document.getElementById(\"options\");\n    const optionsElements = optionsContainer.querySelectorAll(\"*\");\n\n    for (let i = 0; i < optionsElements.length; i++) {\n        switch (optionsElements[i].getAttribute(\"data-type\")) {\n            case \"display\":\n                updateDisplayElement(<HTMLElement> optionsElements[i])\n                break;\n        }\n    }\n}\n\nfunction optionsLocalConfigUpdateListener(changes: StorageChangesObject) {\n    if (changes.unsubmittedSegments) {\n        for (const chooser of unsubmittedVideos) {\n            chooser.update();\n        }\n    }\n}\n\n/**\n * Will set display elements to the proper text\n *\n * @param element\n */\nfunction updateDisplayElement(element: HTMLElement) {\n    const displayOption = element.getAttribute(\"data-sync\")\n    const displayText = Config.config[displayOption];\n    element.innerText = displayText;\n\n    // See if anything extra must be run\n    switch (displayOption) {\n        case \"invidiousInstances\": {\n            element.innerText = displayText.join(', ');\n            let allEquals = displayText.length == invidiousList.length;\n            for (let i = 0; i < invidiousList.length && allEquals; i++) {\n                if (displayText[i] != invidiousList[i])\n                    allEquals = false;\n            }\n            if (!allEquals) {\n                const resetButton = element.parentElement.querySelector(\".invidious-instance-reset\");\n                resetButton.classList.remove(\"hidden\");\n            }\n            break;\n        }\n    }\n}\n\n/**\n * Initializes the option to add Invidious instances\n *\n * @param element\n * @param option\n */\nfunction invidiousInstanceAddInit(element: HTMLElement, option: string) {\n    const textBox = <HTMLInputElement> element.querySelector(\".option-text-box\");\n    const button = element.querySelector(\".trigger-button\");\n\n    const setButton = element.querySelector(\".text-change-set\");\n    const cancelButton = element.querySelector(\".text-change-reset\");\n    const resetButton = element.querySelector(\".invidious-instance-reset\");\n    setButton.addEventListener(\"click\", async function() {\n        if (textBox.value == \"\" || textBox.value.includes(\"/\") || textBox.value.includes(\"http\")) {\n            alert(chrome.i18n.getMessage(\"addInvidiousInstanceError\"));\n        } else {\n            // Add this\n            let instanceList = Config.config[option];\n            if (!instanceList) instanceList = [];\n\n            let domain = textBox.value.trim().toLowerCase();\n            if (domain.includes(\":\")) {\n                domain = domain.split(\":\")[0];\n            }\n\n            instanceList.push(domain);\n\n            Config.config[option] = instanceList;\n\n            const checkbox = <HTMLInputElement> document.querySelector(\"#support-invidious input\");\n            checkbox.checked = true;\n\n            invidiousOnClick(checkbox, \"supportInvidious\");\n\n            resetButton.classList.remove(\"hidden\");\n\n            // Hide this section again\n            textBox.value = \"\";\n            element.querySelector(\".option-hidden-section\").classList.add(\"hidden\");\n            button.classList.remove(\"disabled\");\n        }\n    });\n\n    cancelButton.addEventListener(\"click\", async function() {\n        textBox.value = \"\";\n        element.querySelector(\".option-hidden-section\").classList.add(\"hidden\");\n        button.classList.remove(\"disabled\");\n    });\n\n    resetButton.addEventListener(\"click\", function() {\n        if (confirm(chrome.i18n.getMessage(\"resetInvidiousInstanceAlert\"))) {\n            // Set to CI populated list\n            Config.config[option] = invidiousList;\n            resetButton.classList.add(\"hidden\");\n        }\n    });\n}\n\n/**\n * Run when the invidious button is being initialized\n *\n * @param checkbox\n * @param option\n */\nfunction invidiousInit(checkbox: HTMLInputElement, option: string) {\n    utils.containsInvidiousPermission().then((result) => {\n        if (result != checkbox.checked) {\n            Config.config[option] = result;\n\n            checkbox.checked = result;\n        }\n    });\n}\n\n/**\n * Run whenever the invidious checkbox is clicked\n *\n * @param checkbox\n * @param option\n */\nasync function invidiousOnClick(checkbox: HTMLInputElement, option: string): Promise<void> {\n    const enabled = await utils.applyInvidiousPermissions(checkbox.checked, option);\n    checkbox.checked = enabled;\n}\n\n/**\n * Will trigger the textbox to appear to be able to change an option's text.\n *\n * @param element\n */\nfunction activatePrivateTextChange(element: HTMLElement) {\n    const button = element.querySelector(\".trigger-button\");\n    if (button.classList.contains(\"disabled\")) return;\n\n    button.classList.add(\"disabled\");\n\n    const textBox = <HTMLInputElement> element.querySelector(\".option-text-box\");\n    const option = element.getAttribute(\"data-sync\");\n    const optionType = element.getAttribute(\"data-sync-type\");\n\n    // See if anything extra must be done\n    switch (option) {\n        case \"invidiousInstances\":\n            element.querySelector(\".option-hidden-section\").classList.remove(\"hidden\");\n            return;\n    }\n\n    let result = Config.config[option];\n    // See if anything extra must be done\n    switch (option) {\n        case \"*\": {\n            if (optionType === \"local\") {\n                result = JSON.stringify(Config.cachedLocalStorage);\n            } else {\n                result = JSON.stringify(Config.cachedSyncConfig);\n            }\n            break;\n        }\n    }\n\n    textBox.value = result;\n\n    const setButton = element.querySelector(\".text-change-set\");\n    setButton.addEventListener(\"click\", async () => {\n        setTextOption(option, element, textBox.value);\n    });\n\n    // See if anything extra must be done\n    switch (option) {\n        case \"userID\":\n            if (Config.config[option]) {\n                asyncRequestToServer(\"GET\", \"/api/userInfo\", {\n                    publicUserID: getHash(Config.config[option]),\n                    values: [\"warnings\", \"banned\"]\n                }).then((result) => {\n                    const userInfo = JSON.parse(result.responseText);\n                    if (userInfo.warnings > 0 || userInfo.banned) {\n                        setButton.classList.add(\"hidden\");\n                    }\n                }).catch(e => {\n                    console.error(\"[SB] Caught error while fetching user info for the new user ID\", e)\n                });\n            }\n\n            break;\n    }\n\n    element.querySelector(\".option-hidden-section\").classList.remove(\"hidden\");\n}\n\n/**\n * Function to run when a textbox change is submitted\n *\n * @param option data-sync value\n * @param element main container div\n * @param value new text\n * @param callbackOnError function to run if confirmMessage was denied\n */\nasync function setTextOption(option: string, element: HTMLElement, value: string, callbackOnError?: () => void) {\n    const confirmMessage = element.getAttribute(\"data-confirm-message\");\n    const optionType = element.getAttribute(\"data-sync-type\");\n\n    if (confirmMessage === null || confirm(chrome.i18n.getMessage(confirmMessage))) {\n\n        // See if anything extra must be done\n        switch (option) {\n            case \"*\":\n                try {\n                    const newConfig = JSON.parse(value);\n                    for (const key in newConfig) {\n                        if (optionType === \"local\") {\n                            Config.local[key] = newConfig[key];\n                        } else {\n                            Config.config[key] = newConfig[key];\n                        }\n                    }\n\n                    if (optionType !== \"local\" && newConfig.supportInvidious) {\n                        const checkbox = <HTMLInputElement> document.querySelector(\"#support-invidious > div > label > input\");\n\n                        checkbox.checked = true;\n                        await invidiousOnClick(checkbox, \"supportInvidious\");\n                    }\n\n                    setTimeout(() => window.location.reload(), 200);\n                } catch (e) {\n                    alert(chrome.i18n.getMessage(\"incorrectlyFormattedOptions\"));\n                }\n\n                break;\n            default:\n                Config.config[option] = value;\n        }\n    } else {\n        if (typeof callbackOnError == \"function\")\n            callbackOnError();\n    }\n}\n\nfunction downloadConfig(element: Element) {\n    const optionType = element.getAttribute(\"data-sync-type\");\n\n    const file = document.createElement(\"a\");\n    const jsonData = JSON.parse(JSON.stringify(optionType === \"local\" ? Config.cachedLocalStorage : Config.cachedSyncConfig));\n    const dateTimeString = new Date().toJSON().replace(\"T\", \"_\").replace(/:/g, \".\").replace(/.\\d+Z/g, \"\")\n    file.setAttribute(\"href\", `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(jsonData))}`);\n    file.setAttribute(\"download\", `SponsorBlock${optionType === \"local\" ? \"OtherData\" : \"Config\"}_${dateTimeString}.json`);\n    document.body.append(file);\n    file.click();\n    file.remove();\n}\n\nfunction uploadConfig(e: Event, element: HTMLElement) {\n    const target = e.target as HTMLInputElement;\n    if (target.files.length == 1) {\n        const file = target.files[0];\n        const reader = new FileReader();\n        reader.onload = function(ev) {\n            setTextOption(\"*\", element, ev.target.result as string, () => {\n                target.value = null;\n            });\n        };\n        reader.readAsText(file);\n    }\n}\n\n/**\n * Validates the value used for the database server address.\n * Returns null and alerts the user if there is an issue.\n *\n * @param input Input server address\n */\nfunction validateServerAddress(input: string): string {\n    input = input.trim();\n\n    // Trim the trailing slashes\n    input = input.replace(/\\/+$/, \"\");\n\n    // If it isn't HTTP protocol\n    if ((!input.startsWith(\"https://\") && !input.startsWith(\"http://\"))) {\n\n        alert(chrome.i18n.getMessage(\"customAddressError\"));\n\n        return null;\n    }\n\n    return input;\n}\n\nfunction copyDebugOutputToClipboard() {\n    // Copy object to clipboard\n    navigator.clipboard.writeText(generateDebugDetails())\n    .then(() => {\n        alert(chrome.i18n.getMessage(\"copyDebugInformationComplete\"));\n    })\n    .catch(() => {\n        alert(chrome.i18n.getMessage(\"copyDebugInformationFailed\"));\n    });\n}\n\nfunction isIncognitoAllowed(): Promise<boolean> {\n    return new Promise((resolve) => chrome.extension.isAllowedIncognitoAccess(resolve));\n}\n"
  },
  {
    "path": "src/permissions.ts",
    "content": "import Config from \"./config\";\nimport Utils from \"./utils\";\nimport { localizeHtmlPage } from \"../maze-utils/src/setup\";\nconst utils = new Utils();\n\n// This is needed, if Config is not imported before Utils, things break.\n// Probably due to cyclic dependencies\nConfig.config;\n\nif (document.readyState === \"complete\") {\n    init();\n} else {\n    document.addEventListener(\"DOMContentLoaded\", init);\n}\n\nasync function init() {\n    localizeHtmlPage();\n\n    const acceptButton = document.getElementById(\"acceptPermissionButton\");\n    acceptButton.addEventListener(\"click\", () => {\n        utils.applyInvidiousPermissions(Config.config.supportInvidious).then((enabled) => {\n            Config.config.supportInvidious = enabled;\n\n            if (enabled) {\n                alert(chrome.i18n.getMessage(\"permissionRequestSuccess\"));\n                window.close();\n            } else {\n                alert(chrome.i18n.getMessage(\"permissionRequestFailed\"));\n            }\n        })\n    });\n}"
  },
  {
    "path": "src/popup/PopupComponent.tsx",
    "content": "import * as React from \"react\";\nimport { YourWorkComponent } from \"./YourWorkComponent\";\nimport { isSafari } from \"../../maze-utils/src/config\";\nimport { showDonationLink } from \"../utils/configUtils\";\nimport Config, { ConfigurationID, generateDebugDetails } from \"../config\";\nimport { IsInfoFoundMessageResponse, LogResponse, Message, MessageResponse, PopupMessage } from \"../messageTypes\";\nimport { AnimationUtils } from \"../../maze-utils/src/animationUtils\";\nimport { SegmentListComponent } from \"./SegmentListComponent\";\nimport { ActionType, SegmentUUID, SponsorSourceType, SponsorTime } from \"../types\";\nimport { SegmentSubmissionComponent } from \"./SegmentSubmissionComponent\";\nimport { copyToClipboardPopup } from \"./popupUtils\";\nimport { getSkipProfileID, getSkipProfileIDForChannel, getSkipProfileIDForTab, getSkipProfileIDForTime, getSkipProfileIDForVideo, setCurrentTabSkipProfile } from \"../utils/skipProfiles\";\nimport { SelectOptionComponent } from \"../components/options/SelectOptionComponent\";\nimport * as Video from \"../../maze-utils/src/video\";\n\nexport enum LoadingStatus {\n    Loading,\n    SegmentsFound,\n    NoSegmentsFound,\n    ConnectionError,\n    JSError,\n    StillLoading,\n    NoVideo\n}\n\nexport interface LoadingData {\n    status: LoadingStatus;\n    code?: number;\n    error?: Error | string;\n}\n\ntype SkipProfileAction = \"forJustThisVideo\" | \"forThisChannel\" | \"forThisTab\" | \"forAnHour\" | null;\ninterface SkipProfileOption {\n    name: SkipProfileAction;\n    active: () => boolean;\n}\n\ninterface SegmentsLoadedProps {\n    setStatus: (status: LoadingData) => void;\n    setVideoID: (videoID: string | null) => void;\n    setCurrentTime: (time: number) => void;\n    setSegments: (segments: SponsorTime[]) => void;\n    setLoopedChapter: (loopedChapter: SegmentUUID | null) => void;\n}\n\ninterface LoadSegmentsProps extends SegmentsLoadedProps {\n    updating: boolean;\n}\n\ninterface SkipProfileRadioButtonsProps {\n    selected: SkipProfileAction;\n    setSelected: (s: SkipProfileAction, updateConfig: boolean) => void;\n    disabled: boolean;\n    configID: ConfigurationID | null;\n    videoID: string;\n}\n\ninterface SkipOptionActionComponentProps {\n    selected: boolean;\n    setSelected: (s: boolean) => void;\n    highlighted: boolean;\n    disabled: boolean;\n    overridden: boolean;\n    label: string;\n}\n\nlet loadRetryCount = 0;\n\nexport const PopupComponent = () => {\n    const [status, setStatus] = React.useState<LoadingData>({\n        status: LoadingStatus.Loading\n    });\n    const [extensionEnabled, setExtensionEnabled] = React.useState(!Config.config!.disableSkipping);\n    const [showForceChannelCheckWarning, setShowForceChannelCheckWarning] = React.useState(false);\n    const [showNoticeButton, setShowNoticeButton] = React.useState(Config.config!.dontShowNotice);\n\n    const [currentTime, setCurrentTime] = React.useState<number>(0);\n    const [segments, setSegments] = React.useState<SponsorTime[]>([]);\n    const [loopedChapter, setLoopedChapter] = React.useState<SegmentUUID | null>(null);\n\n    const [videoID, setVideoID] = React.useState<string | null>(null);\n\n    React.useEffect(() => {\n        loadSegments({\n            updating: false,\n            setStatus,\n            setVideoID,\n            setCurrentTime,\n            setSegments,\n            setLoopedChapter\n        });\n\n        setupComPort({\n            setStatus,\n            setVideoID,\n            setCurrentTime,\n            setSegments,\n            setLoopedChapter\n        });\n\n        forwardClickEvents(sendMessage);\n    }, []);\n\n    return (\n        <div id=\"sponsorblockPopup\" className={Config.config.prideTheme ? \"prideTheme\" : \"\"}>\n            {\n                window !== window.top &&\n                <button id=\"sbCloseButton\" title=\"__MSG_closePopup__\" className=\"sbCloseButton\" onClick={() => {\n                    sendMessage({ message: \"closePopup\" });\n                }}>\n                    <img src=\"icons/close.png\" width=\"15\" height=\"15\" alt=\"Close icon\"/>\n                </button>\n            }\n\n            {\n                Config.config!.testingServer &&\n                <div id=\"sbBetaServerWarning\"\n                        title={chrome.i18n.getMessage(\"openOptionsPage\")}\n                        onClick={() => {\n                            chrome.runtime.sendMessage({ \"message\": \"openConfig\", \"hash\": \"advanced\" });\n                        }}>\n                    {chrome.i18n.getMessage(\"betaServerWarning\")}\n                </div>\n            }\n\n            <header className={\"sbPopupLogo \" + (Config.config.cleanPopup ? \"hidden\" : \"\")}>\n                <img src={Config.config.prideTheme ? \"icons/sb-pride.png\" : \"icons/IconSponsorBlocker256px.png\"}\n                    alt=\"SponsorBlock Logo\"\n                    width=\"40\"\n                    height=\"40\"\n                    id=\"sponsorBlockPopupLogo\"\n                />\n                <p className=\"u-mZ\">\n                    SponsorBlock\n                </p>\n            </header>\n\n            <p id=\"videoFound\" \n                    className={\"u-mZ grey-text \" + (Config.config.cleanPopup ? \"cleanPopupMargin\" : \"\")}>\n                {getVideoStatusText(status)}\n            </p>\n\n            <button id=\"refreshSegmentsButton\" title={chrome.i18n.getMessage(\"refreshSegments\")} onClick={(e) => {\n                const stopAnimation = AnimationUtils.applyLoadingAnimation(e.currentTarget, 0.3);\n\n                sendMessage({ message: \"refreshSegments\" }).then(() => {\n                    loadSegments({\n                        updating: true,\n                        setStatus,\n                        setVideoID,\n                        setCurrentTime,\n                        setSegments,\n                        setLoopedChapter\n                    }).then(() => stopAnimation());\n                });\n\n            }}>\n                <img src=\"/icons/refresh.svg\" alt=\"Refresh icon\" id=\"refreshSegments\" />\n            </button>\n\n            <SegmentListComponent\n                videoID={videoID}\n                currentTime={currentTime}\n                status={status.status}\n                segments={segments}\n                loopedChapter={loopedChapter}\n                sendMessage={sendMessage} />\n\n            {/* Toggle Box */}\n            <div className=\"sbControlsMenu\">\n                {\n                    videoID &&\n                        <SkipProfileButton\n                            videoID={videoID}\n                            setShowForceChannelCheckWarning={setShowForceChannelCheckWarning}\n                        />\n                }\n                <label id=\"disableExtension\" htmlFor=\"toggleSwitch\" className=\"toggleSwitchContainer sbControlsMenu-item\" role=\"button\" tabIndex={0}>\n                    <span className=\"toggleSwitchContainer-switch\">\n                        <input type=\"checkbox\" \n                            style={{ \"display\": \"none\" }} \n                            id=\"toggleSwitch\" \n                            checked={extensionEnabled}\n                            onChange={(e) => {\n                                Config.config!.disableSkipping = !e.target.checked;\n                                setExtensionEnabled(e.target.checked)\n                            }}/>\n                        <span className=\"switchBg shadow\"></span>\n                        <span className=\"switchBg white\"></span>\n                        <span className=\"switchBg green\"></span>\n                        <span className=\"switchDot\"></span>\n                    </span>\n                    <span id=\"disableSkipping\" className={extensionEnabled ? \" hidden\" : \"\"}>\n                        {chrome.i18n.getMessage(\"enableSkipping\")}\n                    </span>\n                    <span id=\"enableSkipping\" className={!extensionEnabled ? \" hidden\" : \"\"}>\n                        {chrome.i18n.getMessage(\"disableSkipping\")}\n                    </span>\n                </label>\n                <button id=\"optionsButton\" \n                    className=\"sbControlsMenu-item\" \n                    title={chrome.i18n.getMessage(\"Options\")}\n                    onClick={() => {\n                        chrome.runtime.sendMessage({ \"message\": \"openConfig\" });\n                    }}>\n                <img src=\"/icons/settings.svg\" alt=\"Settings icon\" width=\"23\" height=\"23\" className=\"sbControlsMenu-itemIcon\" id=\"sbPopupIconSettings\" />\n                    {chrome.i18n.getMessage(\"Options\")}\n                </button>\n            </div>\n\n            {\n                showForceChannelCheckWarning &&\n                <a id=\"whitelistForceCheck\" onClick={() => {\n                    chrome.runtime.sendMessage({ \"message\": \"openConfig\", \"hash\": \"behavior\" });\n                }}>\n                    {chrome.i18n.getMessage(\"forceChannelCheckPopup\")}\n                </a>\n            }\n\n            {\n                !Config.config.cleanPopup && !Config.config.hideSegmentCreationInPopup &&\n                <SegmentSubmissionComponent\n                    videoID={videoID || \"\"}\n                    status={status.status}\n                    sendMessage={sendMessage} />\n            }\n            \n\n            {/* Your Work box */}\n            {\n                !Config.config.cleanPopup &&\n                <YourWorkComponent/>\n            }\n\n            {/* Footer */}\n            {\n                !Config.config.cleanPopup &&\n                <footer id=\"sbFooter\">\n                    <a id=\"helpButton\"\n                        onClick={() => {\n                            chrome.runtime.sendMessage({ \"message\": \"openHelp\" });\n                        }}>\n                            {chrome.i18n.getMessage(\"help\")}\n                    </a>\n                    <a href=\"https://sponsor.ajay.app\" target=\"_blank\" rel=\"noreferrer\">\n                        {chrome.i18n.getMessage(\"website\")}\n                    </a>\n                    <a href=\"https://sponsor.ajay.app/stats\" target=\"_blank\" rel=\"noreferrer\" className={isSafari() ? \" hidden\" : \"\"}>\n                        {chrome.i18n.getMessage(\"viewLeaderboard\")}\n                    </a>\n                    <a href=\"https://sponsor.ajay.app/donate\" target=\"_blank\" rel=\"noreferrer\" className={!showDonationLink() ? \" hidden\" : \"\"} onClick={() => {\n                        Config.config!.donateClicked = Config.config!.donateClicked + 1;\n                    }}>\n                        {chrome.i18n.getMessage(\"Donate\")}\n                    </a>\n                    <br />\n                    <a href=\"https://github.com/ajayyy/SponsorBlock\" target=\"_blank\" rel=\"noreferrer\">\n                        GitHub\n                    </a>\n                    <a href=\"https://discord.gg/SponsorBlock\" target=\"_blank\" rel=\"noreferrer\">\n                        Discord\n                    </a>\n                    <a href=\"https://matrix.to/#/#sponsor:ajay.app?via=ajay.app&via=matrix.org&via=mozilla.org\" target=\"_blank\" rel=\"noreferrer\">\n                        Matrix\n                    </a>\n                    <a href=\"https://wiki.sponsor.ajay.app/w/Guidelines\" target=\"_blank\" rel=\"noreferrer\">\n                        {chrome.i18n.getMessage(\"guidelines\")}\n                    </a>\n                    <br />\n                    <a id=\"debugLogs\"\n                            onClick={async () => {\n                                const logs = await sendMessage({ message: \"getLogs\" }) as LogResponse;\n\n                                copyToClipboardPopup(`${generateDebugDetails()}\\n\\nWarn:\\n${logs.warn.join(\"\\n\")}\\n\\nDebug:\\n${logs.debug.join(\"\\n\")}`, sendMessage);\n                            }}>\n                        {chrome.i18n.getMessage(\"copyDebugLogs\")}\n                    </a>\n                </footer>\n            }\n\n            {\n                showNoticeButton &&\n                <button id=\"showNoticeAgain\" onClick={() => {\n                    Config.config!.dontShowNotice = false;\n                    setShowNoticeButton(false);\n                }}>\n                    { chrome.i18n.getMessage(\"showNotice\") }\n                </button>\n            }\n        </div>\n    );\n};\n\nfunction getVideoStatusText(status: LoadingData): string {\n    switch (status.status) {\n        case LoadingStatus.Loading:\n            return chrome.i18n.getMessage(\"Loading\");\n        case LoadingStatus.SegmentsFound:\n            return chrome.i18n.getMessage(\"sponsorFound\");\n        case LoadingStatus.NoSegmentsFound:\n            return chrome.i18n.getMessage(\"sponsor404\");\n        case LoadingStatus.ConnectionError:\n            return `${chrome.i18n.getMessage(\"connectionError\")} ${chrome.i18n.getMessage(\"errorCode\").replace(\"{code}\", `${status.code}`)}`;\n        case LoadingStatus.JSError:\n            return `${chrome.i18n.getMessage(\"connectionError\")} ${status.error}`;\n        case LoadingStatus.StillLoading:\n            return chrome.i18n.getMessage(\"segmentsStillLoading\");\n        case LoadingStatus.NoVideo:\n            return chrome.i18n.getMessage(\"noVideoID\");\n    }\n}\n\nasync function loadSegments(props: LoadSegmentsProps): Promise<void> {\n    const response = await sendMessage({ message: \"isInfoFound\", updating: props.updating }) as IsInfoFoundMessageResponse;\n\n    if (response && response.videoID) {\n        segmentsLoaded(response, props);\n    } else {\n        // Handle error if it exists\n        chrome.runtime.lastError;\n\n        props.setStatus({\n            status: LoadingStatus.NoVideo,\n        });\n\n        if (!props.updating) {\n            loadRetryCount++;\n            if (loadRetryCount < 6) {\n                setTimeout(() => loadSegments(props), 100 * loadRetryCount);\n            }\n        }\n    }\n}\n\nfunction segmentsLoaded(response: IsInfoFoundMessageResponse, props: SegmentsLoadedProps): void {\n    if (response.found) {\n        props.setStatus({\n            status: LoadingStatus.SegmentsFound\n        });\n    } else if (typeof response.status !== \"number\") {\n        props.setStatus({\n            status: LoadingStatus.JSError,\n            error: response.status,\n        })\n    } else if (response.status === 404 || response.status === 200) {\n        props.setStatus({\n            status: LoadingStatus.NoSegmentsFound\n        });\n    } else if (response.status) {\n        props.setStatus({\n            status: LoadingStatus.ConnectionError,\n            code: response.status\n        });\n    } else {\n        props.setStatus({\n            status: LoadingStatus.StillLoading\n        });\n    }\n\n    \n    props.setVideoID(response.videoID);\n    Video.setVideoID(response.videoID as Video.VideoID);\n    props.setCurrentTime(response.time);\n    Video.setChanelIDInfo(response.channelID, response.channelAuthor);\n    setCurrentTabSkipProfile(response.currentTabSkipProfileID);\n    props.setSegments((response.sponsorTimes || [])\n        .filter((segment) => segment.source === SponsorSourceType.Server)\n        .sort((a, b) => b.segment[1] - a.segment[1])\n        .sort((a, b) => a.segment[0] - b.segment[0])\n        .sort((a, b) => a.actionType === ActionType.Full ? -1 : b.actionType === ActionType.Full ? 1 : 0));\n    props.setLoopedChapter(response.loopedChapter);\n}\n\nfunction sendMessage(request: Message): Promise<MessageResponse> {\n    return new Promise((resolve) => {\n        if (chrome.tabs) {\n            chrome.tabs.query({\n                active: true,\n                currentWindow: true\n            }, (tabs) => chrome.tabs.sendMessage(tabs[0].id, request, resolve));\n        } else {\n            chrome.runtime.sendMessage({ message: \"tabs\", data: request }, resolve);\n        }\n    });\n}\n\nfunction setupComPort(props: SegmentsLoadedProps): void {\n    const port = chrome.runtime.connect({ name: \"popup\" });\n    port.onDisconnect.addListener(() => setupComPort(props));\n    port.onMessage.addListener((msg) => onMessage(props, msg));\n}\n\nfunction onMessage(props: SegmentsLoadedProps, msg: PopupMessage) {\n    switch (msg.message) {\n        case \"time\":\n            props.setCurrentTime(msg.time);\n            break;\n        case \"infoUpdated\":\n            segmentsLoaded(msg, props);\n            break;\n        case \"videoChanged\":\n            props.setStatus({\n                status: LoadingStatus.StillLoading\n            });\n            props.setVideoID(msg.videoID);\n            Video.setVideoID(msg.videoID as Video.VideoID);\n            Video.setChanelIDInfo(msg.channelID, msg.channelAuthor);\n            props.setSegments([]);\n            break;\n    }\n}\n\nfunction forwardClickEvents(sendMessage: (request: Message) => Promise<MessageResponse>): void {\n    if (window !== window.top) {\n        document.addEventListener(\"keydown\", (e) => {\n            const target = e.target as HTMLElement;\n            if (target.tagName === \"INPUT\"\n                || target.tagName === \"TEXTAREA\"\n                || e.key === \"ArrowUp\"\n                || e.key === \"ArrowDown\") {\n                return;\n            }\n\n            if (e.key === \" \") {\n                // No scrolling\n                e.preventDefault();\n            }\n\n            sendMessage({\n                message: \"keydown\",\n                key: e.key,\n                keyCode: e.keyCode,\n                code: e.code,\n                which: e.which,\n                shiftKey: e.shiftKey,\n                ctrlKey: e.ctrlKey,\n                altKey: e.altKey,\n                metaKey: e.metaKey\n            });\n        });\n    }\n}\n\n// Copy over styles from parent window\nwindow.addEventListener(\"message\", async (e): Promise<void> => {\n    if (e.source !== window.parent) return;\n    if (e.origin.endsWith(\".youtube.com\") && e.data && e.data?.type === \"style\") {\n        const style = document.createElement(\"style\");\n        style.textContent = e.data.css;\n        document.head.appendChild(style);\n    }\n});\n\nfunction SkipProfileButton(props: {videoID: string; setShowForceChannelCheckWarning: (v: boolean) => void}): JSX.Element {\n    const [menuOpen, setMenuOpen] = React.useState(false);\n    const channelSkipProfileSet = getSkipProfileIDForChannel() !== null;\n    const skipProfileSet = getSkipProfileID() !== null;\n\n    React.useEffect(() => {\n        setMenuOpen(false);\n    }, [props.videoID]);\n\n    return (\n        <>\n            <label id=\"skipProfileButton\" \n                    htmlFor=\"skipProfileToggle\"\n                    className=\"toggleSwitchContainer sbControlsMenu-item\"\n                    role=\"button\"\n                    tabIndex={0}\n                    onClick={() => {\n                        if (menuOpen && !Config.config.forceChannelCheck && getSkipProfileID() !== null) {\n                            props.setShowForceChannelCheckWarning(true);\n                        }\n\n                        setMenuOpen(!menuOpen);\n                    }}>\n                <svg viewBox=\"0 0 24 24\" width=\"23\" height=\"23\" className={\"SBWhitelistIcon sbControlsMenu-itemIcon \" + (menuOpen ? \" rotated\" : \"\")}>\n                    <path d=\"M24 10H14V0h-4v10H0v4h10v10h4V14h10z\" />\n                </svg>\n                <span id=\"whitelistChannel\" className={!(!menuOpen && !channelSkipProfileSet && !skipProfileSet) ? \" hidden\" : \"\"}>\n                    {chrome.i18n.getMessage(\"addChannelToSkipProfile\")}\n                </span>\n                <span id=\"whitelistChannel\" className={!(!menuOpen && channelSkipProfileSet) ? \" hidden\" : \"\"}>\n                    {chrome.i18n.getMessage(\"editChannelsSkipProfile\")}\n                </span>\n                <span id=\"whitelistChannel\" className={!(!menuOpen && !channelSkipProfileSet && skipProfileSet) ? \" hidden\" : \"\"}>\n                    {chrome.i18n.getMessage(\"editActiveSkipProfile\")}\n                </span>\n                <span id=\"unwhitelistChannel\" className={!menuOpen ? \" hidden\" : \"\"}>\n                    {chrome.i18n.getMessage(\"closeSkipProfileMenu\")}\n                </span>\n            </label>\n\n            {\n                props.videoID &&\n                <SkipProfileMenu open={menuOpen} videoID={props.videoID} />\n            }\n        </>\n    );\n}\n\nconst skipProfileOptions: SkipProfileOption[] = [{\n        name: \"forAnHour\",\n        active: () => getSkipProfileIDForTime() !== null\n    }, {\n        name: \"forThisTab\",\n        active: () => getSkipProfileIDForTab() !== null\n    }, {\n        name: \"forJustThisVideo\",\n        active: () => getSkipProfileIDForVideo() !== null\n    }, {\n        name: \"forThisChannel\",\n        active: () => getSkipProfileIDForChannel() !== null\n    }];\n\nfunction SkipProfileMenu(props: {open: boolean; videoID: string}): JSX.Element {\n    const [configID, setConfigID] = React.useState<ConfigurationID | null>(null);\n    const [selectedSkipProfileAction, setSelectedSkipProfileAction] = React.useState<SkipProfileAction>(null);\n    const [allSkipProfiles, setAllSkipProfiles] = React.useState(Object.entries(Config.local!.skipProfiles));\n\n    React.useEffect(() => {\n        if (props.open) {\n            const channelInfo = Video.getChannelIDInfo();\n            if (!channelInfo) {\n                if (Video.isOnYTTV()) {\n                    alert(chrome.i18n.getMessage(\"yttvNoChannelWhitelist\"));\n                } else {\n                    alert(chrome.i18n.getMessage(\"channelDataNotFound\") + \" https://github.com/ajayyy/SponsorBlock/issues/753\");\n                }\n            }\n        }\n\n        setConfigID(getSkipProfileID());\n    }, [props.open, props.videoID]);\n\n    React.useEffect(() => {\n        Config.configLocalListeners.push(() => {\n            setAllSkipProfiles(Object.entries(Config.local!.skipProfiles));\n        });\n    }, []);\n\n    return (\n        <div id=\"skipProfileMenu\" className={`${!props.open ? \" hidden\" : \"\"}`}\n            aria-label={chrome.i18n.getMessage(\"SkipProfileMenu\")}>\n            <div style={{position: \"relative\"}}>\n                <SelectOptionComponent\n                    id=\"sbSkipProfileSelection\"\n                    title={chrome.i18n.getMessage(\"SelectASkipProfile\")}\n                    onChange={(value) => {\n                        if (value === \"new\") {\n                            chrome.runtime.sendMessage({ message: \"openConfig\", hash: \"newProfile\" });\n                            return;\n                        }\n                        \n                        const configID = value === \"null\" ? null : value as ConfigurationID;\n                        setConfigID(configID);\n                        if (configID === null) {\n                            setSelectedSkipProfileAction(null);\n                        }\n\n                        if (selectedSkipProfileAction) {\n                            updateSkipProfileSetting(selectedSkipProfileAction, configID);\n\n                            if (configID === null) {\n                                for (const option of skipProfileOptions) {\n                                    if (option.name !== selectedSkipProfileAction && option.active()) {\n                                        updateSkipProfileSetting(option.name, null);\n                                    }\n                                }\n                            }\n                        }\n                    }}\n                    value={configID ?? \"null\"}\n                    options={[{\n                        value: \"null\",\n                        label: chrome.i18n.getMessage(\"DefaultConfiguration\")\n                    }].concat(allSkipProfiles.map(([key, value]) => ({\n                        value: key,\n                        label: value.name\n                    }))).concat([{\n                        value: \"new\",\n                        label: chrome.i18n.getMessage(\"CreateNewConfiguration\")\n                    }])}\n                />\n\n                <SkipProfileRadioButtons\n                    selected={selectedSkipProfileAction}\n                    setSelected={(s, updateConfig) => {\n                        if (updateConfig) {\n                            if (s === null) {\n                                updateSkipProfileSetting(selectedSkipProfileAction, null);\n                            } else {\n                                updateSkipProfileSetting(s, configID);\n                            }\n                        } else if (s !== null) {\n                            setConfigID(getSkipProfileID());\n                        }\n\n                        setSelectedSkipProfileAction(s);\n                    }}\n                    disabled={configID === null}\n                    configID={configID}\n                    videoID={props.videoID}\n                />\n            </div>\n        </div>\n    );\n}\n\nfunction SkipProfileRadioButtons(props: SkipProfileRadioButtonsProps): JSX.Element {\n    const result: JSX.Element[] = [];\n\n    React.useEffect(() => {\n        if (props.configID === null) {\n            props.setSelected(null, false);\n        } else {\n            for (const option of skipProfileOptions) {\n                if (option.active()) {\n                    if (props.selected !== option.name) {\n                        props.setSelected(option.name, false);\n                    }\n\n                    return;\n                }\n            }\n        }\n    }, [props.configID, props.videoID, props.selected]);\n\n    let alreadySelected = false;\n    for (const option of skipProfileOptions) {\n        const highlighted = option.active() && props.selected !== option.name;\n        const overridden = !highlighted && alreadySelected;\n        result.push(\n            <SkipOptionActionComponent\n                highlighted={highlighted}\n                label={chrome.i18n.getMessage(`skipProfile_${option.name}`)}\n                selected={props.selected === option.name}\n                overridden={overridden}\n                disabled={props.disabled || overridden}\n                key={option.name}\n                setSelected={(s) => {\n                    props.setSelected(s ? option.name : null, true);\n                }}/>\n        );\n\n        if (props.selected === option.name) {\n            alreadySelected = true;\n        }\n    }\n\n    return <div id=\"skipProfileActions\">\n        {result}\n    </div>\n}\n\nfunction SkipOptionActionComponent(props: SkipOptionActionComponentProps): JSX.Element {\n    let title = \"\";\n    if (props.selected) {\n        title = chrome.i18n.getMessage(\"clickToNotApplyThisProfile\");\n    } else if ((props.highlighted && !props.disabled) || props.overridden) {\n        title = chrome.i18n.getMessage(\"skipProfileBeingOverriddenByHigherPriority\");\n    } else if (!props.highlighted && !props.disabled) {\n        title = chrome.i18n.getMessage(\"clickToApplyThisProfile\");\n    } else if (props.disabled) {\n        title = chrome.i18n.getMessage(\"selectASkipProfileFirst\");\n    }\n\n    return (\n        <div className={`skipOptionAction ${props.selected ? \"selected\" : \"\"} ${props.highlighted ? \"highlighted\" : \"\"} ${props.disabled ? \"disabled\" : \"\"}`}\n            title={title}\n            role=\"button\"\n            tabIndex={0}\n            aria-pressed={props.selected}\n            onClick={() => {\n                // Need to uncheck or disable a higher priority option first\n                if (!props.disabled && !props.highlighted) {\n                    props.setSelected(!props.selected);\n                }\n            }}>\n            {props.label}\n        </div>\n    );\n}\n\nfunction updateSkipProfileSetting(action: SkipProfileAction, configID: ConfigurationID | null) {\n    switch (action) {\n        case \"forAnHour\":\n            Config.local!.skipProfileTemp = configID ? { time: Date.now(), configID } : null;\n            break;\n        case \"forThisTab\":\n            setCurrentTabSkipProfile(configID);\n\n            sendMessage({\n                message: \"setCurrentTabSkipProfile\",\n                configID\n            });\n            break;\n        case \"forJustThisVideo\":\n            if (configID) {\n                Config.local!.channelSkipProfileIDs[Video.getVideoID()!] = configID;\n            } else {\n                delete Config.local!.channelSkipProfileIDs[Video.getVideoID()!];\n            }\n\n            Config.forceLocalUpdate(\"channelSkipProfileIDs\");\n            break;\n        case \"forThisChannel\": {\n            const channelInfo = Video.getChannelIDInfo();\n\n            if (configID) {\n                Config.local!.channelSkipProfileIDs[channelInfo.id] = configID;\n                delete Config.local!.channelSkipProfileIDs[channelInfo.author];\n            } else {\n                delete Config.local!.channelSkipProfileIDs[channelInfo.id];\n                delete Config.local!.channelSkipProfileIDs[channelInfo.author];\n            }\n\n            Config.forceLocalUpdate(\"channelSkipProfileIDs\");\n            break;\n        }\n    }\n}"
  },
  {
    "path": "src/popup/SegmentListComponent.tsx",
    "content": "import * as React from \"react\";\nimport { ActionType, SegmentListDefaultTab, SegmentUUID, SponsorHideType, SponsorTime, VideoID } from \"../types\";\nimport Config from \"../config\";\nimport { waitFor } from \"../../maze-utils/src\";\nimport { shortCategoryName } from \"../utils/categoryUtils\";\nimport { formatJSErrorMessage, getFormattedTime, getShortErrorMessage } from \"../../maze-utils/src/formating\";\nimport { AnimationUtils } from \"../../maze-utils/src/animationUtils\";\nimport { asyncRequestToServer } from \"../utils/requests\";\nimport { Message, MessageResponse, VoteResponse } from \"../messageTypes\";\nimport { LoadingStatus } from \"./PopupComponent\";\nimport GenericNotice from \"../render/GenericNotice\";\nimport { exportTimes } from \"../utils/exporter\";\nimport { copyToClipboardPopup } from \"./popupUtils\";\nimport { logRequest } from \"../../maze-utils/src/background-request-proxy\";\n\ninterface SegmentListComponentProps {\n    videoID: VideoID;\n    currentTime: number;\n    status: LoadingStatus;\n    segments: SponsorTime[];\n    loopedChapter: SegmentUUID | null;\n\n    sendMessage: (request: Message) => Promise<MessageResponse>;\n}\n\nenum SegmentListTab {\n    Segments,\n    Chapter\n}\n\ninterface SegmentWithNesting extends SponsorTime {\n    innerChapters?: (SegmentWithNesting|SponsorTime)[];\n}\n\nfunction isSegment(segment) {\n    return segment.actionType !== ActionType.Chapter;\n}\n\nfunction isChapter(segment) {\n    return segment.actionType === ActionType.Chapter;\n}\n\nexport const SegmentListComponent = (props: SegmentListComponentProps) => {\n    const [tab, setTab] = React.useState(SegmentListTab.Segments);\n    const [isVip, setIsVip] = React.useState(Config.config?.isVip ?? false);\n\n    React.useEffect(() => {\n        if (!Config.isReady()) {\n            waitFor(() => Config.isReady()).then(() => {\n                setIsVip(Config.config.isVip);\n            });\n        } else {\n            setIsVip(Config.config.isVip);\n        }\n    }, []);\n\n    const [hasSegments, hasChapters] = React.useMemo(() => {\n        const hasSegments = Boolean(props.segments.find(isSegment))\n        const hasChapters = Boolean(props.segments.find(isChapter))\n        return [hasSegments, hasChapters];\n    }, [props.segments]);\n\n    React.useEffect(() => {\n        const setTabBasedOnConfig = () => {\n            const preferChapters = Config.config.segmentListDefaultTab === SegmentListDefaultTab.Chapters;\n            if (preferChapters) {\n                setTab(hasChapters ? SegmentListTab.Chapter : SegmentListTab.Segments);\n            } else {\n                setTab(hasSegments ? SegmentListTab.Segments : SegmentListTab.Chapter);\n            }\n        };\n\n        if (Config.isReady()) {\n            setTabBasedOnConfig();\n        } else {\n            waitFor(() => Config.isReady()).then(setTabBasedOnConfig);\n        }\n    }, [props.videoID, hasSegments, hasChapters]);\n\n    const segmentsWithNesting = React.useMemo(() => {\n        const result: SegmentWithNesting[] = [];\n        const chapterStack: SegmentWithNesting[] = [];\n        for (let seg of props.segments) {\n            seg = {...seg};\n            // non-chapter, do not nest\n            if (seg.actionType !== ActionType.Chapter) {\n                result.push(seg);\n                continue;\n            }\n            // traverse the stack\n            while (chapterStack.length !== 0) {\n                // where's Array.prototype.at() :sob:\n                const lastChapter = chapterStack[chapterStack.length - 1];\n                // we know lastChapter.startTime <= seg.startTime, as content.ts sorts these\n                // so only compare endTime - if new ends before last, new is nested inside last\n                if (lastChapter.segment[1] >= seg.segment[1]) {\n                    lastChapter.innerChapters ??= [];\n                    lastChapter.innerChapters.push(seg);\n                    chapterStack.push(seg);\n                    break;\n                }\n                // last did not match, pop it off the stack\n                chapterStack.pop();\n            }\n            // chapter stack not empty = we found a place for the chapter\n            if (chapterStack.length !== 0) {\n                continue;\n            }\n            // push the chapter to the top-level list and to the stack\n            result.push(seg);\n            chapterStack.push(seg);\n\n        }\n        return result;\n    }, [props.segments])\n\n\n    return (\n        <div id=\"issueReporterContainer\">\n            <div id=\"issueReporterTabs\" className={hasSegments && hasChapters ? \"\" : \"hidden\"}>\n                <span id=\"issueReporterTabSegments\" className={tab === SegmentListTab.Segments ? \"sbSelected\" : \"\"} onClick={() => {\n                    setTab(SegmentListTab.Segments);\n                }}>\n                    <span>{chrome.i18n.getMessage(\"SegmentsCap\")}</span>\n                </span>\n                <span id=\"issueReporterTabChapters\" className={tab === SegmentListTab.Chapter ? \"sbSelected\" : \"\"} onClick={() => {\n                    setTab(SegmentListTab.Chapter);\n                }}>\n                    <span>{chrome.i18n.getMessage(\"Chapters\")}</span>\n                </span>\n            </div>\n            <div id=\"issueReporterTimeButtons\"\n                    onMouseLeave={() => selectSegment({\n                        segment: null,\n                        sendMessage: props.sendMessage\n                    })}>\n                {\n                    segmentsWithNesting.map((segment) => (\n                        <SegmentListItem\n                            key={segment.UUID}\n                            videoID={props.videoID}\n                            segment={segment}\n                            currentTime={props.currentTime}\n                            isVip={isVip}\n                            loopedChapter={props.loopedChapter} // UUID instead of boolean so it can be passed down to nested chapters \n\n                            tabFilter={tab === SegmentListTab.Chapter ? isChapter : isSegment}\n                            sendMessage={props.sendMessage}\n                        />\n                    ))\n                }\n            </div>\n            \n            <ImportSegments\n                status={props.status}\n                segments={props.segments}\n                sendMessage={props.sendMessage}\n            />\n        </div>\n    );\n};\n\nfunction SegmentListItem({ segment, videoID, currentTime, isVip, loopedChapter, tabFilter, sendMessage }: {\n    segment: SegmentWithNesting;\n    videoID: VideoID;\n    currentTime: number;\n    isVip: boolean;\n    loopedChapter: SegmentUUID;\n    \n    tabFilter: (segment: SponsorTime) => boolean;\n    sendMessage: (request: Message) => Promise<MessageResponse>;\n}) {\n    const [voteMessage, setVoteMessage] = React.useState<string | null>(null);\n    const [hidden, setHidden] = React.useState(segment.hidden ?? SponsorHideType.Visible); // undefined ?? undefined lol\n    const [isLooped, setIsLooped] = React.useState(loopedChapter === segment.UUID);\n\n    // Update internal state if the hidden property of the segment changes\n    React.useEffect(() => {\n        setHidden(segment.hidden ?? SponsorHideType.Visible);\n    }, [segment.hidden])\n\n    let extraInfo: string;\n    switch (hidden) {\n        case SponsorHideType.Visible:\n            extraInfo = \"\";\n            break;\n        case SponsorHideType.Downvoted:\n            extraInfo = \" (\" + chrome.i18n.getMessage(\"hiddenDueToDownvote\") + \")\";\n            break;\n        case SponsorHideType.MinimumDuration:\n            extraInfo = \" (\" + chrome.i18n.getMessage(\"hiddenDueToDuration\") + \")\";\n            break;\n        case SponsorHideType.Hidden:\n            extraInfo = \" (\" + chrome.i18n.getMessage(\"manuallyHidden\") + \")\";\n            break;\n        default:\n            // hidden satisfies never; // need to upgrade TS\n            console.warn(`[SB] Unhandled variant of SponsorHideType in SegmentListItem: ${hidden}`);\n            extraInfo = \"\";\n    }\n\n    return (\n        <div className={\"segmentWrapper \" + (!tabFilter(segment) ? \"hidden\" : \"\")}>\n            <details data-uuid={segment.UUID}\n                    onDoubleClick={() => skipSegment({\n                        segment,\n                        sendMessage\n                    })}\n                    onMouseEnter={() => {\n                        selectSegment({\n                            segment,\n                            sendMessage\n                        });\n                    }}\n                    className={\"votingButtons\"}\n                    >\n                <summary className={\"segmentSummary \" + (\n                    currentTime >= segment.segment[0] ? (\n                        currentTime < segment.segment[1] ? \"segmentActive\" : \"segmentPassed\"\n                    ) : \"\"\n                )}>\n                    <div>\n                        {\n                            segment.actionType !== ActionType.Chapter &&\n                            <span className=\"sponsorTimesCategoryColorCircle dot\" style={{ backgroundColor: Config.config.barTypes[segment.category]?.color }}></span>\n                        }\n                        <span className=\"summaryLabel\">{(segment.description || shortCategoryName(segment.category)) + extraInfo}</span>\n                    </div>\n\n                    <div style={{ margin: \"5px\" }}>\n                        {\n                            segment.actionType === ActionType.Full ? chrome.i18n.getMessage(\"full\") :\n                            (getFormattedTime(segment.segment[0], true) +\n                                (segment.actionType !== ActionType.Poi\n                                    ? \" \" + chrome.i18n.getMessage(\"to\") + \" \" + getFormattedTime(segment.segment[1], true)\n                                    : \"\"))\n                        }\n                    </div>\n                </summary>\n\n                <div className={\"sbVoteButtonsContainer \" + (voteMessage ? \"hidden\" : \"\")}>\n                    <img\n                        className=\"voteButton\"\n                        title=\"Upvote\"\n                        src={chrome.runtime.getURL(\"icons/thumbs_up.svg\")}\n                        onClick={() => {\n                            vote({\n                                type: 1,\n                                UUID: segment.UUID,\n                                setVoteMessage: setVoteMessage,\n                                sendMessage\n                            });\n                        }}/>\n                    <img\n                        className=\"voteButton\"\n                        title=\"Downvote\"\n                        src={segment.locked && isVip ? chrome.runtime.getURL(\"icons/thumbs_down_locked.svg\") : chrome.runtime.getURL(\"icons/thumbs_down.svg\")}\n                        onClick={() => {\n                            vote({\n                                type: 0,\n                                UUID: segment.UUID,\n                                setVoteMessage: setVoteMessage,\n                                sendMessage\n                            });\n                        }}/>\n                    <img\n                        className=\"voteButton\"\n                        title=\"Copy Segment ID\"\n                        src={chrome.runtime.getURL(\"icons/clipboard.svg\")}\n                        onClick={async (e) => {\n                            const stopAnimation = AnimationUtils.applyLoadingAnimation(e.currentTarget, 0.3);\n\n                            try {\n                                if (segment.UUID.length > 60) {\n                                    copyToClipboardPopup(segment.UUID, sendMessage);\n                                } else {\n                                    const segmentIDData = await asyncRequestToServer(\"GET\", \"/api/segmentID\", {\n                                        UUID: segment.UUID,\n                                        videoID: videoID\n                                    });\n                                    if (segmentIDData.ok && segmentIDData.responseText) {\n                                        copyToClipboardPopup(segmentIDData.responseText, sendMessage);\n                                    } else {\n                                        logRequest(segmentIDData, \"SB\", \"segment UUID resolution\");\n                                    }\n                                }\n                            } catch (e) {\n                                console.error(\"[SB] Caught error while attempting to resolve and copy segment UUID\", e);\n                            } finally {\n                                stopAnimation();\n                            }\n\n                        }}/>\n                    {\n                        segment.actionType === ActionType.Chapter &&\n                        <img\n                            className=\"voteButton\"\n                            title={isLooped ? chrome.i18n.getMessage(\"unloopChapter\") : chrome.i18n.getMessage(\"loopChapter\")}\n                            src={isLooped ? chrome.runtime.getURL(\"icons/looped.svg\") : chrome.runtime.getURL(\"icons/loop.svg\")}\n                            onClick={(e) => {\n                                if (isLooped) {\n                                    loopChapter({\n                                        segment: null,\n                                        element: e.currentTarget,\n                                        sendMessage\n                                    });\n                                } else {\n                                    loopChapter({\n                                        segment,\n                                        element: e.currentTarget,\n                                        sendMessage\n                                    });\n                                }\n\n                                setIsLooped(!isLooped);\n                            }}/>\n                    }\n                    {\n                        (segment.actionType === ActionType.Skip || segment.actionType === ActionType.Mute\n                            || segment.actionType === ActionType.Poi\n                            && [SponsorHideType.Visible, SponsorHideType.Hidden].includes(hidden)) &&\n                        <img\n                            className=\"voteButton\"\n                            title={chrome.i18n.getMessage(\"hideSegment\")}\n                            src={hidden === SponsorHideType.Hidden ? chrome.runtime.getURL(\"icons/not_visible.svg\") : chrome.runtime.getURL(\"icons/visible.svg\")}\n                            onClick={(e) => {\n                                const stopAnimation = AnimationUtils.applyLoadingAnimation(e.currentTarget, 0.4);\n                                stopAnimation();\n\n                                const newState = hidden === SponsorHideType.Hidden ? SponsorHideType.Visible : SponsorHideType.Hidden;\n                                setHidden(newState);\n                                sendMessage({\n                                    message: \"hideSegment\",\n                                    type: newState,\n                                    UUID: segment.UUID\n                                });\n                            }}/>\n                    }\n                    {\n                        segment.actionType !== ActionType.Full &&\n                        <img\n                            className=\"voteButton\"\n                            title={segment.actionType === ActionType.Chapter ? chrome.i18n.getMessage(\"playChapter\") : chrome.i18n.getMessage(\"skipSegment\")}\n                            src={chrome.runtime.getURL(\"icons/skip.svg\")}\n                            onClick={(e) => {\n                                skipSegment({\n                                    segment,\n                                    element: e.currentTarget,\n                                    sendMessage\n                                });\n                            }}/>\n                    }\n                </div>\n\n                <div className={\"sponsorTimesVoteStatusContainer \" + (voteMessage ? \"\" : \"hidden\")}>\n                    <div className=\"sponsorTimesThanksForVotingText\">\n                        {voteMessage}\n                    </div>\n                </div>\n            </details>\n\n            {\n                segment.innerChapters\n                && <InnerChapterList\n                    chapters={segment.innerChapters}\n                    videoID={videoID}\n                    currentTime={currentTime}\n                    isVip={isVip}\n                    loopedChapter={loopedChapter}\n                    tabFilter={tabFilter}\n                    sendMessage={sendMessage}\n                />\n            }\n        </div>\n    );\n}\n\nfunction InnerChapterList({ chapters, videoID, currentTime, isVip, loopedChapter, tabFilter, sendMessage }: {\n    chapters: (SegmentWithNesting)[];\n    videoID: VideoID;\n    currentTime: number;\n    isVip: boolean;\n    loopedChapter: SegmentUUID;\n\n    tabFilter: (segment: SponsorTime) => boolean;\n    sendMessage: (request: Message) => Promise<MessageResponse>;\n}) {\n    return <details className=\"innerChapterList\" open>\n        <summary\n            onClick={(e) => {\n                e.currentTarget.firstChild.textContent = (e.currentTarget.parentElement as HTMLDetailsElement).open ? chrome.i18n.getMessage(\"expandChapters\").replace(\"{0}\", String(chapters.length)) : chrome.i18n.getMessage(\"collapseChapters\");\n            }}>\n            {chrome.i18n.getMessage(\"collapseChapters\")}\n        </summary>\n        <div className=\"innerChaptersContainer\">\n            {\n                chapters.map((chapter) => {\n                    return <SegmentListItem\n                        key={chapter.UUID}\n                        videoID={videoID}\n                        segment={chapter}\n                        currentTime={currentTime}\n                        isVip={isVip}\n                        loopedChapter={loopedChapter}\n\n                        tabFilter={tabFilter}\n                        sendMessage={sendMessage}\n                    />\n                })\n            }\n        </div>\n    </details>\n}\n\nasync function vote(props: {\n    type: number;\n    UUID: SegmentUUID;\n    setVoteMessage: (message: string | null) => void;\n    sendMessage: (request: Message) => Promise<MessageResponse>;\n}): Promise<void> {\n    props.setVoteMessage(chrome.i18n.getMessage(\"Loading\"));\n\n    const response = await props.sendMessage({\n        message: \"submitVote\",\n        type: props.type,\n        UUID: props.UUID\n    }) as VoteResponse;\n\n    if (response != undefined) {\n        let messageDuration = 1_500;\n        // See if it was a success or failure\n        if (\"error\" in response) {\n            // JS error\n            console.error(\"[SB] Caught error while attempting to submit a vote\", response.error);\n            props.setVoteMessage(formatJSErrorMessage(response.error));\n            messageDuration = 10_000;\n        }\n        else if (response.ok || response.status === 429) {\n            // Success (treat rate limits as a success)\n            props.setVoteMessage(chrome.i18n.getMessage(\"voted\"));\n        } else {\n            // Error\n            logRequest({headers: null, ...response}, \"SB\", \"vote on segment\");\n            props.setVoteMessage(getShortErrorMessage(response.status, response.responseText));\n            messageDuration = 10_000;\n        }\n        setTimeout(() => props.setVoteMessage(null), messageDuration);\n    }\n}\n\nfunction skipSegment({ segment, element, sendMessage }: {\n    segment: SponsorTime;\n    element?: HTMLElement;\n\n    sendMessage: (request: Message) => Promise<MessageResponse>;\n}): void {\n    if (segment.actionType === ActionType.Chapter) {\n        sendMessage({\n            message: \"unskip\",\n            UUID: segment.UUID\n        });\n    } else {\n        sendMessage({\n            message: \"reskip\",\n            UUID: segment.UUID\n        });\n    }\n\n    if (element) {\n        const stopAnimation = AnimationUtils.applyLoadingAnimation(element, 0.3);\n        stopAnimation();\n    }\n}\n\nfunction selectSegment({ segment, sendMessage }: {\n    segment: SponsorTime | null;\n\n    sendMessage: (request: Message) => Promise<MessageResponse>;\n}): void {\n    sendMessage({\n        message: \"selectSegment\",\n        UUID: segment?.UUID\n    });\n}\n\nfunction loopChapter({ segment, element, sendMessage }: {\n    segment: SponsorTime;\n    element: HTMLElement;\n\n    sendMessage: (request: Message) => Promise<MessageResponse>;\n}): void {\n    sendMessage({\n        message: \"loopChapter\",\n        UUID: segment?.UUID\n    });\n\n    if (element) {\n        const stopAnimation = AnimationUtils.applyLoadingAnimation(element, 0.3);\n        stopAnimation();\n    }\n}\n\ninterface ImportSegmentsProps {\n    status: LoadingStatus;\n    segments: SponsorTime[];\n\n    sendMessage: (request: Message) => Promise<MessageResponse>;\n}\n\nfunction ImportSegments(props: ImportSegmentsProps) {\n    const [importMenuVisible, setImportMenuVisible] = React.useState(false);\n    const textArea = React.useRef<HTMLTextAreaElement>(null);\n\n    return (\n        <div id=\"issueReporterImportExport\" className={props.status === LoadingStatus.Loading ? \"hidden\" : \"\"}>\n            <div id=\"importExportButtons\">\n            <button id=\"importSegmentsButton\"\n                    className={props.status === LoadingStatus.SegmentsFound || props.status === LoadingStatus.NoSegmentsFound ? \"\" : \"hidden\"}\n                    title={chrome.i18n.getMessage(\"importSegments\")}\n                    onClick={() => {\n                        setImportMenuVisible(!importMenuVisible);\n                    }}>\n                <img src=\"/icons/import.svg\" alt=\"Import icon\" id=\"importSegments\" />\n            </button>\n            <button id=\"exportSegmentsButton\"\n                    className={props.segments.length === 0 ? \"hidden\" : \"\"}\n                    title={chrome.i18n.getMessage(\"exportSegments\")}\n                    onClick={(e) => {\n                        copyToClipboardPopup(exportTimes(props.segments), props.sendMessage);\n\n                        const stopAnimation = AnimationUtils.applyLoadingAnimation(e.currentTarget, 0.3);\n                        stopAnimation();\n                        new GenericNotice(null, \"exportCopied\", {\n                            title:  chrome.i18n.getMessage(`CopiedExclamation`),\n                            timed: true,\n                            maxCountdownTime: () => 0.6,\n                            referenceNode: e.currentTarget.parentElement,\n                            dontPauseCountdown: true,\n                            style: {\n                                top: 0,\n                                bottom: 0,\n                                minWidth: 0,\n                                right: \"30px\",\n                                margin: \"auto\",\n                                height: \"max-content\"\n                            },\n                            hideLogo: true,\n                            hideRightInfo: true\n                        });\n                    }}>\n                <img src=\"/icons/export.svg\" alt=\"Export icon\" id=\"exportSegments\" />\n            </button>\n            </div>\n\n            <span id=\"importSegmentsMenu\" className={importMenuVisible ? \"\" : \"hidden\"}>\n                <textarea id=\"importSegmentsText\" rows={5} style={{ width: \"80%\" }} ref={textArea}></textarea>\n\n                <button id=\"importSegmentsSubmit\"\n                        title={chrome.i18n.getMessage(\"importSegments\")}\n                        onClick={() => {\n                            const text = textArea.current.value;\n\n                            props.sendMessage({\n                                message: \"importSegments\",\n                                data: text\n                            });\n\n                            setImportMenuVisible(false);\n                        }}>\n                    {chrome.i18n.getMessage(\"Import\")}\n                </button>\n            </span>\n        </div>\n    )\n}\n"
  },
  {
    "path": "src/popup/SegmentSubmissionComponent.tsx",
    "content": "import * as React from \"react\";\nimport { VideoID } from \"../types\";\nimport Config from \"../config\";\nimport { Message, MessageResponse } from \"../messageTypes\";\nimport { LoadingStatus } from \"./PopupComponent\";\n\ninterface SegmentSubmissionComponentProps {\n    videoID: VideoID;\n    status: LoadingStatus;\n\n    sendMessage: (request: Message) => Promise<MessageResponse>;\n}\n\nexport const SegmentSubmissionComponent = (props: SegmentSubmissionComponentProps) => {\n    const segments = Config.local.unsubmittedSegments[props.videoID];\n\n    const [showSubmitButton, setShowSubmitButton] = React.useState(segments && segments.length > 0);\n    const [showStartSegment, setShowStartSegment] = React.useState(!segments || segments[segments.length - 1].segment.length === 2);\n\n    return (\n        <div id=\"mainControls\" className={props.status === LoadingStatus.Loading ? \"hidden\" : \"\"}>\n            <h1 className=\"sbHeader\">\n                {chrome.i18n.getMessage(\"recordTimesDescription\")}\n            </h1>\n            <sub className=\"sponsorStartHint grey-text\">\n                {chrome.i18n.getMessage(\"popupHint\")}\n            </sub>\n            <div style={{ textAlign: \"center\", margin: \"8px 0\" }}>\n                <button id=\"sponsorStart\" \n                        className=\"sbMediumButton\"\n                        style={{ marginRight: \"8px\" }}\n                        onClick={() => {\n                            props.sendMessage({\n                                from: \"popup\",\n                                message: \"sponsorStart\"\n                            });\n\n                            setShowStartSegment(!showStartSegment);\n                            setShowSubmitButton(true);\n\n                            // Once data is saved, make sure it is correct\n                            setTimeout(() => {\n                                const segments = Config.local.unsubmittedSegments[props.videoID];\n                                setShowStartSegment(!segments || segments[segments.length - 1].segment.length === 2);\n\n                                setShowSubmitButton(segments && segments.length > 0);\n                            }, 200);\n                        }}>\n                    {showStartSegment ? chrome.i18n.getMessage(\"sponsorStart\") : chrome.i18n.getMessage(\"sponsorEnd\")}\n                </button>\n                <button id=\"submitTimes\" \n                        className={\"sbMediumButton \" + (showSubmitButton ? \"\" : \"hidden\")}\n                        onClick={() => {\n                            props.sendMessage({\n                                message: \"submitTimes\"\n                            });\n                        }}>\n                    {chrome.i18n.getMessage(\"OpenSubmissionMenu\")}\n                </button>\n            </div>\n            <span id=\"submissionHint\" className={showSubmitButton ? \"\" : \"hidden\"}>\n                {chrome.i18n.getMessage(\"submissionEditHint\")}\n            </span>\n        </div>\n    );\n};"
  },
  {
    "path": "src/popup/YourWorkComponent.tsx",
    "content": "import * as React from \"react\";\nimport { getHash } from \"../../maze-utils/src/hash\";\nimport { formatJSErrorMessage, getShortErrorMessage } from \"../../maze-utils/src/formating\";\nimport Config from \"../config\";\nimport { asyncRequestToServer } from \"../utils/requests\";\nimport PencilIcon from \"../svg-icons/pencilIcon\";\nimport ClipboardIcon from \"../svg-icons/clipboardIcon\";\nimport CheckIcon from \"../svg-icons/checkIcon\";\nimport { showDonationLink } from \"../utils/configUtils\";\nimport { FetchResponse, logRequest } from \"../../maze-utils/src/background-request-proxy\";\n\nexport const YourWorkComponent = () => {\n    const [isSettingUsername, setIsSettingUsername] = React.useState(false);\n    const [username, setUsername] = React.useState(\"\");\n    const [newUsername, setNewUsername] = React.useState(\"\");\n    const [usernameSubmissionStatus, setUsernameSubmissionStatus] = React.useState(\"\");\n    const [submissionCount, setSubmissionCount] = React.useState(\"\");\n    const [viewCount, setViewCount] = React.useState(0);\n    const [minutesSaved, setMinutesSaved] = React.useState(0);\n    const [showDonateMessage, setShowDonateMessage] = React.useState(false);\n\n    React.useEffect(() => {\n        (async () => {\n            const values = [\"userName\", \"viewCount\", \"minutesSaved\", \"vip\", \"permissions\", \"segmentCount\"];\n            let result: FetchResponse;\n            try {\n                result = await asyncRequestToServer(\"GET\", \"/api/userInfo\", {\n                    publicUserID: await getHash(Config.config!.userID!),\n                    values\n                });\n            } catch (e) {\n                console.error(\"[SB] Caught error while fetching user info\", e);\n                return\n            }\n\n            if (result.ok) {\n                const userInfo = JSON.parse(result.responseText);\n                setUsername(userInfo.userName);\n                setSubmissionCount(Math.max(Config.config.sponsorTimesContributed ?? 0, userInfo.segmentCount).toLocaleString());\n                setViewCount(userInfo.viewCount);\n                setMinutesSaved(userInfo.minutesSaved);\n\n                if (username === \"sponege\") {\n                    Config.config.prideTheme = true;\n                }\n\n                Config.config!.isVip = userInfo.vip;\n                Config.config!.permissions = userInfo.permissions;\n\n                setShowDonateMessage(Config.config.showDonationLink && Config.config.donateClicked <= 0 && Config.config.showPopupDonationCount < 5\n                    && viewCount < 50000 && !Config.config.isVip && Config.config.skipCount > 10 && showDonationLink());\n            } else {\n                logRequest(result, \"SB\", \"user info\");\n            }\n        })();\n    }, []);\n\n    return (\n        <div className=\"sbYourWorkBox\">\n            <h2 className=\"sbHeader\" style={{ \"padding\": \"8px 15px\" }}>\n                {chrome.i18n.getMessage(\"yourWork\")}\n            </h2>\n            <div className=\"sbYourWorkCols\">\n                {/* Username */}\n                <div id=\"usernameElement\">\n                    <p className=\"u-mZ grey-text\">\n                        {chrome.i18n.getMessage(\"Username\")}:\n                        {/* loading/errors */}\n                        <span id=\"setUsernameStatus\" \n                            className={`u-mZ white-text${!usernameSubmissionStatus ? \" hidden\" : \"\"}`}>\n                            {usernameSubmissionStatus}\n                        </span>\n                    </p>\n                    <div id=\"setUsernameContainer\" className={isSettingUsername ? \" hidden\" : \"\"}>\n                        <p id=\"usernameValue\">{username}</p>\n                        <button id=\"setUsernameButton\" \n                            title={chrome.i18n.getMessage(\"setUsername\")}\n                            onClick={() => {\n                                setNewUsername(username);\n                                setIsSettingUsername(!isSettingUsername);\n                            }}>\n                            <PencilIcon id=\"sbPopupIconEdit\" className=\"sbPopupButton\" />\n                        </button>\n                        <button id=\"copyUserID\" \n                            title={chrome.i18n.getMessage(\"copyPublicID\")}\n                            onClick={async () => {\n                                window.navigator.clipboard.writeText(await getHash(Config.config!.userID!));\n                            }}>\n                            <ClipboardIcon id=\"sbPopupIconCopyUserID\" className=\"sbPopupButton\" />\n                        </button>\n                    </div>\n                    <div id=\"setUsername\" className={!isSettingUsername ? \" hidden\" : \" SBExpanded\"}>\n                        <input id=\"usernameInput\" \n                            placeholder={chrome.i18n.getMessage(\"Username\")}\n                            value={newUsername}\n                            onChange={(e) => {\n                                setNewUsername(e.target.value);\n                            }}/>\n                        <button id=\"submitUsername\"\n                            onClick={() => {\n                                if (newUsername.length > 0) {\n                                    setUsernameSubmissionStatus(chrome.i18n.getMessage(\"Loading\"));\n                                    asyncRequestToServer(\"POST\", `/api/setUsername?userID=${Config.config!.userID}&username=${newUsername}`)\n                                    .then((result) => {\n                                        if (result.ok) {\n                                            setUsernameSubmissionStatus(\"\");\n                                            setUsername(newUsername);\n                                            setIsSettingUsername(!isSettingUsername);\n                                        } else {\n                                            logRequest(result, \"SB\", \"username change\");\n                                            setUsernameSubmissionStatus(getShortErrorMessage(result.status, result.responseText));\n                                        }\n                                    }).catch((e) => {\n                                        console.error(\"[SB] Caught error while requesting a username change\", e)\n                                        setUsernameSubmissionStatus(formatJSErrorMessage(e));\n                                    });\n                                }\n                            }}>\n                            <CheckIcon id=\"sbPopupIconCheck\" className=\"sbPopupButton\" />\n                        </button>\n                    </div>\n                </div>\n                <SubmissionCounts\n                    isSettingUsername={isSettingUsername}\n                    submissionCount={submissionCount}\n                />\n            </div>\n\n            <TimeSavedMessage\n                viewCount={viewCount}\n                minutesSaved={minutesSaved}\n            />\n\n            {showDonateMessage && <DonateMessage onClose={() => {\n                setShowDonateMessage(false);\n                Config.config.showPopupDonationCount = 100;\n            }} />}\n\n        </div>\n    );\n};\n\nfunction SubmissionCounts(props: { isSettingUsername: boolean; submissionCount: string }): JSX.Element {\n    return <>\n        <div id=\"sponsorTimesContributionsContainer\" className={props.isSettingUsername ? \" hidden\" : \"\"}>\n            <p className=\"u-mZ grey-text\">\n                {chrome.i18n.getMessage(\"Submissions\")}:\n            </p>\n            <p id=\"sponsorTimesContributionsDisplay\" className=\"u-mZ\">{props.submissionCount}</p>\n        </div>\n    </>\n}\n\nfunction TimeSavedMessage({ viewCount, minutesSaved }: { viewCount: number; minutesSaved: number }): JSX.Element {\n    return (\n        <>\n            {\n                viewCount > 0 &&\n                <p id=\"sponsorTimesViewsContainer\" className=\"u-mZ sbStatsSentence\">\n                    {chrome.i18n.getMessage(\"savedPeopleFrom\")}\n                    <b>\n                        <span id=\"sponsorTimesViewsDisplay\">{viewCount.toLocaleString()}</span>{\" \"}\n                    </b>\n                    <span id=\"sponsorTimesViewsDisplayEndWord\">{viewCount !== 1 ? chrome.i18n.getMessage(\"Segments\") : chrome.i18n.getMessage(\"Segment\")}</span>\n                    <br />\n                    <span className=\"sbExtraInfo\">\n                        {\"(\"}{\" \"}\n                        <b>\n                            <span id=\"sponsorTimesOthersTimeSavedDisplay\">{getFormattedHours(minutesSaved)}</span>{\" \"}\n                            <span id=\"sponsorTimesOthersTimeSavedEndWord\">{minutesSaved !== 1 ? chrome.i18n.getMessage(\"minsLower\") : chrome.i18n.getMessage(\"minLower\")}</span>{\" \"}\n                        </b>\n                        <span>{chrome.i18n.getMessage(\"youHaveSavedTimeEnd\")}</span>{\" \"}\n                        {\" )\"}\n                    </span>\n                </p>\n            }\n            <p id=\"sponsorTimesSkipsDoneContainer\" className=\"u-mZ sbStatsSentence\">\n                {chrome.i18n.getMessage(\"youHaveSkipped\")}\n                <b>\n                    <span id=\"sponsorTimesSkipsDoneDisplay\">{Config.config.skipCount}</span>{\" \"}\n                </b>\n                <span id=\"sponsorTimesSkipsDoneEndWord\">{Config.config.skipCount > 1 ? chrome.i18n.getMessage(\"Segments\") : chrome.i18n.getMessage(\"Segment\")}</span>{\" \"}\n                <span className=\"sbExtraInfo\">\n                    {\"(\"}{\" \"}\n                    <b>\n                        <span id=\"sponsorTimeSavedDisplay\">{getFormattedHours(Config.config.minutesSaved)}</span>{\" \"}\n                        <span id=\"sponsorTimeSavedEndWord\">{Config.config.minutesSaved !== 1 ? chrome.i18n.getMessage(\"minsLower\") : chrome.i18n.getMessage(\"minLower\")}</span>{\" \"}\n                    </b>\n                    {\")\"}\n                </span>\n            </p>\n        </>\n    );\n}\n\nfunction DonateMessage(props: { onClose: () => void }): JSX.Element {\n    return (\n        <div id=\"sponsorTimesDonateContainer\" style={{ alignItems: \"center\", justifyContent: \"center\", display: \"flex\" }}>\n            <img className=\"sbHeart\" src=\"/icons/heart.svg\" alt=\"Heart icon\" />\n            <a id=\"sbConsiderDonateLink\" href=\"https://sponsor.ajay.app/donate\" target=\"_blank\" rel=\"noreferrer\" onClick={() => {\n                Config.config.donateClicked = Config.config.donateClicked + 1;\n            }}>\n                {chrome.i18n.getMessage(\"considerDonating\")}\n            </a>\n            <img id=\"sbCloseDonate\" src=\"/icons/close.png\" alt={chrome.i18n.getMessage(\"closeIcon\")} height=\"8\" style={{ paddingLeft: \"5px\", cursor: \"pointer\" }} onClick={props.onClose} />\n        </div>\n    );\n}\n\n/**\n * Converts time in minutes to 2d 5h 25.1\n * If less than 1 hour, just returns minutes\n *\n * @param {float} minutes\n * @returns {string}\n */\nfunction getFormattedHours(minutes) {\n    minutes = Math.round(minutes * 10) / 10;\n    const years = Math.floor(minutes / 525600); // Assumes 365.0 days in a year\n    const days = Math.floor(minutes / 1440) % 365;\n    const hours = Math.floor(minutes / 60) % 24;\n    return (years > 0 ? years + chrome.i18n.getMessage(\"yearAbbreviation\") + \" \" : \"\") + (days > 0 ? days + chrome.i18n.getMessage(\"dayAbbreviation\") + \" \" : \"\") + (hours > 0 ? hours + chrome.i18n.getMessage(\"hourAbbreviation\") + \" \" : \"\") + (minutes % 60).toFixed(1);\n}\n"
  },
  {
    "path": "src/popup/popup.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport { PopupComponent } from \"./PopupComponent\";\nimport { waitFor } from \"../../maze-utils/src\";\nimport Config from \"../config\";\n\n\ndocument.addEventListener(\"DOMContentLoaded\", async () => {\n    await waitFor(() => Config.isReady());\n\n    const root = createRoot(document.body);\n    root.render(<PopupComponent/>);\n})"
  },
  {
    "path": "src/popup/popupUtils.ts",
    "content": "import { Message, MessageResponse } from \"../messageTypes\";\n\nexport function copyToClipboardPopup(text: string, sendMessage: (request: Message) => Promise<MessageResponse>): void {\n    if (window === window.top) {\n        window.navigator.clipboard.writeText(text);\n    } else {\n        sendMessage({\n            message: \"copyToClipboard\",\n            text\n        });\n    }\n}"
  },
  {
    "path": "src/render/AdvancedSkipOptions.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot } from 'react-dom/client';\n\nimport { AdvancedSkipOptionsComponent } from \"../components/options/AdvancedSkipOptionsComponent\";\n\nclass AdvancedSkipOptions {\n    constructor(element: Element) {\n        const root = createRoot(element);\n        root.render(\n            <AdvancedSkipOptionsComponent />\n        );\n    }\n}\n\nexport default AdvancedSkipOptions;"
  },
  {
    "path": "src/render/CategoryChooser.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot } from 'react-dom/client';\n\nimport { CategoryChooserComponent } from \"../components/options/CategoryChooserComponent\";\n\nclass CategoryChooser {\n\n    constructor(element: Element) {\n        const root = createRoot(element);\n        root.render(\n            <CategoryChooserComponent />\n        );\n    }\n}\n\nexport default CategoryChooser;"
  },
  {
    "path": "src/render/CategoryPill.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot, Root } from \"react-dom/client\";\nimport CategoryPillComponent, { CategoryPillState } from \"../components/CategoryPillComponent\";\nimport Config from \"../config\";\nimport { VoteResponse } from \"../messageTypes\";\nimport { Category, SegmentUUID, SponsorTime } from \"../types\";\nimport { Tooltip } from \"./Tooltip\";\nimport { waitFor } from \"../../maze-utils/src\";\nimport { getYouTubeTitleNode } from \"../../maze-utils/src/elements\";\nimport { addCleanupListener } from \"../../maze-utils/src/cleanup\";\n\nconst id = \"categoryPill\";\n\nexport class CategoryPill {\n    container: HTMLElement;\n    ref: React.RefObject<CategoryPillComponent>;\n    root: Root;\n\n    lastState: CategoryPillState;\n\n    mutationObserver?: MutationObserver;\n    onMobileYouTube: boolean;\n    onInvidious: boolean;\n    vote: (type: number, UUID: SegmentUUID, category?: Category) => Promise<VoteResponse>;\n    \n    constructor() {\n        this.ref = React.createRef();\n\n        addCleanupListener(() => {\n            if (this.mutationObserver) {\n                this.mutationObserver.disconnect();\n            }\n        });\n    }\n\n    async attachToPage(onMobileYouTube: boolean, onInvidious: boolean,\n            vote: (type: number, UUID: SegmentUUID, category?: Category) => Promise<VoteResponse>): Promise<void> {\n        this.onMobileYouTube = onMobileYouTube;\n        this.onInvidious = onInvidious;\n        this.vote = vote;\n\n        this.attachToPageInternal();\n    }\n\n    private async attachToPageInternal(): Promise<void> {\n        let referenceNode =\n            await waitFor(() => getYouTubeTitleNode());\n\n        // Experimental YouTube layout with description on right\n        const isOnDescriptionOnRightLayout = document.querySelector(\"#title #description\");\n        if (isOnDescriptionOnRightLayout) {\n            referenceNode = referenceNode.parentElement;\n        }\n\n        if (referenceNode && !referenceNode.contains(this.container)) {\n            if (!this.container) {\n                this.container = document.createElement('span');\n                this.container.id = id;\n                this.container.style.display = \"relative\";\n\n                this.root = createRoot(this.container);\n                this.ref = React.createRef();\n                this.root.render(<CategoryPillComponent \n                        ref={this.ref}\n                        vote={this.vote} \n                        showTextByDefault={!this.onMobileYouTube}\n                        showTooltipOnClick={this.onMobileYouTube} />);\n\n                if (this.onMobileYouTube) {\n                    if (this.mutationObserver) {\n                        this.mutationObserver.disconnect();\n                    }\n                    \n                    this.mutationObserver = new MutationObserver((changes) => {\n                        if (changes.some((change) => change.removedNodes.length > 0)) {\n                            this.attachToPageInternal();\n                        }\n                    });\n    \n                    this.mutationObserver.observe(referenceNode, { \n                        childList: true,\n                        subtree: true\n                    });\n                }\n            }\n\n            if (this.lastState) {\n                waitFor(() => this.ref.current).then(() => {\n                    this.ref.current?.setState(this.lastState);\n                });\n            }\n\n            // Use a parent because YouTube does weird things to the top level object\n            // react would have to rerender if container was the top level\n            const parent = document.createElement(\"span\");\n            parent.id = \"categoryPillParent\";\n            parent.appendChild(this.container);\n\n            referenceNode.prepend(parent);\n            if (!isOnDescriptionOnRightLayout) {\n                referenceNode.style.display = \"flex\";\n            }\n        }\n    }\n\n    close(): void {\n        this.root.unmount();\n        this.container.remove();\n    }\n\n    setVisibility(show: boolean): void {\n        const newState = {\n            show,\n            open: show ? this.ref.current?.state.open : false\n        };\n\n        this.ref.current?.setState(newState);\n        this.lastState = newState;\n    }\n\n    async setSegment(segment: SponsorTime): Promise<void> {\n        await waitFor(() => this.ref.current);\n\n        if (this.ref.current?.state?.segment !== segment || !this.ref.current?.state?.show) {\n            const newState = {\n                segment,\n                show: true,\n                open: false\n            };\n\n            this.ref.current?.setState(newState);\n            this.lastState = newState;\n\n            if (!Config.config.categoryPillUpdate) {\n                Config.config.categoryPillUpdate = true;\n\n                const watchDiv = await waitFor(() => document.querySelector(\"#info.ytd-watch-flexy\") as HTMLElement);\n                if (watchDiv) {\n                    new Tooltip({\n                        text: chrome.i18n.getMessage(\"categoryPillNewFeature\"),\n                        link: \"https://blog.ajay.app/full-video-sponsorblock\",\n                        referenceNode: watchDiv,\n                        prependElement: watchDiv.firstChild as HTMLElement,\n                        bottomOffset: \"-10px\",\n                        opacity: 0.95,\n                        timeout: 50000\n                    });\n                }\n            }\n        }\n\n        if (this.onMobileYouTube && !document.contains(this.container)) {\n            this.attachToPageInternal();\n        }\n    }\n}"
  },
  {
    "path": "src/render/ChapterVote.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot, Root } from 'react-dom/client';\nimport ChapterVoteComponent, { ChapterVoteState } from \"../components/ChapterVoteComponent\";\nimport { VoteResponse } from \"../messageTypes\";\nimport { Category, SegmentUUID, SponsorTime } from \"../types\";\n\nexport class ChapterVote {\n    container: HTMLElement;\n    ref: React.RefObject<ChapterVoteComponent>;\n    root: Root;\n\n    unsavedState: ChapterVoteState;\n\n    mutationObserver?: MutationObserver;\n    \n    constructor(vote: (type: number, UUID: SegmentUUID, category?: Category) => Promise<VoteResponse>) {\n        this.ref = React.createRef();\n\n        this.container = document.createElement('span');\n        this.container.id = \"chapterVote\";\n        this.container.style.height = \"100%\";\n\n        if (document.location.host === \"tv.youtube.com\") {\n            this.container.style.lineHeight = \"initial\";\n        }\n\n        this.root = createRoot(this.container);\n        this.root.render(<ChapterVoteComponent ref={this.ref} vote={vote} />);\n    }\n\n    getContainer(): HTMLElement {\n        return this.container;\n    }\n\n    close(): void {\n        this.root.unmount();\n        this.container.remove();\n    }\n\n    setVisibility(show: boolean): void {\n        const newState = {\n            show,\n            ...(!show ? { segment: null } : {})\n        };\n\n        if (this.ref.current) {\n            this.ref.current?.setState(newState);\n        } else {\n            this.unsavedState = newState;\n        }\n    }\n\n    async setSegment(segment: SponsorTime): Promise<void> {\n        if (this.ref.current?.state?.segment !== segment) {\n            const newState = {\n                segment,\n                show: true\n            };\n\n            if (this.ref.current) {\n                this.ref.current?.setState(newState);\n            } else {\n                this.unsavedState = newState;\n            }\n        }\n    }\n}"
  },
  {
    "path": "src/render/GenericNotice.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot, Root } from 'react-dom/client';\nimport NoticeComponent from \"../components/NoticeComponent\";\n\nimport Utils from \"../utils\";\nconst utils = new Utils();\n\nimport { ContentContainer } from \"../types\";\nimport NoticeTextSelectionComponent from \"../components/NoticeTextSectionComponent\";\nimport { ButtonListener } from \"../../maze-utils/src/components/component-types\";\nimport { getVideo } from \"../../maze-utils/src/video\";\n\nexport interface TextBox {\n    icon: string;\n    text: string;\n}\n\nexport interface NoticeOptions {\n    title: string;\n    referenceNode?: HTMLElement;\n    textBoxes?: TextBox[];\n    buttons?: ButtonListener[];\n    fadeIn?: boolean;\n    timed?: boolean;\n    style?: React.CSSProperties;\n    extraClass?: string;\n    maxCountdownTime?: () => number;\n    dontPauseCountdown?: boolean;\n    hideLogo?: boolean;\n    hideRightInfo?: boolean;\n}\n\nexport default class GenericNotice {\n    // Contains functions and variables from the content script needed by the skip notice\n    contentContainer: ContentContainer;\n\n    noticeElement: HTMLDivElement;\n    noticeRef: React.MutableRefObject<NoticeComponent>;\n    idSuffix: string;\n    root: Root;\n\n    constructor(contentContainer: ContentContainer, idSuffix: string, options: NoticeOptions) {\n        this.noticeRef = React.createRef();\n        this.idSuffix = idSuffix;\n\n        this.contentContainer = contentContainer;\n\n        const referenceNode = options.referenceNode ?? utils.findReferenceNode();\n    \n        this.noticeElement = document.createElement(\"div\");\n        this.noticeElement.className = \"sponsorSkipNoticeContainer\";\n        this.noticeElement.id = \"sponsorSkipNoticeContainer\" + idSuffix;\n\n        referenceNode.prepend(this.noticeElement);\n\n        this.root = createRoot(this.noticeElement);\n\n        this.update(options);\n    }\n\n    update(options: NoticeOptions): void {\n        this.root.render(\n            <NoticeComponent\n                noticeTitle={options.title}\n                idSuffix={this.idSuffix}\n                fadeIn={options.fadeIn ?? true}\n                timed={options.timed ?? true}\n                ref={this.noticeRef}\n                style={options.style}\n                extraClass={options.extraClass}\n                maxCountdownTime={options.maxCountdownTime}\n                dontPauseCountdown={options.dontPauseCountdown}\n                hideLogo={options.hideLogo}\n                hideRightInfo={options.hideRightInfo}\n                closeListener={() => this.close()} >\n                    {options.textBoxes?.length > 0 ?\n                        <tr id={\"sponsorSkipNoticeMiddleRow\" + this.idSuffix}\n                            className=\"sponsorTimeMessagesRow\"\n                            style={{maxHeight: getVideo() ? (getVideo().offsetHeight - 200) + \"px\" : null}}>\n                            <td style={{width: \"100%\"}}>\n                                {this.getMessageBoxes(this.idSuffix, options.textBoxes)}\n                            </td>\n                        </tr>\n                    : null}\n\n                    {!options.hideLogo ?\n                        <>\n                            <tr id={\"sponsorSkipNoticeSpacer\" + this.idSuffix}\n                                className=\"sponsorBlockSpacer\">\n                            </tr>\n\n                            <tr className=\"sponsorSkipNoticeRightSection\"\n                                style={{position: \"relative\"}}>\n                                <td>\n                                    {this.getButtons(options.buttons)}\n                                </td>\n                            </tr>\n                        </>\n                    : null}\n\n            </NoticeComponent>\n        );\n    }\n\n    getMessageBoxes(idSuffix: string, textBoxes: TextBox[]): JSX.Element[] { \n        if (textBoxes) {\n            const result = [];\n            for (let i = 0; i < textBoxes.length; i++) {\n                result.push(\n                    <NoticeTextSelectionComponent idSuffix={idSuffix}\n                        key={i}\n                        icon={textBoxes[i].icon}\n                        text={textBoxes[i].text} />\n                )\n            }\n\n            return result;\n        } else {\n            return null;\n        }\n    }\n\n    getButtons(buttons?: ButtonListener[]): JSX.Element[] {\n        if (buttons) {\n            const result: JSX.Element[] = [];\n\n            for (const button of buttons) {\n                result.push(\n                    <button className=\"sponsorSkipObject sponsorSkipNoticeButton sponsorSkipNoticeRightButton\"\n                            key={button.name}\n                            onClick={(e) => button.listener(e)}>\n\n                            {button.name}\n                    </button>\n                )\n            }\n\n            return result;\n        } else {\n            return null;\n        }\n    }\n\n    close(): void {\n        this.root.unmount();\n\n        this.noticeElement.remove();\n    }\n}"
  },
  {
    "path": "src/render/RectangleTooltip.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot, Root } from 'react-dom/client';\n\nexport interface RectangleTooltipProps {\n    text: string; \n    link?: string;\n    referenceNode: HTMLElement;\n    prependElement?: HTMLElement; // Element to append before\n    bottomOffset?: string;\n    leftOffset?: string;\n    timeout?: number;\n    htmlId?: string;\n    maxHeight?: string;\n    maxWidth?: string;\n    backgroundColor?: string;\n    fontSize?: string;\n    buttonFunction?: () => void;\n}\n\nexport class RectangleTooltip {\n    text: string;   \n    container: HTMLDivElement;\n    root: Root;\n    timer: NodeJS.Timeout;\n    \n    constructor(props: RectangleTooltipProps) {\n        props.bottomOffset ??= \"0px\";\n        props.leftOffset ??= \"0px\";\n        props.maxHeight ??= \"100px\";\n        props.maxWidth ??= \"300px\";\n        props.backgroundColor ??= \"rgba(28, 28, 28, 0.7)\";\n        this.text = props.text;\n        props.fontSize ??= \"10px\";\n\n        this.container = document.createElement('div');\n        props.htmlId ??= \"sponsorRectangleTooltip\" + props.text;\n        this.container.id = props.htmlId;\n        this.container.style.display = \"relative\";\n\n        if (props.prependElement) {\n            props.referenceNode.insertBefore(this.container, props.prependElement);\n        } else {\n            props.referenceNode.appendChild(this.container);\n        }\n\n        if (props.timeout) {\n            this.timer = setTimeout(() => this.close(), props.timeout * 1000);\n        }\n\n        this.root = createRoot(this.container);\n        this.root.render(\n            <div style={{\n                bottom: props.bottomOffset, \n                left: props.leftOffset,\n                maxHeight: props.maxHeight,\n                maxWidth: props.maxWidth,\n                backgroundColor: props.backgroundColor,\n                fontSize: props.fontSize}} \n                    className=\"sponsorBlockRectangleTooltip\" >\n                    <div>\n                        <img className=\"sponsorSkipLogo sponsorSkipObject\"\n                            src={chrome.runtime.getURL(\"icons/IconSponsorBlocker256px.png\")}>\n                        </img>\n                        <span className=\"sponsorSkipObject\">\n                            {this.text + (props.link ? \". \" : \"\")}\n                            {props.link ? \n                                <a style={{textDecoration: \"underline\"}} \n                                        target=\"_blank\"\n                                        rel=\"noopener noreferrer\"\n                                        href={props.link}>\n                                    {chrome.i18n.getMessage(\"LearnMore\")}\n                                    </a> \n                            : null}\n                        </span>\n                    </div>\n                    <button className=\"sponsorSkipObject sponsorSkipNoticeButton\"\n                        style ={{float: \"right\" }}\n                        onClick={() => {\n                            if (props.buttonFunction) props.buttonFunction();\n                            this.close();\n                        }}>\n\n                        {chrome.i18n.getMessage(\"GotIt\")}\n                    </button>\n            </div>\n        )\n    }\n\n    close(): void {\n        this.root.unmount();\n        this.container.remove();\n\n        if (this.timer) clearTimeout(this.timer);\n    }\n}"
  },
  {
    "path": "src/render/SkipNotice.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot, Root } from 'react-dom/client';\n\nimport Utils from \"../utils\";\nconst utils = new Utils();\n\nimport SkipNoticeComponent from \"../components/SkipNoticeComponent\";\nimport { SponsorTime, ContentContainer, NoticeVisibilityMode } from \"../types\";\nimport Config from \"../config\";\nimport { SkipNoticeAction } from \"../utils/noticeUtils\";\n\nclass SkipNotice {\n    segments: SponsorTime[];\n    autoSkip: boolean;\n    // Contains functions and variables from the content script needed by the skip notice\n    contentContainer: ContentContainer;\n\n    noticeElement: HTMLDivElement;\n\n    skipNoticeRef: React.MutableRefObject<SkipNoticeComponent>;\n    root: Root;\n\n    constructor(segments: SponsorTime[], autoSkip = false, contentContainer: ContentContainer, componentDidMount: () => void, unskipTime: number = null, startReskip = false, upcomingNoticeShown: boolean, voteNotice = false) {\n        this.skipNoticeRef = React.createRef();\n\n        this.segments = segments;\n        this.autoSkip = autoSkip;\n        this.contentContainer = contentContainer;\n\n        const referenceNode = utils.findReferenceNode();\n    \n        const amountOfPreviousNotices = document.getElementsByClassName(\"sponsorSkipNotice\").length;\n        //this is the suffix added at the end of every id\n        let idSuffix = \"\";\n        for (const segment of this.segments) {\n            idSuffix += segment.UUID;\n        }\n        idSuffix += amountOfPreviousNotices;\n\n        this.noticeElement = document.createElement(\"div\");\n        this.noticeElement.className = \"sponsorSkipNoticeContainer\";\n        this.noticeElement.id = \"sponsorSkipNoticeContainer\" + idSuffix;\n\n        referenceNode.prepend(this.noticeElement);\n        this.root = createRoot(this.noticeElement);\n        this.root.render(\n            <SkipNoticeComponent segments={segments} \n                autoSkip={autoSkip} \n                startReskip={startReskip}\n                voteNotice={voteNotice}\n                contentContainer={contentContainer}\n                ref={this.skipNoticeRef}\n                closeListener={() => this.close()}\n                smaller={!voteNotice && (Config.config.noticeVisibilityMode >= NoticeVisibilityMode.MiniForAll \n                    || (Config.config.noticeVisibilityMode >= NoticeVisibilityMode.MiniForAutoSkip && autoSkip))}\n                fadeIn={!upcomingNoticeShown && !voteNotice}\n                unskipTime={unskipTime}\n                componentDidMount={componentDidMount} />\n        );\n    }\n\n    setShowKeybindHint(value: boolean): void {\n        this.skipNoticeRef?.current?.setState({\n            showKeybindHint: value\n        });\n    }\n\n    close(): void {\n        this.root.unmount();\n\n        this.noticeElement.remove();\n\n        const skipNotices = this.contentContainer().skipNotices;\n        skipNotices.splice(skipNotices.indexOf(this), 1);\n    }\n\n    toggleSkip(): void {\n        this.skipNoticeRef?.current?.prepAction(SkipNoticeAction.Unskip0);\n    }\n\n    unmutedListener(time: number): void {\n        this.skipNoticeRef?.current?.unmutedListener(time);\n    }\n\n    async waitForSkipNoticeRef(): Promise<SkipNoticeComponent> {\n        const waitForRef = () => new Promise<SkipNoticeComponent>((resolve) => {\n            const observer = new MutationObserver(() => {\n            if (this.skipNoticeRef.current) {\n                observer.disconnect();\n                resolve(this.skipNoticeRef.current);\n            }\n            });\n\n            observer.observe(document.getElementsByClassName(\"sponsorSkipNoticeContainer\")[0], { childList: true, subtree: true});\n\n            if (this.skipNoticeRef.current) {\n            observer.disconnect();\n            resolve(this.skipNoticeRef.current);\n            }\n        });\n\n        return this.skipNoticeRef?.current || await waitForRef();\n    }\n}\n\nexport default SkipNotice;"
  },
  {
    "path": "src/render/SubmissionNotice.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot, Root } from 'react-dom/client';\n\nimport Utils from \"../utils\";\nconst utils = new Utils();\n\nimport SubmissionNoticeComponent from \"../components/SubmissionNoticeComponent\";\nimport { ContentContainer } from \"../types\";\n\nclass SubmissionNotice {\n    // Contains functions and variables from the content script needed by the skip notice\n    contentContainer: () => unknown;\n\n    callback: () => Promise<boolean>;\n\n    noticeRef: React.MutableRefObject<SubmissionNoticeComponent>;\n\n    noticeElement: HTMLDivElement;\n\n    root: Root;\n\n    constructor(contentContainer: ContentContainer, callback: () => Promise<boolean>) {\n        this.noticeRef = React.createRef();\n\n        this.contentContainer = contentContainer;\n        this.callback = callback;\n\n        const referenceNode = utils.findReferenceNode();\n    \n        this.noticeElement = document.createElement(\"div\");\n        this.noticeElement.id = \"submissionNoticeContainer\";\n\n        referenceNode.prepend(this.noticeElement);\n\n        this.root = createRoot(this.noticeElement);\n        this.root.render(\n            <SubmissionNoticeComponent\n                contentContainer={contentContainer}\n                callback={callback} \n                ref={this.noticeRef}\n                closeListener={() => this.close(false)} />\n        );\n    }\n\n    update(): void {\n        this.noticeRef.current.forceUpdate();\n    }\n\n    close(callRef = true): void {\n        if (callRef) this.noticeRef.current.cancel();\n        this.root.unmount();\n\n        this.noticeElement.remove();\n    }\n\n    submit(): void {\n        this.noticeRef.current?.submit?.();\n    }\n\n    scrollToBottom(): void {\n        this.noticeRef.current?.scrollToBottom?.();\n    }\n}\n\nexport default SubmissionNotice;"
  },
  {
    "path": "src/render/Tooltip.tsx",
    "content": "import { GenericTooltip, TooltipProps } from \"../../maze-utils/src/components/Tooltip\";\n\nexport class Tooltip extends GenericTooltip {\n    constructor(props: TooltipProps) {\n        super(props, \"icons/IconSponsorBlocker256px.png\")\n    }\n}"
  },
  {
    "path": "src/render/UnsubmittedVideos.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot } from 'react-dom/client';\nimport UnsubmittedVideosComponent from \"../components/options/UnsubmittedVideosComponent\";\n\nclass UnsubmittedVideos {\n\n    ref: React.RefObject<UnsubmittedVideosComponent>;\n\n    constructor(element: Element) {\n        this.ref = React.createRef();\n\n        const root = createRoot(element);\n        root.render(\n            <UnsubmittedVideosComponent ref={this.ref} />\n        );\n    }\n\n    update(): void {\n        this.ref.current?.forceUpdate();\n    }\n\n}\n\nexport default UnsubmittedVideos;\n"
  },
  {
    "path": "src/render/UpcomingNotice.tsx",
    "content": "import * as React from \"react\";\nimport { createRoot, Root } from \"react-dom/client\";\nimport { ContentContainer, SponsorTime } from \"../types\";\n\nimport Utils from \"../utils\";\nimport SkipNoticeComponent from \"../components/SkipNoticeComponent\";\nconst utils = new Utils();\n\nclass UpcomingNotice {\n    segments: SponsorTime[];\n    // Contains functions and variables from the content script needed by the skip notice\n    contentContainer: ContentContainer;\n\n    noticeElement: HTMLDivElement;\n\n    upcomingNoticeRef: React.MutableRefObject<SkipNoticeComponent>;\n    root: Root;\n\n    closed = false;\n\n    constructor(segments: SponsorTime[], contentContainer: ContentContainer, timeLeft: number, autoSkip: boolean) {\n        this.upcomingNoticeRef = React.createRef();\n\n        this.segments = segments;\n        this.contentContainer = contentContainer;\n\n        const referenceNode = utils.findReferenceNode();\n\n        this.noticeElement = document.createElement(\"div\");\n        this.noticeElement.className = \"sponsorSkipNoticeContainer\";\n\n        referenceNode.prepend(this.noticeElement);\n\n        this.root = createRoot(this.noticeElement);\n        this.root.render(\n            <SkipNoticeComponent segments={segments} \n                autoSkip={autoSkip} \n                upcomingNotice={true}\n                contentContainer={contentContainer}\n                ref={this.upcomingNoticeRef}\n                closeListener={() => this.close()}\n                smaller={true}\n                fadeIn={true}\n                maxCountdownTime={timeLeft} />\n        );\n    }\n\n    close(): void {\n        this.root.unmount();\n        this.noticeElement.remove();\n\n        this.closed = true;\n    }\n\n    sameNotice(segments: SponsorTime[]): boolean {\n        if (segments.length !== this.segments.length) return false;\n\n        for (let i = 0; i < segments.length; i++) {\n            if (segments[i].UUID !== this.segments[i].UUID) return false;\n        }\n\n        return true;\n    }\n}\n\nexport default UpcomingNotice;"
  },
  {
    "path": "src/svg-icons/checkIcon.tsx",
    "content": "import * as React from \"react\";\n\nexport interface CheckIconProps {\n  id?: string;\n  style?: React.CSSProperties;\n  className?: string;\n  onClick?: () => void;\n}\n\nconst CheckIcon = ({\n  id = \"\",\n  className = \"\",\n  style = {},\n  onClick\n}: CheckIconProps): JSX.Element => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 24 24\"\n    className={className}\n    style={style}\n    id={id}\n    onClick={onClick} >\n    <path d=\"M20.3 2L9 13.6l-5.3-5L0 12.3 9 21 24 5.7z\"/>\n  </svg>\n);\n\nexport default CheckIcon;"
  },
  {
    "path": "src/svg-icons/clipboardIcon.tsx",
    "content": "import * as React from \"react\";\n\nexport interface ClipboardIconProps {\n  id?: string;\n  style?: React.CSSProperties;\n  className?: string;\n  onClick?: () => void;\n}\n\nconst ClipboardIcon = ({\n  id = \"\",\n  className = \"\",\n  style = {},\n  onClick\n}: ClipboardIconProps): JSX.Element => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 24 24\"\n    className={className}\n    style={style}\n    id={id}\n    onClick={onClick} >\n    <path d=\"M0 0h24v24H0z\" fill=\"none\" />\n    <path d=\"M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm-1 4l6 6v10c0 1.1-.9 2-2 2H7.99C6.89 23 6 22.1 6 21l.01-14c0-1.1.89-2 1.99-2h7zm-1 7h5.5L14 6.5V12z\" />\n  </svg>\n);\n\nexport default ClipboardIcon;"
  },
  {
    "path": "src/svg-icons/lock_svg.tsx",
    "content": "import * as React from \"react\";\n\nconst lockSvg = ({\n  fill = \"#fcba03\",\n  className = \"\",\n  width = \"20\",\n  height = \"20\",\n  onClick\n}): JSX.Element => (\n  <svg \n    xmlns=\"http://www.w3.org/2000/svg\" \n    height={width}\n    width={height}\n    className={className}\n    fill={fill}\n    onClick={onClick} >\n    <path \n      d=\"M5.5 18q-.625 0-1.062-.438Q4 17.125 4 16.5v-8q0-.625.438-1.062Q4.875 7 5.5 7H6V5q0-1.667 1.167-2.833Q8.333 1 10 1q1.667 0 2.833 1.167Q14 3.333 14 5v2h.5q.625 0 1.062.438Q16 7.875 16 8.5v8q0 .625-.438 1.062Q15.125 18 14.5 18Zm4.5-4q.625 0 1.062-.438.438-.437.438-1.062t-.438-1.062Q10.625 11 10 11t-1.062.438Q8.5 11.875 8.5 12.5t.438 1.062Q9.375 14 10 14ZM7.5 7h5V5q0-1.042-.729-1.771Q11.042 2.5 10 2.5q-1.042 0-1.771.729Q7.5 3.958 7.5 5Z\"/>\n  </svg>\n);\n\nexport default lockSvg;\n"
  },
  {
    "path": "src/svg-icons/pencilIcon.tsx",
    "content": "import * as React from \"react\";\n\nexport interface PencilIconProps {\n  id?: string;\n  style?: React.CSSProperties;\n  className?: string;\n  onClick?: () => void;\n}\n\nconst PencilIcon = ({\n  id = \"\",\n  className = \"\",\n  style = {},\n  onClick\n}: PencilIconProps): JSX.Element => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 24 24\"\n    className={className}\n    style={style}\n    id={id}\n    onClick={onClick} >\n    <path d=\"M14.1 7.1l2.9 2.9L6.1 20.7l-3.6.7.7-3.6L14.1 7.1zm0-2.8L1.4 16.9 0 24l7.1-1.4L19.8 9.9l-5.7-5.7zm7.1 4.3L24 5.7 18.3 0l-2.8 2.8 5.7 5.7z\"/>\n  </svg>\n);\n\nexport default PencilIcon;"
  },
  {
    "path": "src/svg-icons/pencil_svg.tsx",
    "content": "import * as React from \"react\";\n\nconst pencilSvg = ({\n  fill = \"#ffffff\"\n  }): JSX.Element => (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"18\"\n      height=\"18\"\n      viewBox=\"0 0 24 24\"\n      fill={fill}\n      >\n      <path \n          d=\"M14.1 7.1l2.9 2.9L6.1 20.7l-3.6.7.7-3.6L14.1 7.1zm0-2.8L1.4 16.9 0 24l7.1-1.4L19.8 9.9l-5.7-5.7zm7.1 4.3L24 5.7 18.3 0l-2.8 2.8 5.7 5.7z\"></path>\n    </svg>\n  );\n\nexport default pencilSvg;\n"
  },
  {
    "path": "src/svg-icons/resetIcon.tsx",
    "content": "import * as React from \"react\";\n\nexport interface AddIconProps {\n  style?: React.CSSProperties;\n  className?: string;\n  onClick?: () => void;\n}\n\nconst ResetIcon = ({\n  className = \"\",\n  style = {},\n  onClick\n}: AddIconProps): JSX.Element => (\n  <svg\n    xmlns=\"http://www.w3.org/2000/svg\"\n    viewBox=\"0 0 24 24\"\n    className={className}\n    style={style}\n    onClick={onClick} >\n    <path\n      d=\"M 23.993883,23.993883 H 0.006117 V 0.006117 h 23.987766 z\"\n      fill=\"none\"\n      id=\"path2\"\n      style={{strokeWidth: 0.99949}} />\n    <path\n      d=\"m 3.508834,3.5213414 c -2.1778668,2.1778667 -3.52964574,5.1668007 -3.52964574,8.4861686 0,6.638738 5.37707764,12.000795 12.01581474,12.000795 6.638737,0 12.015814,-5.362057 12.015814,-12.000795 0,-5.6023732 -3.830041,-10.273521 -9.011861,-11.61028034 V 3.5213414 c 3.499607,1.2316209 6.007907,4.5660093 6.007907,8.4861686 0,4.971544 -4.040317,9.011861 -9.01186,9.011861 -4.9715438,0 -9.0118611,-4.040317 -9.0118611,-9.011861 0,-2.4932821 1.0363647,-4.7162068 2.6735186,-6.3383421 L 10.493026,10.505534 V -0.00830443 H -0.02081174 Z\"\n      id=\"path4\"\n      style={{strokeWidth: 1.50198}} />\n  </svg>\n);\n\nexport default ResetIcon;"
  },
  {
    "path": "src/svg-icons/sb_svg.tsx",
    "content": "import * as React from \"react\";\n\nexport interface SbIconProps {\n  id?: string;\n  fill?: string;\n  className?: string;\n  width?: string;\n  height?: string;\n  onClick?: () => void;\n}\n\nexport default function SbSvg({\n  id = \"\",\n  fill = \"#ff0000\",\n  className = \"\",\n  onClick\n}: SbIconProps): JSX.Element {\n  return (\n    <svg \n      xmlns=\"http://www.w3.org/2000/svg\" \n      viewBox=\"0 0 565.15 568\"\n      id={id}\n      className={className}\n      onClick={() => onClick?.() } >\n      <g\n      id=\"Layer_2\"\n      data-name=\"Layer 2\">\n        <g\n          id=\"Layer_1-2\"\n          data-name=\"Layer 1\"\n          style={{\n            fill\n          }}>\n          <path\n            d=\"M282.58,568a65,65,0,0,1-34.14-9.66C95.41,463.94,2.54,300.46,0,121A64.91,64.91,0,0,1,34,62.91a522.56,522.56,0,0,1,497.16,0,64.91,64.91,0,0,1,34,58.12c-2.53,179.43-95.4,342.91-248.42,437.3A65,65,0,0,1,282.58,568Zm0-548.31A502.24,502.24,0,0,0,43.4,80.22a45.27,45.27,0,0,0-23.7,40.53c2.44,172.67,91.81,330,239.07,420.83a46.19,46.19,0,0,0,47.61,0C453.64,450.73,543,293.42,545.45,120.75a45.26,45.26,0,0,0-23.7-40.54A502.26,502.26,0,0,0,282.58,19.69Z\"\n            id=\"path8\"\n            style={{\n              fill\n            }} />\n          <path\n            style={{\n              fill\n            }}\n            d=\"M 284.70508 42.693359 A 479.9 479.9 0 0 0 54.369141 100.41992 A 22.53 22.53 0 0 0 42.669922 120.41992 C 45.069922 290.25992 135.67008 438.63977 270.83008 522.00977 A 22.48 22.48 0 0 0 294.32031 522.00977 C 429.48031 438.63977 520.08047 290.25992 522.48047 120.41992 A 22.53 22.53 0 0 0 510.7793 100.41992 A 479.9 479.9 0 0 0 284.70508 42.693359 z M 220.41016 145.74023 L 411.2793 255.93945 L 220.41016 366.14062 L 220.41016 145.74023 z \"\n            id=\"path10\" />\n        </g>\n      </g>\n      <polygon style={{\n          fill: \"#fff\"\n        }}\n        points=\"411.28 255.94 220.41 145.74 220.41 366.14 411.28 255.94\"\n      />\n    </svg>\n  );\n}"
  },
  {
    "path": "src/svg-icons/thumbs_down_svg.tsx",
    "content": "import * as React from \"react\";\n\nconst thumbsDownSvg = ({\n  fill = \"#ffffff\",\n  className = \"\",\n  width = \"18\",\n  height = \"18\"\n  }): JSX.Element => (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width={width}\n      height={height}\n      fill={fill}\n      className={className}\n      viewBox=\"0 0 24 24\"\n      >\n      <path\n          fill=\"none\"\n          d=\"M0 0h24v24H0z\">\n      </path>\n      <path\n          d=\"M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z\"\n      ></path>\n    </svg>\n  );\n\nexport default thumbsDownSvg;\n"
  },
  {
    "path": "src/svg-icons/thumbs_up_svg.tsx",
    "content": "import * as React from \"react\";\n\nconst thumbsUpSvg = ({\n  fill = \"#ffffff\",\n  className = \"\",\n  width = \"18\",\n  height = \"18\"\n  }): JSX.Element => (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      fill={fill}\n      width={width}\n      height={height}\n      className={className}\n      viewBox=\"0 0 24 24\"\n      >\n      <path\n        fill=\"none\"\n        d=\"M0 0h24v24H0V0z\"></path>\n      <path\n          d=\"M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z\"\n      ></path>\n    </svg>\n  );\n\nexport default thumbsUpSvg;\n"
  },
  {
    "path": "src/types.ts",
    "content": "import SubmissionNotice from \"./render/SubmissionNotice\";\nimport SkipNoticeComponent from \"./components/SkipNoticeComponent\";\nimport SkipNotice from \"./render/SkipNotice\";\n\nexport interface ContentContainer {\n    (): {\n        vote: (type: number, UUID: SegmentUUID, category?: Category, skipNotice?: SkipNoticeComponent) => void;\n        dontShowNoticeAgain: () => void;\n        unskipSponsorTime: (segment: SponsorTime, unskipTime: number, forceSeek?: boolean, voteNotice?: boolean) => void;\n        sponsorTimes: SponsorTime[];\n        sponsorTimesSubmitting: SponsorTime[];\n        skipNotices: SkipNotice[];\n        sponsorVideoID;\n        reskipSponsorTime: (segment: SponsorTime, forceSeek?: boolean) => void;\n        updatePreviewBar: () => void;\n        onMobileYouTube: boolean;\n        sponsorSubmissionNotice: SubmissionNotice;\n        resetSponsorSubmissionNotice: (callRef?: boolean) => void;\n        updateEditButtonsOnPlayer: () => void;\n        previewTime: (time: number, unpause?: boolean) => void;\n        videoInfo: VideoInfo;\n        getRealCurrentTime: () => number;\n        lockedCategories: string[];\n        channelIDInfo: ChannelIDInfo;\n    };\n}\n\nexport interface VideoDurationResponse {\n    duration: number;\n}\n\nexport enum CategorySkipOption {\n    FallbackToDefault = -2,\n    Disabled = -1,\n    ShowOverlay,\n    ManualSkip,\n    AutoSkip\n}\n\nexport interface CategorySelection {\n    name: Category;\n    option: CategorySkipOption;\n}\n\nexport enum SponsorHideType {\n    Visible = undefined,\n    Downvoted = 1,\n    MinimumDuration,\n    Hidden,\n}\n\nexport enum ActionType {\n    Skip = \"skip\",\n    Mute = \"mute\",\n    Chapter = \"chapter\",\n    Full = \"full\",\n    Poi = \"poi\"\n}\n\nexport const ActionTypes = [\n    ActionType.Skip,\n    ActionType.Mute,\n    ActionType.Chapter,\n    ActionType.Full,\n    ActionType.Poi\n];\n\nexport type SegmentUUID = string  & { __segmentUUIDBrand: unknown };\nexport type Category = string & { __categoryBrand: unknown };\n\nexport enum SponsorSourceType {\n    Server = undefined,\n    Local = 1,\n    YouTube = 2,\n    Autogenerated = 3\n}\n\nexport interface SegmentContainer {\n    segment: [number] | [number, number];\n}\n\nexport interface SponsorTime extends SegmentContainer {\n    UUID: SegmentUUID;\n    locked?: number;\n\n    category: Category;\n    actionType: ActionType;\n    description?: string;\n\n    hidden?: SponsorHideType;\n    source: SponsorSourceType;\n    videoDuration?: number;\n}\n\nexport interface ScheduledTime extends SponsorTime {\n    scheduledTime: number;\n}\n\nexport interface PreviewBarOption {\n    color: string;\n    opacity: string;\n}\n\n\nexport interface Registration {\n    message: string;\n    id: string;\n    allFrames: boolean;\n    js: string[];\n    css: string[];\n    matches: string[];\n}\n\nexport interface BackgroundScriptContainer {\n    registerFirefoxContentScript: (opts: Registration) => void;\n    unregisterFirefoxContentScript: (id: string) => void;\n}\n\nexport interface VideoInfo {\n    responseContext: {\n        serviceTrackingParams: Array<{service: string; params: Array<{key: string; value: string}>}>;\n        webResponseContextExtensionData: {\n            hasDecorated: boolean;\n        };\n    };\n    playabilityStatus: {\n        status: string;\n        playableInEmbed: boolean;\n        miniplayer: {\n            miniplayerRenderer: {\n                playbackMode: string;\n            };\n        };\n    };\n    streamingData: unknown;\n    playbackTracking: unknown;\n    videoDetails: {\n        videoId: string;\n        title: string;\n        lengthSeconds: string;\n        keywords: string[];\n        channelId: string;\n        isOwnerViewing: boolean;\n        shortDescription: string;\n        isCrawlable: boolean;\n        thumbnail: {\n            thumbnails: Array<{url: string; width: number; height: number}>;\n        };\n        averageRating: number;\n        allowRatings: boolean;\n        viewCount: string;\n        author: string;\n        isPrivate: boolean;\n        isUnpluggedCorpus: boolean;\n        isLiveContent: boolean;\n    };\n    playerConfig: unknown;\n    storyboards: unknown;\n    microformat: {\n        playerMicroformatRenderer: {\n            thumbnail: {\n                thumbnails: Array<{url: string; width: number; height: number}>;\n            };\n            embed: {\n                iframeUrl: string;\n                flashUrl: string;\n                width: number;\n                height: number;\n                flashSecureUrl: string;\n            };\n            title: {\n                simpleText: string;\n            };\n            description: {\n                simpleText: string;\n            };\n            lengthSeconds: string;\n            ownerProfileUrl: string;\n            externalChannelId: string;\n            availableCountries: string[];\n            isUnlisted: boolean;\n            hasYpcMetadata: boolean;\n            viewCount: string;\n            category: Category;\n            publishDate: string;\n            ownerChannelName: string;\n            uploadDate: string;\n        };\n    };\n    trackingParams: string;\n    attestation: unknown;\n    messages: unknown;\n}\n\nexport type VideoID = string;\n\nexport type UnEncodedSegmentTimes = [string, SponsorTime[]][];\n\nexport enum ChannelIDStatus {\n    Fetching,\n    Found,\n    Failed\n}\n\nexport interface ChannelIDInfo {\n    id: string;\n    status: ChannelIDStatus;\n}\n\nexport interface SkipToTimeParams {\n    v: HTMLVideoElement; \n    skipTime: number[]; \n    skippingSegments: SponsorTime[]; \n    openNotice: boolean; \n    forceAutoSkip?: boolean;\n    unskipTime?: number;\n}\n\nexport interface ToggleSkippable {\n    toggleSkip: () => void;\n    setShowKeybindHint: (show: boolean) => void;\n}\n\nexport enum NoticeVisibilityMode {\n    FullSize = 0,\n    MiniForAutoSkip = 1,\n    MiniForAll = 2,\n    FadedForAutoSkip = 3,\n    FadedForAll = 4\n}\n\nexport enum SegmentListDefaultTab {\n    Segments,\n    Chapters,\n}"
  },
  {
    "path": "src/utils/arrayUtils.ts",
    "content": "export function partition<T>(array: T[], filter: (element: T) => boolean): [T[], T[]] {\n  const pass = [], fail = [];\n  array.forEach((element) => (filter(element) ? pass : fail).push(element));\n  \n  return [pass, fail];\n}"
  },
  {
    "path": "src/utils/categoryUtils.ts",
    "content": "import { ActionType, Category, SponsorTime } from \"../types\";\n\nexport function getSkippingText(segments: SponsorTime[], autoSkip: boolean): string {\n    const categoryName = chrome.i18n.getMessage(segments.length > 1 ? \"multipleSegments\" \n        : \"category_\" + segments[0].category + \"_short\") || chrome.i18n.getMessage(\"category_\" + segments[0].category);\n    if (autoSkip) {\n        let messageId = \"\";\n        switch (segments[0].actionType) {\n            case ActionType.Chapter:\n            case ActionType.Skip:\n                messageId = \"skipped\";\n                break;\n            case ActionType.Mute:\n                messageId = \"muted\";\n                break;\n            case ActionType.Poi:\n                messageId = \"skipped_to_category\";\n                break;\n        }\n            \n        return chrome.i18n.getMessage(messageId).replace(\"{0}\", categoryName);\n    } else {\n        let messageId = \"\";\n        switch (segments[0].actionType) {\n            case ActionType.Chapter:\n            case ActionType.Skip:\n                messageId = \"skip_category\";\n                break;\n            case ActionType.Mute:\n                messageId = \"mute_category\";\n                break;\n            case ActionType.Poi:\n                messageId = \"skip_to_category\";\n                break;\n        }\n\n        return chrome.i18n.getMessage(messageId).replace(\"{0}\", categoryName);\n    }\n}\n\nexport function getUpcomingText(segments: SponsorTime[]): string {\n    const categoryName = chrome.i18n.getMessage(segments.length > 1 ? \"multipleSegments\" \n        : \"category_\" + segments[0].category + \"_short\") || chrome.i18n.getMessage(\"category_\" + segments[0].category);\n        \n    const messageId = \"upcoming\";\n    return chrome.i18n.getMessage(messageId).replace(\"{0}\", categoryName);\n}\n\nexport function getVoteText(segments: SponsorTime[]): string {\n    const categoryName = chrome.i18n.getMessage(segments.length > 1 ? \"multipleSegments\" \n        : \"category_\" + segments[0].category + \"_short\") || chrome.i18n.getMessage(\"category_\" + segments[0].category);\n        \n    const messageId = \"voted_on\";\n    return chrome.i18n.getMessage(messageId).replace(\"{0}\", categoryName);\n}\n\n\nexport function getCategorySuffix(category: Category): string {\n    if (category.startsWith(\"poi_\")) {\n        return \"_POI\";\n    } else if (category === \"exclusive_access\") {\n        return \"_full\";\n    } else if (category === \"chapter\") {\n        return \"_chapter\";\n    } else {\n        return \"\";\n    }\n}\n\nexport function shortCategoryName(categoryName: string): string {\n    return chrome.i18n.getMessage(\"category_\" + categoryName + \"_short\") || chrome.i18n.getMessage(\"category_\" + categoryName);\n}\nexport const DEFAULT_CATEGORY = \"chooseACategory\";"
  },
  {
    "path": "src/utils/compatibility.ts",
    "content": "import Config from \"../config\";\n\nexport function runCompatibilityChecks() {\n    if (Config.config.showZoomToFillError2 && document.URL.includes(\"watch?v=\")) {\n        setTimeout(() => {\n            const zoomToFill = document.querySelector(\".zoomtofillBtn\");\n    \n            if (zoomToFill) {\n                alert(chrome.i18n.getMessage(\"zoomToFillUnsupported\"));\n            }\n\n            Config.config.showZoomToFillError2 = false;\n        }, 10000);\n    }\n}\n\nexport function isVorapisInstalled() {\n    return document.querySelector(`.v3`);\n}"
  },
  {
    "path": "src/utils/configUtils.ts",
    "content": "import Config from \"../config\";\n\nexport function showDonationLink(): boolean {\n    return navigator.vendor !== \"Apple Computer, Inc.\" && Config.config.showDonationLink;\n}"
  },
  {
    "path": "src/utils/constants.ts",
    "content": "import { TextBox } from \"../render/GenericNotice\";\nimport { Category } from \"../types\";\n\nexport function getGuidelineInfo(category: Category): TextBox[] {\n    switch (category) {\n        case \"sponsor\":\n            return [{\n                icon: \"icons/money.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/close-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/segway.png\",\n                text: chrome.i18n.getMessage(`generic_guideline1`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n        case \"selfpromo\":\n            return [{\n                icon: \"icons/money.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/campaign.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/close-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline3`)\n            }, {\n                icon: \"icons/segway.png\",\n                text: chrome.i18n.getMessage(`generic_guideline1`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n        case \"exclusive_access\":\n            return [{\n                icon: \"icons/money.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }];\n        case \"interaction\":\n            return [{\n                icon: \"icons/lightbulb.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/lightbulb.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/close-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline3`)\n            }, {\n                icon: \"icons/segway.png\",\n                text: chrome.i18n.getMessage(`generic_guideline1`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n        case \"intro\":\n            return [{\n                icon: \"icons/check-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/close-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/segway.png\",\n                text: chrome.i18n.getMessage(`generic_guideline1`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n        case \"outro\":\n            return [{\n                icon: \"icons/close-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/segway.png\",\n                text: chrome.i18n.getMessage(`generic_guideline1`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n        case \"preview\":\n            return [{\n                icon: \"icons/check-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/check-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/close-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline3`)\n            }, {\n                icon: \"icons/segway.png\",\n                text: chrome.i18n.getMessage(`generic_guideline1`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n        case \"hook\":\n            return [{\n                icon: \"icons/campaign.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/check-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/close-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline3`)\n            }, {\n                icon: \"icons/segway.png\",\n                text: chrome.i18n.getMessage(`generic_guideline1`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n        case \"filler\":\n            return [{\n                icon: \"icons/stopwatch.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/stopwatch.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/close-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline3`)\n            }, {\n                icon: \"icons/segway.png\",\n                text: chrome.i18n.getMessage(`generic_guideline1`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n        case \"music_offtopic\":\n            return [{\n                icon: \"icons/music-note.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/music-note.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n        case \"poi_highlight\":\n            return [{\n                icon: \"icons/star.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/bolt.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/bolt.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline3`)\n            }];\n        case \"chapter\":\n            return [{\n                icon: \"icons/close-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline1`)\n            }, {\n                icon: \"icons/check-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline2`)\n            }, {\n                icon: \"icons/check-smaller.svg\",\n                text: chrome.i18n.getMessage(`category_${category}_guideline3`)\n            }];\n        default:\n            return [{\n                icon: \"icons/segway.png\",\n                text: chrome.i18n.getMessage(`generic_guideline1`)\n            }, {\n                icon: \"icons/right-arrow.svg\",\n                text: chrome.i18n.getMessage(`generic_guideline2`)\n            }];\n    }\n}\n\nexport const defaultPreviewTime = 2;"
  },
  {
    "path": "src/utils/crossExtension.ts",
    "content": "import * as CompileConfig from \"../../config.json\";\n\nimport Config from \"../config\";\nimport { isSafari } from \"../../maze-utils/src/config\";\nimport { isFirefoxOrSafari } from \"../../maze-utils/src\";\n\nexport function isDeArrowInstalled(): Promise<boolean> {\n    if (Config.config.deArrowInstalled) {\n        return Promise.resolve(true);\n    } else {\n        return new Promise((resolve) => {\n            const extensionIds = getExtensionIdsToImportFrom();\n\n            let count = 0;\n            for (const id of extensionIds) {\n                chrome.runtime.sendMessage(id, { message: \"isInstalled\" }, (response) => {\n                    if (chrome.runtime.lastError) {\n                        count++;\n\n                        if (count === extensionIds.length) {\n                            resolve(false);\n                        }\n                        return;\n                    }\n\n                    resolve(response);\n                    if (response) {\n                        Config.config.deArrowInstalled = true;\n                    }\n                });\n            }\n        });\n    }\n}\n\nexport function getExtensionIdsToImportFrom(): string[] {\n    if (isSafari()) {\n        return CompileConfig.extensionImportList.safari;\n    } else if (isFirefoxOrSafari()) {\n        return CompileConfig.extensionImportList.firefox;\n    } else {\n        return CompileConfig.extensionImportList.chromium;\n    }\n}"
  },
  {
    "path": "src/utils/exporter.ts",
    "content": "import { ActionType, Category, SegmentUUID, SponsorSourceType, SponsorTime } from \"../types\";\nimport { shortCategoryName } from \"./categoryUtils\";\nimport * as CompileConfig from \"../../config.json\";\nimport { getFormattedTime, getFormattedTimeToSeconds } from \"../../maze-utils/src/formating\";\nimport { generateUserID } from \"../../maze-utils/src/setup\";\n\nconst inTest = typeof chrome === \"undefined\";\n\nconst chapterNames = CompileConfig.categoryList.filter((code) => code !== \"chapter\")\n    .map((code) => ({\n        code,\n        names: !inTest ? [chrome.i18n.getMessage(\"category_\" + code), shortCategoryName(code)] : [code]\n    }));\n\nexport function exportTimes(segments: SponsorTime[]): string {\n    let result = \"\";\n    for (const segment of segments) {\n        if (![ActionType.Full, ActionType.Mute].includes(segment.actionType)\n                && ![SponsorSourceType.YouTube, SponsorSourceType.Autogenerated].includes(segment.source)) {\n            result += exportTime(segment) + \"\\n\";\n        }\n    }\n\n    return result.replace(/\\n$/, \"\");\n}\n\nfunction exportTime(segment: SponsorTime): string {\n    const name = segment.description || shortCategoryName(segment.category);\n\n    return `${getFormattedTime(segment.segment[0], true)}${\n        segment.segment[1] && segment.segment[0] !== segment.segment[1] \n            ? ` - ${getFormattedTime(segment.segment[1], true)}` : \"\"} ${name}`;\n}\n\nexport function importTimes(data: string, videoDuration: number): SponsorTime[] {\n    const lines = data.split(\"\\n\");\n    const timeRegex = /(?:((?:\\d+:)?\\d+:\\d+)+(?:\\.\\d+)?)|(?:\\d+(?=s| second))/g;\n    const anyLineHasTime = lines.some((line) => timeRegex.test(line));\n\n    const result: SponsorTime[] = [];\n    for (const line of lines) {\n        const match = line.match(timeRegex);\n        if (match) {\n            const startTime = getFormattedTimeToSeconds(match[0]);\n            if (startTime !== null) {\n                // Remove \"seconds\", \"at\", special characters, and \")\" if there was a \"(\"\n                const specialCharMatchers = [{\n                    matcher: /^(?:\\s+seconds?)?[-:()\\s]*|(?:\\s+at)?[-:(\\s]+$/g\n                }, {\n                    matcher: /[-:()\\s]*$/g,\n                    condition: (value) => !!value.match(/^\\s*\\(/)\n                }];\n                const titleLeft = removeIf(line.split(match[0])[0], specialCharMatchers);\n                let titleRight = null;\n                const split2 = line.split(match[1] || match[0]);\n                titleRight = removeIf(split2[split2.length - 1], specialCharMatchers)\n\n                const title = titleLeft?.length > titleRight?.length ? titleLeft : titleRight;\n                const determinedCategory = chapterNames.find(c => c.names.includes(title))?.code as Category;\n\n                const category = title ? (determinedCategory ?? (\"chapter\" as Category)) : \"chooseACategory\" as Category;\n                const segment: SponsorTime = {\n                    segment: [startTime, getFormattedTimeToSeconds(match[1])],\n                    category,\n                    actionType: category === \"chapter\" ? ActionType.Chapter : ActionType.Skip,\n                    description: category === \"chapter\" ? title : null,\n                    source: SponsorSourceType.Local,\n                    UUID: generateUserID() as SegmentUUID\n                };\n\n                if (result.length > 0 && result[result.length - 1].segment[1] === null) {\n                    result[result.length - 1].segment[1] = segment.segment[0];\n                }\n\n                result.push(segment);\n            }\n        } else if (!anyLineHasTime) {\n            // Adding chapters with placeholder times\n            const segment: SponsorTime = {\n                segment: [0, 0],\n                category: \"chapter\" as Category,\n                actionType: ActionType.Chapter,\n                description: line,\n                source: SponsorSourceType.Local,\n                UUID: generateUserID() as SegmentUUID\n            };\n\n            result.push(segment);\n        }\n    }\n\n    if (result.length > 0 && result[result.length - 1].segment[1] === null) {\n        result[result.length - 1].segment[1] = videoDuration;\n    }\n\n    return result;\n}\n\nfunction removeIf(value: string, matchers: Array<{ matcher: RegExp; condition?: (value: string) => boolean }>): string {\n    let result = value;\n    for (const matcher of matchers) {\n        if (!matcher.condition || matcher.condition(value)) {\n            result = result.replace(matcher.matcher, \"\");\n        }\n    }\n\n    return result;\n}\n\nexport function exportTimesAsHashParam(segments: SponsorTime[]): string {\n    const hashparamSegments = segments.map(segment => ({\n        actionType: segment.actionType,\n        category: segment.category,\n        segment: segment.segment,\n        ...(segment.description ? {description: segment.description} : {})  // don't include the description param if empty\n    }));\n\n    return `#segments=${JSON.stringify(hashparamSegments)}`;\n}\n\n\nexport function normalizeChapterName(description: string): string {\n    return description.toLowerCase().replace(/[.:'’`‛‘\"‟”-]/ug, \"\").replace(/\\s+/g, \" \");\n}"
  },
  {
    "path": "src/utils/genericUtils.ts",
    "content": "/* Gets percieved luminance of a color */\nfunction getLuminance(color: string): number {\n    const {r, g, b} = hexToRgb(color);\n    return Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b));\n}\n\n/* Converts hex color to rgb color */\nconst hexChars = \"0123456789abcdef\";\nfunction hexToRgb(hex: string): { r: number; g: number; b: number } | null {\n  if (hex.length == 4)\n    hex = \"#\" + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];\n  return /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex)\n    ? {\n        r: hexChars.indexOf(hex[1]) * 16 + hexChars.indexOf(hex[2]),\n        g: hexChars.indexOf(hex[3]) * 16 + hexChars.indexOf(hex[4]),\n        b: hexChars.indexOf(hex[5]) * 16 + hexChars.indexOf(hex[6]),\n  }: null;\n}\n\n/**\n * List of all indexes that have the specified value\n * https://stackoverflow.com/a/54954694/1985387\n */\nfunction indexesOf<T>(array: T[], value: T): number[] {\n    return array.map((v, i) => v === value ? i : -1).filter(i => i !== -1);\n}\n\nexport const GenericUtils = {\n    getLuminance,\n    indexesOf\n}\n"
  },
  {
    "path": "src/utils/logger.ts",
    "content": "if (typeof (window) !== \"undefined\") {\n    window[\"SBLogs\"] = {\n        debug: [],\n        warn: []\n    };\n}\n\nexport function logDebug(message: string) {\n    if (typeof (window) !== \"undefined\") {\n        window[\"SBLogs\"].debug.push(`[${new Date().toISOString()}] ${message}`);\n    } else {\n        console.log(`[${new Date().toISOString()}] ${message}`)\n    }\n}\n\nexport function logWarn(message: string) {\n    if (typeof (window) !== \"undefined\") {\n        window[\"SBLogs\"].warn.push(`[${new Date().toISOString()}] ${message}`);\n    } else {\n        console.warn(`[${new Date().toISOString()}] ${message}`)\n    }\n}"
  },
  {
    "path": "src/utils/mobileUtils.ts",
    "content": "export function isMobileControlsOpen(): boolean {\n    const overlay = document.getElementById(\"player-control-overlay\");\n\n    if (overlay) {\n        return !!overlay?.classList?.contains(\"fadein\");\n    }\n\n    return false;\n}"
  },
  {
    "path": "src/utils/noticeUtils.ts",
    "content": "import Config from \"../config\";\nimport { SponsorTime } from \"../types\";\n\nexport enum SkipNoticeAction {\n    None,\n    Upvote,\n    Downvote,\n    CategoryVote,\n    CopyDownvote,\n    Unskip0,\n    Unskip1\n}\n\nexport function downvoteButtonColor(segments: SponsorTime[], actionState: SkipNoticeAction, downvoteType: SkipNoticeAction): string {\n    // Also used for \"Copy and Downvote\"\n    if (segments?.length > 1) {\n        return (actionState === downvoteType) ? Config.config.colorPalette.red : Config.config.colorPalette.white;\n    } else {\n        // You dont have segment selectors so the lockbutton needs to be colored and cannot be selected.\n        return Config.config.isVip && segments?.[0].locked === 1 ? Config.config.colorPalette.locked : Config.config.colorPalette.white;\n    }\n}"
  },
  {
    "path": "src/utils/pageCleaner.ts",
    "content": "export function cleanPage() {\n    // For live-updates\n    if (document.readyState === \"complete\") {\n        for (const element of document.querySelectorAll(\"#categoryPillParent, .playerButton, .sponsorThumbnailLabel, #submissionNoticeContainer, .sponsorSkipNoticeContainer, #sponsorBlockPopupContainer, .skipButtonControlBarContainer, #previewbar, .sponsorBlockChapterBar\")) {\n            element.remove();\n        }\n    }\n}"
  },
  {
    "path": "src/utils/pageUtils.ts",
    "content": "import { ActionType, Category, SponsorSourceType, SponsorTime, VideoID } from \"../types\";\nimport { getFormattedTimeToSeconds } from \"../../maze-utils/src/formating\";\nimport { getSkipProfileBool } from \"./skipProfiles\";\n\nexport function getControls(): HTMLElement {\n    const controlsSelectors = [\n        // YouTube\n        \".ytp-right-controls\",\n        // Mobile YouTube\n        \".player-controls-top\",\n        // Invidious/videojs video element's controls element\n        \".vjs-control-bar\",\n        // Piped shaka player\n        \".shaka-bottom-controls\",\n        // Vorapis v3\n        \".html5-player-chrome\",\n        // tv.youtube.com\n        \".ypcs-control-buttons-right\"\n    ];\n\n    for (const controlsSelector of controlsSelectors) {\n        const controls = Array.from(document.querySelectorAll(controlsSelector)).filter(el => !isInPreviewPlayer(el));\n\n        if (controls.length > 0) {\n            return <HTMLElement> controls[controls.length - 1];\n        }\n    }\n\n    return null;\n}\n\nexport function isInPreviewPlayer(element: Element): boolean {\n    return !!element.closest(\"#inline-preview-player\");\n}\n\nexport function isVisible(element: HTMLElement): boolean {\n    return element && element.offsetWidth > 0 && element.offsetHeight > 0;\n}\n\nexport function getHashParams(): Record<string, unknown> {\n    const windowHash = window.location.hash.slice(1);\n    if (windowHash) {\n        const params: Record<string, unknown> = windowHash.split('&').reduce((acc, param) => {\n            const [key, value] = param.split('=');\n            const decoded = decodeURIComponent(value);\n            try {\n                acc[key] = decoded?.match(/{|\\[/) ? JSON.parse(decoded) : value;\n            } catch (e) {\n                console.error(`Failed to parse hash parameter ${key}: ${value}`);\n            }\n\n            return acc;\n        }, {});\n\n        return params;\n    }\n\n    return {};\n}\n\nexport function hasAutogeneratedChapters(): boolean {\n    return !!document.querySelector(\"ytd-engagement-panel-section-list-renderer ytd-macro-markers-list-renderer #menu\");\n}\n\nexport function getExistingChapters(currentVideoID: VideoID, duration: number): SponsorTime[] {\n    const chaptersBox = document.querySelector(\"ytd-macro-markers-list-renderer\");\n    const title = chaptersBox?.closest(\"ytd-engagement-panel-section-list-renderer\")?.querySelector(\"#title-text.ytd-engagement-panel-title-header-renderer\");\n    if (title?.textContent?.includes(\"Key moment\")) return [];\n\n    const autogenerated = hasAutogeneratedChapters();\n    if (!getSkipProfileBool(\"showAutogeneratedChapters\") && autogenerated) return [];\n\n    const chapters: SponsorTime[] = [];\n    // .ytp-timed-markers-container indicates that key-moments are present, which should not be divided\n    if (chaptersBox) {\n        let lastSegment: SponsorTime = null;\n        const links = chaptersBox.querySelectorAll(\"ytd-macro-markers-list-item-renderer > a\");\n        for (const link of links) {\n            const timeElement = link.querySelector(\"#time\") as HTMLElement;\n            const description = link.querySelector(\"#details h4\") as HTMLElement;\n            if (timeElement && description?.innerText?.length > 0 && link.getAttribute(\"href\")?.includes(currentVideoID)) {\n                const time = getFormattedTimeToSeconds(timeElement.innerText.replace(/\\./g, \":\"));\n                if (time === null) return [];\n\n                if (lastSegment) {\n                    lastSegment.segment[1] = time;\n                    chapters.push(lastSegment);\n                }\n\n                lastSegment = {\n                    segment: [time, null],\n                    category: \"chapter\" as Category,\n                    actionType: ActionType.Chapter,\n                    description: description.innerText,\n                    source: autogenerated ? SponsorSourceType.Autogenerated : SponsorSourceType.YouTube,\n                    UUID: null\n                };\n            }\n        }\n\n        if (lastSegment) {\n            lastSegment.segment[1] = duration;\n            chapters.push(lastSegment);\n        }\n    }\n\n    return chapters;\n}\n\nexport function isPlayingPlaylist() {\n    return !!document.URL.includes(\"&list=\");\n}"
  },
  {
    "path": "src/utils/requests.ts",
    "content": "import Config from \"../config\";\nimport * as CompileConfig from \"../../config.json\";\nimport { FetchResponse, sendRequestToCustomServer } from \"../../maze-utils/src/background-request-proxy\";\n\n/**\n * Sends a request to the SponsorBlock server with address added as a query\n * \n * @param type The request type. \"GET\", \"POST\", etc.\n * @param address The address to add to the SponsorBlock server address\n * @param callback \n */    \nexport async function asyncRequestToServer(type: string, address: string, data = {}, headers = {}): Promise<FetchResponse> {\n    const serverAddress = Config.config.testingServer ? CompileConfig.testingServerAddress : Config.config.serverAddress;\n\n    return await (sendRequestToCustomServer(type, serverAddress + address, data, headers));\n}\n"
  },
  {
    "path": "src/utils/segmentData.ts",
    "content": "import { DataCache } from \"../../maze-utils/src/cache\";\nimport { getHash, HashedValue } from \"../../maze-utils/src/hash\";\nimport Config, {  } from \"../config\";\nimport * as CompileConfig from \"../../config.json\";\nimport { ActionTypes, SponsorSourceType, SponsorTime, VideoID } from \"../types\";\nimport { getHashParams } from \"./pageUtils\";\nimport { asyncRequestToServer } from \"./requests\";\nimport { extensionUserAgent } from \"../../maze-utils/src\";\nimport { logRequest, serializeOrStringify } from \"../../maze-utils/src/background-request-proxy\";\n\nconst segmentDataCache = new DataCache<VideoID, SegmentResponse>(() => {\n    return {\n        segments: null,\n        status: 200\n    };\n}, null, 5);\n\nconst pendingList: Record<VideoID, Promise<SegmentResponse>> = {};\n\nexport interface SegmentResponse {\n    segments: SponsorTime[] | null;\n    status: number | Error | string;\n}\n\nexport async function getSegmentsForVideo(videoID: VideoID, ignoreCache: boolean): Promise<SegmentResponse> {\n    if (!ignoreCache) {\n        const cachedData = segmentDataCache.getFromCache(videoID);\n        if (cachedData) {\n            segmentDataCache.cacheUsed(videoID);\n            return cachedData;\n        }\n    }\n\n    if (pendingList[videoID]) {\n        return await pendingList[videoID];\n    }\n\n    const pendingData = fetchSegmentsForVideo(videoID);\n    pendingList[videoID] = pendingData;\n\n    let result: Awaited<typeof pendingData>;\n    try {\n        result = await pendingData;\n    } catch (e) {\n        console.error(\"[SB] Caught error while fetching segments\", e);\n        return {\n            segments: null,\n            status: serializeOrStringify(e),\n        }\n    } finally {\n        delete pendingList[videoID];\n    }\n\n    return result;\n}\n\nasync function fetchSegmentsForVideo(videoID: VideoID): Promise<SegmentResponse> {\n    const extraRequestData: Record<string, unknown> = {};\n    const hashParams = getHashParams();\n    if (hashParams.requiredSegment) extraRequestData.requiredSegment = hashParams.requiredSegment;\n\n    const hashPrefix = (await getHash(videoID, 1)).slice(0, 5) as VideoID & HashedValue;\n    const hasDownvotedSegments = !!Config.local.downvotedSegments[hashPrefix.slice(0, 4)];\n    const response = await asyncRequestToServer('GET', \"/api/skipSegments/\" + hashPrefix, {\n        categories: CompileConfig.categoryList,\n        actionTypes: ActionTypes,\n        trimUUIDs: hasDownvotedSegments ? null : 5,\n        ...extraRequestData\n    }, {\n        \"X-CLIENT-NAME\": extensionUserAgent(),\n    });\n\n    if (response.ok) {\n        const receivedSegments: SponsorTime[] = JSON.parse(response.responseText)\n                    ?.filter((video) => video.videoID === videoID)\n                    ?.map((video) => video.segments)?.[0]\n                    ?.map((segment) => ({\n                        ...segment,\n                        source: SponsorSourceType.Server\n                    }))\n                    ?.sort((a, b) => a.segment[0] - b.segment[0]);\n\n        if (receivedSegments && receivedSegments.length) {\n            const result = {\n                segments: receivedSegments,\n                status: response.status\n            };\n\n            segmentDataCache.setupCache(videoID).segments = result.segments;\n            return result;\n        } else {\n            // Setup with null data\n            segmentDataCache.setupCache(videoID);\n        }\n    } else if (response.status !== 404) {\n        logRequest(response, \"SB\", \"skip segments\");\n    }\n\n    return {\n        segments: null,\n        status: response.status\n    };\n}"
  },
  {
    "path": "src/utils/skipProfiles.ts",
    "content": "import { getChannelIDInfo, getVideoID } from \"../../maze-utils/src/video\";\nimport Config, { ConfigurationID, CustomConfiguration } from \"../config\";\nimport { SponsorHideType, SponsorTime } from \"../types\";\n\nlet currentTabSkipProfile: ConfigurationID = null;\n\nexport function getSkipProfileIDForTime(): ConfigurationID | null {\n    if (Config.local.skipProfileTemp !== null && Config.local.skipProfileTemp.time > Date.now() - 60 * 60 * 1000) {\n        return Config.local.skipProfileTemp.configID;\n    } else {\n        return null;\n    }\n}\n\nexport function getSkipProfileIDForTab(): ConfigurationID | null {\n    return currentTabSkipProfile;\n}\n\nexport function setCurrentTabSkipProfile(configID: ConfigurationID | null) {\n    currentTabSkipProfile = configID ?? null;\n}\n\nexport function getSkipProfileIDForVideo(): ConfigurationID | null {\n    return Config.local.channelSkipProfileIDs[getVideoID()] ?? null;\n}\n\nexport function getSkipProfileIDForChannel(): ConfigurationID | null {\n    const channelInfo = getChannelIDInfo();\n\n    if (!channelInfo) {\n        return null;\n    }\n\n    return Config.local.channelSkipProfileIDs[channelInfo.id]\n        ?? Config.local.channelSkipProfileIDs[channelInfo.author]\n       ?? null;\n}\n\nexport function getSkipProfileID(): ConfigurationID | null {\n    const configID =\n        getSkipProfileIDForTime()\n        ?? getSkipProfileIDForTab()\n        ?? getSkipProfileIDForVideo()\n        ?? getSkipProfileIDForChannel();\n    \n    return configID ?? null;\n}\n\nexport function getSkipProfile(): CustomConfiguration | null {\n    const configID = getSkipProfileID();\n    \n    if (configID) {\n        return Config.local.skipProfiles[configID];\n    }\n\n    return null;\n}\n\ntype SkipProfileBoolKey =\n    \"showAutogeneratedChapters\"\n    | \"autoSkipOnMusicVideos\"\n    | \"skipNonMusicOnlyOnYoutubeMusic\"\n    | \"muteSegments\"\n    | \"fullVideoSegments\"\n    | \"manualSkipOnFullVideo\";\n\nexport function getSkipProfileBool(key: SkipProfileBoolKey): boolean {\n    return getSkipProfileValue<boolean>(key);\n}\n\nexport function getSkipProfileNum(key: \"minDuration\"): number {\n    return getSkipProfileValue<number>(key);\n}\n\nfunction getSkipProfileValue<T>(key: keyof CustomConfiguration): T {\n    const profile = getSkipProfile();\n    if (profile && profile[key] !== null) {\n        return profile[key] as T;\n    }\n\n    return Config.config[key];\n}\n\nexport function hideTooShortSegments(sponsorTimes: SponsorTime[]) {\n    const minDuration = getSkipProfileNum(\"minDuration\");\n\n    if (minDuration !== 0) {\n        for (const segment of sponsorTimes) {\n            const duration = segment.segment[1] - segment.segment[0];\n            if (duration > 0 && duration < minDuration && (segment.hidden === SponsorHideType.Visible || SponsorHideType.MinimumDuration)) {\n                segment.hidden = SponsorHideType.MinimumDuration;\n            } else if (segment.hidden === SponsorHideType.MinimumDuration) {\n                segment.hidden = SponsorHideType.Visible;\n            }\n        }\n    }\n}"
  },
  {
    "path": "src/utils/skipRule.ts",
    "content": "import { getCurrentPageTitle } from \"../../maze-utils/src/elements\";\nimport { getChannelIDInfo, getVideoDuration } from \"../../maze-utils/src/video\";\nimport Config from \"../config\";\nimport {ActionType, ActionTypes, CategorySelection, CategorySkipOption, SponsorSourceType, SponsorTime} from \"../types\";\nimport { getSkipProfile, getSkipProfileBool } from \"./skipProfiles\";\nimport { VideoLabelsCacheData } from \"./videoLabels\";\nimport * as CompileConfig from \"../../config.json\";\nimport { AdvancedSkipCheck, AdvancedSkipPredicate, AdvancedSkipRule, PredicateOperator, SkipRuleAttribute, SkipRuleOperator } from \"./skipRule.type\";\n\nconst SKIP_RULE_ATTRIBUTES = Object.values(SkipRuleAttribute);\nconst SKIP_RULE_OPERATORS = Object.values(SkipRuleOperator);\nconst INVERTED_SKIP_RULE_OPERATORS = {\n    \"<=\": SkipRuleOperator.Greater,\n    \"<\": SkipRuleOperator.GreaterOrEqual,\n    \">=\": SkipRuleOperator.Less,\n    \">\": SkipRuleOperator.LessOrEqual,\n    \"!=\": SkipRuleOperator.Equal,\n    \"==\": SkipRuleOperator.NotEqual,\n    \"!*=\": SkipRuleOperator.Contains,\n    \"*=\": SkipRuleOperator.NotContains,\n    \"!~=\": SkipRuleOperator.Regex,\n    \"~=\": SkipRuleOperator.NotRegex,\n    \"!~i=\": SkipRuleOperator.RegexIgnoreCase,\n    \"~i=\": SkipRuleOperator.NotRegexIgnoreCase,\n};\nconst WORD_EXTRA_CHARACTER = /[a-zA-Z0-9.]/;\nconst OPERATOR_EXTRA_CHARACTER = /[<>=!~*&|-]/;\nconst ANY_EXTRA_CHARACTER = /[a-zA-Z0-9<>=!~*&|.-]/;\n\nexport function getCategorySelection(segment: SponsorTime | VideoLabelsCacheData): CategorySelection {\n    // First check skip rules\n    for (const rule of Config.local.skipRules) {\n        if (isSkipPredicatePassing(segment, rule.predicate)) {\n            return { name: segment.category, option: rule.skipOption } as CategorySelection;\n        }\n    }\n\n    // Action type filters\n    if (\"actionType\" in segment && (segment as SponsorTime).actionType === \"mute\" && !getSkipProfileBool(\"muteSegments\")) {\n        return { name: segment.category, option: CategorySkipOption.Disabled } as CategorySelection;\n    }\n\n    // Then check skip profile\n    const profile = getSkipProfile();\n    if (profile) {\n        const profileSelection = profile.categorySelections.find(selection => selection.name === segment.category);\n        if (profileSelection) {\n            return profileSelection;\n        }\n    }\n\n    // Then fallback to default\n    for (const selection of Config.config.categorySelections) {\n        if (selection.name === segment.category) {\n            return selection;\n        }\n    }\n    return { name: segment.category, option: CategorySkipOption.Disabled} as CategorySelection;\n}\n\nfunction getSkipCheckValue(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipCheck): string | number | undefined {\n    switch (rule.attribute) {\n        case SkipRuleAttribute.StartTime:\n            return (segment as SponsorTime).segment?.[0];\n        case SkipRuleAttribute.EndTime:\n            return (segment as SponsorTime).segment?.[1];\n        case SkipRuleAttribute.Duration:\n            return (segment as SponsorTime).segment?.[1] - (segment as SponsorTime).segment?.[0];\n        case SkipRuleAttribute.StartTimePercent: {\n            const startTime = (segment as SponsorTime).segment?.[0];\n            if (startTime === undefined) return undefined;\n\n            return startTime / getVideoDuration() * 100;\n        }\n        case SkipRuleAttribute.EndTimePercent: {\n            const endTime = (segment as SponsorTime).segment?.[1];\n            if (endTime === undefined) return undefined;\n\n            return endTime / getVideoDuration() * 100;\n        }\n        case SkipRuleAttribute.DurationPercent: {\n            const startTime = (segment as SponsorTime).segment?.[0];\n            const endTime = (segment as SponsorTime).segment?.[1];\n            if (startTime === undefined || endTime === undefined) return undefined;\n\n            return (endTime - startTime) / getVideoDuration() * 100;\n        }\n        case SkipRuleAttribute.Category:\n            return segment.category;\n        case SkipRuleAttribute.ActionType:\n            return (segment as SponsorTime).actionType;\n        case SkipRuleAttribute.Description:\n            return (segment as SponsorTime).description || \"\";\n        case SkipRuleAttribute.Source:\n            switch ((segment as SponsorTime).source) {\n                case SponsorSourceType.Local:\n                    return \"local\";\n                case SponsorSourceType.YouTube:\n                    return \"youtube\";\n                case SponsorSourceType.Autogenerated:\n                    return \"autogenerated\";\n                case SponsorSourceType.Server:\n                    return \"server\";\n                default:\n                    return undefined;\n            }\n        case SkipRuleAttribute.ChannelID:\n            return getChannelIDInfo().id;\n        case SkipRuleAttribute.ChannelName:\n            return getChannelIDInfo().author;\n        case SkipRuleAttribute.VideoDuration:\n            return getVideoDuration();\n        case SkipRuleAttribute.Title:\n            return getCurrentPageTitle() || \"\";\n        default:\n            return undefined;\n    }\n}\n\nfunction isSkipCheckPassing(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipCheck): boolean {\n    const value = getSkipCheckValue(segment, rule);\n\n    switch (rule.operator) {\n        case SkipRuleOperator.Less:\n            return typeof value === \"number\" && value < (rule.value as number);\n        case SkipRuleOperator.LessOrEqual:\n            return typeof value === \"number\" && value <= (rule.value as number);\n        case SkipRuleOperator.Greater:\n            return typeof value === \"number\" && value > (rule.value as number);\n        case SkipRuleOperator.GreaterOrEqual:\n            return typeof value === \"number\" && value >= (rule.value as number);\n        case SkipRuleOperator.Equal:\n            return value === rule.value;\n        case SkipRuleOperator.NotEqual:\n            return value !== rule.value;\n        case SkipRuleOperator.Contains:\n            return String(value).toLocaleLowerCase().includes(String(rule.value).toLocaleLowerCase());\n        case SkipRuleOperator.NotContains:\n            return !String(value).toLocaleLowerCase().includes(String(rule.value).toLocaleLowerCase());\n        case SkipRuleOperator.Regex:\n            return new RegExp(rule.value as string).test(String(value));\n        case SkipRuleOperator.RegexIgnoreCase:\n            return new RegExp(rule.value as string, \"i\").test(String(value));\n        case SkipRuleOperator.NotRegex:\n            return !new RegExp(rule.value as string).test(String(value));\n        case SkipRuleOperator.NotRegexIgnoreCase:\n            return !new RegExp(rule.value as string, \"i\").test(String(value));\n        default:\n            return false;\n    }\n}\n\nfunction isSkipPredicatePassing(segment: SponsorTime | VideoLabelsCacheData, predicate: AdvancedSkipPredicate): boolean {\n    if (predicate.kind === \"check\") {\n        return isSkipCheckPassing(segment, predicate as AdvancedSkipCheck);\n    } else { // predicate.kind === \"operator\"\n        // TODO Is recursion fine to use here?\n        if (predicate.operator == PredicateOperator.And) {\n            return isSkipPredicatePassing(segment, predicate.left) && isSkipPredicatePassing(segment, predicate.right);\n        } else { // predicate.operator === PredicateOperator.Or\n            return isSkipPredicatePassing(segment, predicate.left) || isSkipPredicatePassing(segment, predicate.right);\n        }\n    }\n}\n\nexport function getCategoryDefaultSelection(category: string): CategorySelection {\n    for (const selection of Config.config.categorySelections) {\n        if (selection.name === category) {\n            return selection;\n        }\n    }\n    return { name: category, option: CategorySkipOption.Disabled} as CategorySelection;\n}\n\ntype TokenType =\n    | \"if\" // Keywords\n    | \"disabled\" | \"show overlay\" | \"manual skip\" | \"auto skip\" // Skip option\n    | `${SkipRuleAttribute}` // Segment attributes\n    | `${SkipRuleOperator}` // Segment attribute operators\n    | \"and\" | \"or\" | \"not\" // Expression operators\n    | \"(\" | \")\" | \"comment\" // Syntax\n    | \"string\" | \"number\" // Literal values\n    | \"eof\" | \"error\"; // Sentinel and special tokens\n\nexport interface SourcePos {\n    line: number;\n}\n\nexport interface Span {\n    start: SourcePos;\n    end: SourcePos;\n}\n\ninterface Token {\n    type: TokenType;\n    span: Span;\n    value: string;\n}\n\nclass Lexer {\n    private readonly source: string;\n    private start: number;\n    private current: number;\n\n    private start_pos: SourcePos;\n    private current_pos: SourcePos;\n\n    public constructor(source: string) {\n        this.source = source;\n        this.start = 0;\n        this.current = 0;\n        this.start_pos = { line: 1 };\n        this.current_pos = { line: 1 };\n    }\n\n    private makeToken(type: TokenType): Token {\n        return {\n            type,\n            span: { start: this.start_pos, end: this.current_pos, },\n            value: this.source.slice(this.start, this.current),\n        };\n    }\n\n    /**\n     * Returns the UTF-16 value at the current position and advances it forward.\n     * If the end of the source string has been reached, returns <code>null</code>.\n     *\n     * @return current UTF-16 value, or <code>null</code> on EOF\n     */\n    private consume(): string | null {\n        if (this.source.length > this.current) {\n            // The UTF-16 value at the current position, which could be either a Unicode code point or a lone surrogate.\n            // The check above this is also based on the UTF-16 value count, so this should not be able to fail on “weird” inputs.\n            const c = this.source[this.current];\n            this.current++;\n\n            if (c === \"\\n\") {\n                // Cannot use this.current_pos.line++, because SourcePos is mutable and used in tokens without copying\n                this.current_pos = { line: this.current_pos.line + 1, };\n            }\n\n            return c;\n        } else {\n            return null;\n        }\n    }\n\n    /**\n     * Returns the UTF-16 value at the current position without advancing it.\n     * If the end of the source string has been reached, returns <code>null</code>.\n     *\n     * @return current UTF-16 value, or <code>null</code> on EOF\n     */\n    private peek(): string | null {\n        if (this.source.length > this.current) {\n            // See comment in consume() for Unicode expectations here\n            return this.source[this.current];\n        } else {\n            return null;\n        }\n    }\n\n    /**\n     * Checks the word at the current position against a list of\n     * expected keywords. The keyword can consist of multiple characters.\n     * If a match is found, the current position is advanced by the length\n     * of the keyword found.\n     *\n     * @param keywords the expected set of keywords at the current position\n     * @param caseSensitive whether to do a case-sensitive comparison\n     * @return the matching keyword, or <code>null</code>\n     */\n    private expectKeyword(keywords: readonly string[], caseSensitive: boolean): string | null {\n        for (const keyword of keywords) {\n            // slice() clamps to string length, so cannot cause out of bounds errors\n            const actual = this.source.slice(this.current, this.current + keyword.length);\n\n            if (caseSensitive && keyword === actual || !caseSensitive && keyword.toLowerCase() === actual.toLowerCase()) {\n                // Does not handle keywords containing line feeds, which shouldn't happen anyway\n                this.current += keyword.length;\n                return keyword;\n            }\n        }\n\n        return null;\n    }\n\n    /**\n     * Skips a series of whitespace characters starting at the current\n     * position. May advance the current position multiple times, once,\n     * or not at all.\n     */\n    private skipWhitespace() {\n        let c = this.peek();\n        const whitespace = /\\s+/;\n\n        while (c != null) {\n            if (!whitespace.test(c)) {\n                return;\n            }\n\n            this.consume();\n            c = this.peek();\n        }\n    }\n\n    /**\n     * Skips all characters until the next <code>\"\\n\"</code> (line feed)\n     * character occurs (inclusive). Will always advance the current position\n     * at least once.\n     */\n    private skipLine() {\n        let c = this.consume();\n        while (c != null) {\n            if (c == '\\n') {\n                return;\n            }\n\n            c = this.consume();\n        }\n    }\n\n    /**\n     * @return whether the lexer has reached the end of input\n     */\n    private isEof(): boolean {\n        return this.current >= this.source.length;\n    }\n\n    /**\n     * Sets the start position of the next token that will be emitted\n     * to the current position.\n     *\n     * More characters need to be consumed after calling this, as\n     * an empty token would be emitted otherwise.\n     */\n    private resetToCurrent() {\n        this.start = this.current;\n        this.start_pos = this.current_pos;\n    }\n\n    public nextToken(): Token {\n        this.skipWhitespace();\n        this.resetToCurrent();\n\n        if (this.isEof()) {\n            return this.makeToken(\"eof\");\n        }\n\n        const keyword = this.expectKeyword([\n            \"if\", \"and\", \"or\", \"not\",\n            \"(\", \")\",\n            \"//\",\n        ].concat(SKIP_RULE_ATTRIBUTES)\n            .concat(SKIP_RULE_OPERATORS), true);\n        let type: TokenType | null = null;\n        let kind: \"word\" | \"operator\" | null = null;\n\n        if (keyword !== null) {\n            if ((SKIP_RULE_ATTRIBUTES as string[]).includes(keyword)) {\n                kind = \"word\";\n                type = keyword as TokenType;\n            } else if ((SKIP_RULE_OPERATORS as string[]).includes(keyword)) {\n                kind = \"operator\";\n                type = keyword as TokenType;\n            } else {\n                switch (keyword) {\n                    case \"if\":  // Fallthrough\n                    case \"and\": // Fallthrough\n                    case \"or\": // Fallthrough\n                    case \"not\": kind = \"word\"; type = keyword as TokenType; break;\n\n                    case \"(\": return this.makeToken(\"(\");\n                    case \")\": return this.makeToken(\")\");\n\n                    case \"//\":\n                        this.resetToCurrent();\n                        this.skipLine();\n                        return this.makeToken(\"comment\");\n\n                    default:\n                }\n            }\n        } else {\n            const keyword2 = this.expectKeyword(\n                [ \"disabled\", \"show overlay\", \"manual skip\", \"auto skip\" ], false);\n\n            if (keyword2 !== null) {\n                kind = \"word\";\n                type = keyword2 as TokenType;\n            }\n        }\n\n        if (type !== null) {\n            const more = kind == \"operator\" ? OPERATOR_EXTRA_CHARACTER : kind == \"word\" ? WORD_EXTRA_CHARACTER : ANY_EXTRA_CHARACTER;\n\n            let c = this.peek();\n            let error = false;\n            while (c !== null && more.test(c)) {\n                error = true;\n                this.consume();\n                c = this.peek();\n            }\n\n            return this.makeToken(error ? \"error\" : type);\n        }\n\n        let c = this.consume();\n\n        if (c === '\"') {\n            // Parses string according to ECMA-404 2nd edition (JSON), section 9 “String”\n            let output = \"\";\n            let c = this.consume();\n            let error = false;\n\n            while (c !== null && c !== '\"') {\n                if (c == '\\\\') {\n                    c = this.consume();\n\n                    switch (c) {\n                        case '\"':\n                            output = output.concat('\"');\n                            break;\n                        case '\\\\':\n                            output = output.concat('\\\\');\n                            break;\n                        case '/':\n                            output = output.concat('/');\n                            break;\n                        case 'b':\n                            output = output.concat('\\b');\n                            break;\n                        case 'f':\n                            output = output.concat('\\f');\n                            break;\n                        case 'n':\n                            output = output.concat('\\n');\n                            break;\n                        case 'r':\n                            output = output.concat('\\r');\n                            break;\n                        case 't':\n                            output = output.concat('\\t');\n                            break;\n                        case 'u': {\n                            // UTF-16 value sequence\n                            const digits = this.source.slice(this.current, this.current + 4);\n\n                            if (digits.length < 4 || !/[0-9a-zA-Z]{4}/.test(digits)) {\n                                error = true;\n                                output = output.concat(`\\\\u`);\n                                c = this.consume();\n                                continue;\n                            }\n\n                            const value = parseInt(digits, 16);\n                            // fromCharCode() takes a UTF-16 value without performing validity checks,\n                            // which is exactly what is needed here – in JSON, code units outside the\n                            // BMP are represented by two Unicode escape sequences.\n                            output = output.concat(String.fromCharCode(value));\n                            break;\n                        }\n                        default:\n                            error = true;\n                            output = output.concat(`\\\\${c}`);\n                            break;\n                    }\n                } else if (c === '\\n') {\n                    // Unterminated / multi-line string, unsupported\n                    error = true;\n                    // Prevent unterminated strings from consuming the entire rest of the input\n                    break;\n                } else {\n                    output = output.concat(c);\n                }\n\n                c = this.consume();\n            }\n\n            return {\n                type: error || c !== '\"' ? \"error\" : \"string\",\n                span: { start: this.start_pos, end: this.current_pos, },\n                value: output,\n            };\n        } else if (/[0-9-]/.test(c)) {\n            // Parses number according to ECMA-404 2nd edition (JSON), section 8 “Numbers”\n            if (c === '-') {\n                c = this.consume();\n\n                if (!/[0-9]/.test(c)) {\n                    return this.makeToken(\"error\");\n                }\n            }\n\n            const leadingZero = c === '0';\n            let next = this.peek();\n            let error = false;\n\n            while (next !== null && /[0-9]/.test(next)) {\n                this.consume();\n                next = this.peek();\n\n                if (leadingZero) {\n                    error = true;\n                }\n            }\n\n\n            if (next !== null && next === '.') {\n                this.consume();\n                next = this.peek();\n\n                if (next === null || !/[0-9]/.test(next)) {\n                    return this.makeToken(\"error\");\n                }\n\n                do {\n                    this.consume();\n                    next = this.peek();\n                } while (next !== null && /[0-9]/.test(next));\n            }\n\n            next = this.peek();\n\n            if (next != null && (next === 'e' || next === 'E')) {\n                this.consume();\n                next = this.peek();\n\n                if (next === null) {\n                    return this.makeToken(\"error\");\n                }\n\n                if (next === '+' || next === '-') {\n                    this.consume();\n                    next = this.peek();\n                }\n\n                while (next !== null && /[0-9]/.test(next)) {\n                    this.consume();\n                    next = this.peek();\n                }\n            }\n\n            return this.makeToken(error ? \"error\" : \"number\");\n        }\n\n        // Consume common characters up to a space for a more useful value in the error token\n        const common = ANY_EXTRA_CHARACTER;\n        c = this.peek();\n        while (c !== null && common.test(c)) {\n            this.consume();\n            c = this.peek();\n        }\n\n        return this.makeToken(\"error\");\n    }\n}\n\nexport interface ParseError {\n    span: Span;\n    message: string;\n}\n\nclass Parser {\n    private lexer: Lexer;\n\n    private previous: Token;\n    private current: Token;\n\n    private readonly rules: AdvancedSkipRule[];\n    private readonly errors: ParseError[];\n\n    private erroring: boolean;\n    private panicMode: boolean;\n\n    public constructor(lexer: Lexer) {\n        this.lexer = lexer;\n        this.previous = null;\n        this.current = lexer.nextToken();\n        this.rules = [];\n        this.errors = [];\n        this.erroring = false;\n        this.panicMode = false;\n    }\n\n    // Helper functions\n\n    /**\n     * Adds an error message. The current skip rule will be marked as erroring.\n     *\n     * @param span the range of the error\n     * @param message the message to report\n     * @param panic if <code>true</code>, all further errors will be silenced\n     *              until panic mode is disabled again\n     */\n    private errorAt(span: Span, message: string, panic: boolean) {\n        if (!this.panicMode) {\n            this.errors.push({span, message,});\n        }\n\n        this.panicMode ||= panic;\n        this.erroring = true;\n    }\n\n    /**\n     * Adds an error message for an error occurring at the previous token\n     * (which was just consumed).\n     *\n     * @param message the message to report\n     * @param panic if <code>true</code>, all further errors will be silenced\n     *              until panic mode is disabled again\n     */\n    private error(message: string, panic: boolean) {\n        this.errorAt(this.previous.span, message, panic);\n    }\n\n    /**\n     * Adds an error message for an error occurring at the current token\n     * (which has not been consumed yet).\n     *\n     * @param message the message to report\n     * @param panic if <code>true</code>, all further errors will be silenced\n     *              until panic mode is disabled again\n     */\n    private errorAtCurrent(message: string, panic: boolean) {\n        this.errorAt(this.current.span, message, panic);\n    }\n\n    /**\n     * Consumes the current token, which can then be accessed at <code>previous</code>.\n     * The next token will be at <code>current</code> after this call.\n     *\n     * If a token of type <code>error</code> is found, issues an error message.\n     */\n    private consume() {\n        this.previous = this.current;\n        // Intentionally ignoring `error` tokens here;\n        // by handling those in later privates with more context (match(), expect(), ...),\n        // the user gets better errors\n        this.current = this.lexer.nextToken();\n    }\n\n    /**\n     * Checks the current token (that has not been consumed yet) against a set of expected token types.\n     *\n     * @param expected the set of expected token types\n     * @return whether the actual current token matches any expected token type\n     */\n    private match(expected: readonly TokenType[]): boolean {\n        if (expected.includes(this.current.type)) {\n            this.consume();\n            return true;\n        } else {\n            return false;\n        }\n    }\n\n    /**\n     * Checks the current token (that has not been consumed yet) against a set of expected token types.\n     *\n     * If there is no match, issues an error message which will be prepended to <code>, got: <token type></code>.\n     *\n     * @param expected the set of expected token types\n     * @param message the error message to report in case the actual token doesn't match\n     * @param panic if <code>true</code>, all further errors will be silenced\n     *              until panic mode is disabled again\n     */\n    private expect(expected: readonly TokenType[], message: string, panic: boolean) {\n        if (!this.match(expected)) {\n            this.errorAtCurrent(message.concat(this.current.type === \"error\" ?  `, got: ${JSON.stringify(this.current.value)}` : `, got: \\`${this.current.type}\\``), panic);\n        }\n    }\n\n    /**\n     * Synchronize with the next rule block and disable panic mode.\n     * Skips all tokens until the <code>if</code> keyword is found.\n     */\n    private synchronize() {\n        this.panicMode = false;\n\n        while (!this.isEof()) {\n            if (this.current.type === \"if\") {\n                return;\n            }\n\n            this.consume();\n        }\n    }\n\n    /**\n     * @return whether the parser has reached the end of input\n     */\n    private isEof(): boolean {\n        return this.current.type === \"eof\";\n    }\n\n    // Parsing functions\n\n    /**\n     * Parse the config. Should only ever be called once on a given\n     * <code>Parser</code> instance.\n     */\n    public parse(): { rules: AdvancedSkipRule[]; errors: ParseError[] } {\n        while (!this.isEof()) {\n            this.erroring = false;\n            const rule = this.parseRule();\n\n            if (!this.erroring && rule) {\n                this.rules.push(rule);\n            }\n\n            if (this.panicMode) {\n                this.synchronize();\n            }\n        }\n\n        return { rules: this.rules, errors: this.errors, };\n    }\n\n    private parseRule(): AdvancedSkipRule | null {\n        const rule: AdvancedSkipRule = {\n            predicate: null,\n            skipOption: null,\n            comments: [],\n        };\n\n        while (this.match([\"comment\"])) {\n            rule.comments.push(this.previous.value.trim());\n        }\n\n        this.expect([\"if\"], rule.comments.length !== 0 ? \"expected `if` after `comment`\" : \"expected `if`\", true);\n        rule.predicate = this.parsePredicate();\n\n        this.expect([\"disabled\", \"show overlay\", \"manual skip\", \"auto skip\"], \"expected skip option after condition\", true);\n        switch (this.previous.type) {\n            case \"disabled\":\n                rule.skipOption = CategorySkipOption.Disabled;\n                break;\n            case \"show overlay\":\n                rule.skipOption = CategorySkipOption.ShowOverlay;\n                break;\n            case \"manual skip\":\n                rule.skipOption = CategorySkipOption.ManualSkip;\n                break;\n            case \"auto skip\":\n                rule.skipOption = CategorySkipOption.AutoSkip;\n                break;\n            default:\n            // Ignore, should have already errored\n        }\n\n        return rule;\n    }\n\n    private parsePredicate(): AdvancedSkipPredicate | null {\n        return this.parseOr();\n    }\n\n    private parseOr(): AdvancedSkipPredicate | null {\n        let left = this.parseAnd();\n\n        while (this.match([\"or\"])) {\n            const right = this.parseAnd();\n\n            left = {\n                kind: \"operator\",\n                operator: PredicateOperator.Or,\n                left, right,\n            };\n        }\n\n        return left;\n    }\n\n    private parseAnd(): AdvancedSkipPredicate | null {\n        let left = this.parseUnary();\n\n        while (this.match([\"and\"])) {\n            const right = this.parseUnary();\n\n            left = {\n                kind: \"operator\",\n                operator: PredicateOperator.And,\n                left, right,\n            };\n        }\n\n        return left;\n    }\n\n    private parseUnary(): AdvancedSkipPredicate | null {\n        if (this.match([\"not\"])) {\n            const predicate = this.parseUnary();\n            return predicate ? invertPredicate(predicate) : null;\n        }\n\n        return this.parsePrimary();\n    }\n\n    private parsePrimary(): AdvancedSkipPredicate | null {\n        if (this.match([\"(\"])) {\n            const predicate = this.parsePredicate();\n            this.expect([\")\"], \"expected `)` after condition\", true);\n            return predicate;\n        } else {\n            return this.parseCheck();\n        }\n    }\n\n    private parseCheck(): AdvancedSkipCheck | null {\n        this.expect(SKIP_RULE_ATTRIBUTES, `expected attribute after \\`${this.previous.type}\\``, true);\n\n        if (this.erroring) {\n            return null;\n        }\n\n        const attribute = this.previous.type as SkipRuleAttribute;\n        this.expect(SKIP_RULE_OPERATORS, `expected operator after \\`${attribute}\\``, true);\n\n        if (this.erroring) {\n            return null;\n        }\n\n        const operator = this.previous.type as SkipRuleOperator;\n        this.expect([\"string\", \"number\"], `expected string or number after \\`${operator}\\``, true);\n\n        if (this.erroring) {\n            return null;\n        }\n\n        const value = this.previous.type === \"number\" ? Number(this.previous.value) : this.previous.value;\n\n        if ([SkipRuleOperator.Equal, SkipRuleOperator.NotEqual].includes(operator)) {\n            if (attribute === SkipRuleAttribute.Category\n                && !CompileConfig.categoryList.includes(value as string)) {\n                this.error(`unknown category: \\`${value}\\``, false);\n                return null;\n            } else if (attribute === SkipRuleAttribute.ActionType\n                && !ActionTypes.includes(value as ActionType)) {\n                this.error(`unknown action type: \\`${value}\\``, false);\n                return null;\n            } else if (attribute === SkipRuleAttribute.Source\n                && ![\"local\", \"youtube\", \"autogenerated\", \"server\"].includes(value as string)) {\n                this.error(`unknown chapter source: \\`${value}\\``, false);\n                return null;\n            }\n        }\n\n        return {\n            kind: \"check\",\n            attribute, operator, value,\n        };\n    }\n}\n\nexport function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors: ParseError[] } {\n    const parser = new Parser(new Lexer(config));\n    return parser.parse();\n}\n\nexport function configToText(config: AdvancedSkipRule[]): string {\n    let result = \"\";\n\n    for (const rule of config) {\n        for (const comment of rule.comments) {\n            result += \"// \" + comment + \"\\n\";\n        }\n\n        result += \"if \";\n        result += predicateToText(rule.predicate, null);\n\n        switch (rule.skipOption) {\n            case CategorySkipOption.Disabled:\n                result += \"\\nDisabled\";\n                break;\n            case CategorySkipOption.ShowOverlay:\n                result += \"\\nShow Overlay\";\n                break;\n            case CategorySkipOption.ManualSkip:\n                result += \"\\nManual Skip\";\n                break;\n            case CategorySkipOption.AutoSkip:\n                result += \"\\nAuto Skip\";\n                break;\n            default:\n                return null; // Invalid skip option\n        }\n\n        result += \"\\n\\n\";\n    }\n\n    return result.trim();\n}\n\nfunction predicateToText(predicate: AdvancedSkipPredicate, outerPrecedence: \"or\" | \"and\" | \"not\" | null): string {\n    if (predicate.kind === \"check\") {\n        return `${predicate.attribute} ${predicate.operator} ${JSON.stringify(predicate.value)}`;\n    } else if (predicate.displayInverted) {\n        // Should always be fine, considering `not` has the highest precedence\n        return `not ${predicateToText(invertPredicate(predicate), \"not\")}`;\n    } else {\n        let text: string;\n\n        if (predicate.operator === PredicateOperator.And) {\n            text = `${predicateToText(predicate.left, \"and\")} and ${predicateToText(predicate.right, \"and\")}`;\n        } else { // Or\n            text = `${predicateToText(predicate.left, \"or\")} or ${predicateToText(predicate.right, \"or\")}`;\n        }\n\n        return outerPrecedence !== null && outerPrecedence !== predicate.operator ? `(${text})` : text;\n    }\n}\n\nfunction invertPredicate(predicate: AdvancedSkipPredicate): AdvancedSkipPredicate {\n    if (predicate.kind === \"check\") {\n        return {\n            ...predicate,\n            operator: INVERTED_SKIP_RULE_OPERATORS[predicate.operator],\n        };\n    } else {\n        // not (a and b) == (not a or not b)\n        // not (a or b) == (not a and not b)\n        return {\n            kind: \"operator\",\n            operator: predicate.operator === \"and\" ? PredicateOperator.Or : PredicateOperator.And,\n            left: predicate.left ? invertPredicate(predicate.left) : null,\n            right: predicate.right ? invertPredicate(predicate.right) : null,\n            displayInverted: !predicate.displayInverted,\n        };\n    }\n}\n"
  },
  {
    "path": "src/utils/skipRule.type.ts",
    "content": "import type { CategorySkipOption } from \"../types\";\n\nexport interface Permission {\n    canSubmit: boolean;\n}\n\n// Note that attributes that are prefixes of other attributes (like `time.start`) need to be ordered *after*\n// the longer attributes, because these are matched sequentially. Using the longer attribute would otherwise result\n// in an error token.\nexport enum SkipRuleAttribute {\n    StartTimePercent = \"time.startPercent\",\n    StartTime = \"time.start\",\n    EndTimePercent = \"time.endPercent\",\n    EndTime = \"time.end\",\n    DurationPercent = \"time.durationPercent\",\n    Duration = \"time.duration\",\n    Category = \"category\",\n    ActionType = \"actionType\",\n    Description = \"chapter.name\",\n    Source = \"chapter.source\",\n    ChannelID = \"channel.id\",\n    ChannelName = \"channel.name\",\n    VideoDuration = \"video.duration\",\n    Title = \"video.title\"\n}\n\n// Note that operators that are prefixes of other attributes (like `<`) need to be ordered *after* the longer\n// operators, because these are matched sequentially. Using the longer operator would otherwise result\n// in an error token.\nexport enum SkipRuleOperator {\n    LessOrEqual = \"<=\",\n    Less = \"<\",\n    GreaterOrEqual = \">=\",\n    Greater = \">\",\n    NotEqual = \"!=\",\n    Equal = \"==\",\n    NotContains = \"!*=\",\n    Contains = \"*=\",\n    NotRegex = \"!~=\",\n    Regex = \"~=\",\n    NotRegexIgnoreCase = \"!~i=\",\n    RegexIgnoreCase = \"~i=\"\n}\n\nexport interface AdvancedSkipCheck {\n    kind: \"check\";\n    attribute: SkipRuleAttribute;\n    operator: SkipRuleOperator;\n    value: string | number;\n}\n\nexport enum PredicateOperator {\n    And = \"and\",\n    Or = \"or\",\n}\n\nexport interface AdvancedSkipOperator {\n    kind: \"operator\";\n    operator: PredicateOperator;\n    left: AdvancedSkipPredicate;\n    right: AdvancedSkipPredicate;\n    displayInverted?: boolean;\n}\n\nexport type AdvancedSkipPredicate = AdvancedSkipCheck | AdvancedSkipOperator;\n\nexport interface AdvancedSkipRule {\n    predicate: AdvancedSkipPredicate;\n    skipOption: CategorySkipOption;\n    comments: string[];\n}"
  },
  {
    "path": "src/utils/thumbnails.ts",
    "content": "import { extractVideoID, isOnInvidious } from \"../../maze-utils/src/video\";\nimport Config from \"../config\";\nimport { getHasStartSegment, getVideoLabel } from \"./videoLabels\";\nimport { getThumbnailSelector, setThumbnailListener } from \"../../maze-utils/src/thumbnailManagement\";\nimport { VideoID } from \"../types\";\nimport { getSegmentsForVideo } from \"./segmentData\";\nimport { onMobile } from \"../../maze-utils/src/pageInfo\";\n\nexport async function handleThumbnails(thumbnails: HTMLImageElement[]): Promise<void> {\n    await Promise.all(thumbnails.map((t) => {\n        labelThumbnail(t);\n        setupThumbnailHover(t);\n    }));\n}\n\nexport async function labelThumbnail(thumbnail: HTMLImageElement): Promise<HTMLElement | null> {\n    if (!Config.config?.fullVideoSegments || !Config.config?.fullVideoLabelsOnThumbnails) {\n        hideThumbnailLabel(thumbnail);\n        return null;\n    }\n    \n    const videoID = await extractVideoIDFromElement(thumbnail);\n    if (!videoID) {\n        hideThumbnailLabel(thumbnail);\n        return null;\n    }\n\n    const category = await getVideoLabel(videoID);\n    if (!category) {\n        hideThumbnailLabel(thumbnail);\n        return null;\n    }\n\n    const { overlay, text } = createOrGetThumbnail(thumbnail);\n\n    overlay.style.setProperty('--category-color', `var(--sb-category-preview-${category}, var(--sb-category-${category}))`);\n    overlay.style.setProperty('--category-text-color', `var(--sb-category-text-preview-${category}, var(--sb-category-text-${category}))`);\n    text.innerText = chrome.i18n.getMessage(`category_${category}`);\n    overlay.classList.add(\"sponsorThumbnailLabelVisible\");\n\n    return overlay;\n}\n\nexport async function setupThumbnailHover(thumbnail: HTMLImageElement): Promise<void> {\n    // Cache would be reset every load due to no SPA\n    if (isOnInvidious()) return;\n\n    const mainElement = thumbnail.closest(\"#dismissible\") as HTMLElement;\n    if (mainElement) {\n        mainElement.removeEventListener(\"mouseenter\", thumbnailHoverListener);\n        mainElement.addEventListener(\"mouseenter\", thumbnailHoverListener);\n    }\n}\n\nfunction thumbnailHoverListener(e: MouseEvent) {\n    if (!chrome.runtime?.id) return;\n\n    const thumbnail = (e.target as HTMLElement).querySelector(getThumbnailSelector()) as HTMLImageElement;\n    if (!thumbnail) return;\n\n    // Pre-fetch data for this video\n    let fetched = false;\n    const preFetch = async () => {\n        fetched = true;\n        const videoID = await extractVideoIDFromElement(thumbnail);\n        if (videoID && await getHasStartSegment(videoID)) {\n            void getSegmentsForVideo(videoID, false);\n        }\n    };\n    const timeout = setTimeout(preFetch, 100);\n    const onMouseDown = () => {\n        clearTimeout(timeout);\n        if (!fetched) {\n            preFetch();\n        }\n    };\n\n    e.target.addEventListener(\"mousedown\", onMouseDown, { once: true });\n    e.target.addEventListener(\"mouseleave\", () => {\n        clearTimeout(timeout);\n        e.target.removeEventListener(\"mousedown\", onMouseDown);\n    }, { once: true });\n}\n\nfunction getLink(thumbnail: HTMLImageElement): HTMLAnchorElement | null {\n    if (isOnInvidious()) {\n        return thumbnail.parentElement as HTMLAnchorElement | null;\n    } else if (!onMobile()) {\n        const link = thumbnail.querySelector(\"a#thumbnail, a.reel-item-endpoint, a.yt-lockup-metadata-view-model__title, a.yt-lockup-metadata-view-model__title-link, a.yt-lockup-view-model__content-image, a.yt-lockup-metadata-view-model-wiz__title\") as HTMLAnchorElement;\n        if (link) {\n            return link;\n        } else if (thumbnail.nodeName === \"YTD-HERO-PLAYLIST-THUMBNAIL-RENDERER\"\n            || thumbnail.nodeName === \"YT-THUMBNAIL-VIEW-MODEL\"\n        ) {\n            return thumbnail.closest(\"a\") as HTMLAnchorElement;\n        } else {\n            return null;\n        }\n    } else {\n        // Big thumbnails, compact thumbnails, shorts, channel feature, playlist header\n        return thumbnail.querySelector(\"a.media-item-thumbnail-container, a.compact-media-item-image, a.reel-item-endpoint, :scope > a, .amsterdam-playlist-thumbnail-wrapper > a\") as HTMLAnchorElement;\n    }\n}\n\nasync function extractVideoIDFromElement(thumbnail: HTMLImageElement): Promise<VideoID | null> {\n    const link = getLink(thumbnail);\n    if (!link || link.nodeName !== \"A\" || !link.href) return null; // no link found\n\n    return await extractVideoID(link);\n}\n\nfunction getOldThumbnailLabel(thumbnail: HTMLImageElement): HTMLElement | null {\n    return thumbnail.querySelector(\".sponsorThumbnailLabel\") as HTMLElement | null;\n}   \n\nfunction hideThumbnailLabel(thumbnail: HTMLImageElement): void {\n    const oldLabel = getOldThumbnailLabel(thumbnail);\n    if (oldLabel) {\n        oldLabel.classList.remove(\"sponsorThumbnailLabelVisible\");\n    }\n}\n\nfunction createOrGetThumbnail(thumbnail: HTMLImageElement): { overlay: HTMLElement; text: HTMLElement } {\n    const oldElement = getOldThumbnailLabel(thumbnail);\n    if (oldElement) {\n        return {\n            overlay: oldElement as HTMLElement,\n            text: oldElement.querySelector(\"span\") as HTMLElement\n        };\n    }\n\n    const overlay = document.createElement(\"div\") as HTMLElement;\n    overlay.classList.add(\"sponsorThumbnailLabel\");\n    // Disable hover autoplay\n    overlay.addEventListener(\"pointerenter\", (e) => {\n        e.stopPropagation();\n        thumbnail.dispatchEvent(new PointerEvent(\"pointerleave\", { bubbles: true }));\n    });\n    overlay.addEventListener(\"pointerleave\", (e) => {\n        e.stopPropagation();\n        thumbnail.dispatchEvent(new PointerEvent(\"pointerenter\", { bubbles: true }));\n    });\n\n    const icon = createSBIconElement();\n    const text = document.createElement(\"span\");\n    overlay.appendChild(icon);\n    overlay.appendChild(text);\n    thumbnail.appendChild(overlay);\n\n    return {\n        overlay,\n        text\n    };\n}\n\nfunction createSBIconElement(): SVGSVGElement {\n    const svg = document.createElementNS(\"http://www.w3.org/2000/svg\", \"svg\");\n    svg.setAttribute(\"viewBox\", \"0 0 565.15 568\");\n    const use = document.createElementNS(\"http://www.w3.org/2000/svg\", \"use\");\n    use.setAttribute(\"href\", \"#SponsorBlockIcon\");\n    svg.appendChild(use);\n    return svg;\n}\n\n\n// Inserts the icon svg definition, so it can be used elsewhere\nfunction insertSBIconDefinition() {\n    const container = document.createElement(\"span\");\n\n    // svg from /public/icons/PlayerStartIconSponsorBlocker.svg, with useless stuff removed\n    container.innerHTML = `\n<svg viewBox=\"0 0 565.15 568\" style=\"display: none\">\n  <defs>\n    <g id=\"SponsorBlockIcon\">\n      <path d=\"M282.58,568a65,65,0,0,1-34.14-9.66C95.41,463.94,2.54,300.46,0,121A64.91,64.91,0,0,1,34,62.91a522.56,522.56,0,0,1,497.16,0,64.91,64.91,0,0,1,34,58.12c-2.53,179.43-95.4,342.91-248.42,437.3A65,65,0,0,1,282.58,568Zm0-548.31A502.24,502.24,0,0,0,43.4,80.22a45.27,45.27,0,0,0-23.7,40.53c2.44,172.67,91.81,330,239.07,420.83a46.19,46.19,0,0,0,47.61,0C453.64,450.73,543,293.42,545.45,120.75a45.26,45.26,0,0,0-23.7-40.54A502.26,502.26,0,0,0,282.58,19.69Z\"/>\n      <path d=\"M 284.70508 42.693359 A 479.9 479.9 0 0 0 54.369141 100.41992 A 22.53 22.53 0 0 0 42.669922 120.41992 C 45.069922 290.25992 135.67008 438.63977 270.83008 522.00977 A 22.48 22.48 0 0 0 294.32031 522.00977 C 429.48031 438.63977 520.08047 290.25992 522.48047 120.41992 A 22.53 22.53 0 0 0 510.7793 100.41992 A 479.9 479.9 0 0 0 284.70508 42.693359 z M 220.41016 145.74023 L 411.2793 255.93945 L 220.41016 366.14062 L 220.41016 145.74023 z \"/>\n    </g>\n  </defs>\n</svg>`;\n    document.body.appendChild(container.children[0]);\n}\n\nexport function setupThumbnailListener(): void {\n    setThumbnailListener(handleThumbnails, () => {\n        insertSBIconDefinition();\n    }, () => Config.isReady());\n}"
  },
  {
    "path": "src/utils/urlParser.ts",
    "content": "\nexport function getStartTimeFromUrl(url: string): number {\n    const urlParams = new URLSearchParams(url);\n    const time = urlParams?.get('t') || urlParams?.get('time_continue');\n\n    return urlTimeToSeconds(time);\n}\n\nexport function urlTimeToSeconds(time: string): number {\n    if (!time) {\n        return 0;\n    }\n\n    const re = /(?:(\\d{1,3})h)?(?:(\\d{1,2})m)?(\\d+)s?/;\n    const match = re.exec(time);\n\n    if (match) {\n        const hours = parseInt(match[1] ?? '0', 10);\n        const minutes = parseInt(match[2] ?? '0', 10);\n        const seconds = parseInt(match[3] ?? '0', 10);\n\n        return hours * 3600 + minutes * 60 + seconds;\n    } else if (/\\d+/.test(time)) {\n        return parseInt(time, 10);\n    } else {\n        return 0;\n    }\n}"
  },
  {
    "path": "src/utils/videoLabels.ts",
    "content": "import { Category, CategorySkipOption, VideoID } from \"../types\";\nimport { getHash } from \"../../maze-utils/src/hash\";\nimport { logWarn } from \"./logger\";\nimport { asyncRequestToServer } from \"./requests\";\nimport { getCategorySelection } from \"./skipRule\";\nimport { FetchResponse, logRequest } from \"../../maze-utils/src/background-request-proxy\";\n\nexport interface VideoLabelsCacheData {\n    category: Category;\n    hasStartSegment: boolean;\n}\n\nexport interface LabelCacheEntry {\n    timestamp: number;\n    videos: Record<VideoID, VideoLabelsCacheData>;\n}\n\nconst labelCache: Record<string, LabelCacheEntry> = {};\nconst cacheLimit = 1000;\n\nasync function getLabelHashBlock(hashPrefix: string): Promise<LabelCacheEntry | null> {\n    // Check cache\n    const cachedEntry = labelCache[hashPrefix];\n    if (cachedEntry) {\n        return cachedEntry;\n    }\n\n    let response: FetchResponse;\n    try {\n        response = await asyncRequestToServer(\"GET\", `/api/videoLabels/${hashPrefix}?hasStartSegment=true`);\n    } catch (e) {\n        console.error(\"[SB] Caught error while fetching video labels\", e)\n        return null;\n    }\n    if (response.status !== 200) {\n        logRequest(response, \"SB\", \"video labels\");\n        // No video labels or server down\n        labelCache[hashPrefix] = {\n            timestamp: Date.now(),\n            videos: {},\n        };\n        return null;\n    }\n\n    try {\n        const data = JSON.parse(response.responseText);\n\n        const newEntry: LabelCacheEntry = {\n            timestamp: Date.now(),\n            videos: Object.fromEntries(data.map(video => [video.videoID, {\n                category: video.segments[0]?.category,\n                hasStartSegment: video.hasStartSegment\n            }])),\n        };\n        labelCache[hashPrefix] = newEntry;\n\n        if (Object.keys(labelCache).length > cacheLimit) {\n            // Remove oldest entry\n            const oldestEntry = Object.entries(labelCache).reduce((a, b) => a[1].timestamp < b[1].timestamp ? a : b);\n            delete labelCache[oldestEntry[0]];\n        }\n\n        return newEntry;\n    } catch (e) {\n        logWarn(`Error parsing video labels: ${e}`);\n\n        return null;\n    }\n}\n\nexport async function getVideoLabel(videoID: VideoID): Promise<Category | null> {\n    const prefix = (await getHash(videoID, 1)).slice(0, 4);\n    const result = await getLabelHashBlock(prefix);\n\n    if (result) {\n        const category = result.videos[videoID]?.category;\n        if (category && getCategorySelection(result.videos[videoID]).option !== CategorySkipOption.Disabled) {\n            return category;\n        } else {\n            return null;\n        }\n    }\n\n    return null;\n}\n\nexport async function getHasStartSegment(videoID: VideoID): Promise<boolean | null> {\n    const prefix = (await getHash(videoID, 1)).slice(0, 4);\n    const result = await getLabelHashBlock(prefix);\n\n    if (result) {\n        return result?.videos[videoID]?.hasStartSegment ?? false;\n    }\n\n    return null;\n}\n"
  },
  {
    "path": "src/utils/warnings.ts",
    "content": "import { objectToURI } from \"../../maze-utils/src\";\nimport { FetchResponse, logRequest } from \"../../maze-utils/src/background-request-proxy\";\nimport { formatJSErrorMessage, getLongErrorMessage } from \"../../maze-utils/src/formating\";\nimport { getHash } from \"../../maze-utils/src/hash\";\nimport Config from \"../config\";\nimport GenericNotice, { NoticeOptions } from \"../render/GenericNotice\";\nimport { ContentContainer } from \"../types\";\nimport { asyncRequestToServer } from \"./requests\";\n\nexport interface ChatConfig {\n    displayName: string;\n    composerInitialValue?: string;\n    customDescription?: string;\n}\n\nexport async function openWarningDialog(contentContainer: ContentContainer): Promise<void> {\n    let userInfo: FetchResponse;\n    try {\n        userInfo = await asyncRequestToServer(\"GET\", \"/api/userInfo\", {\n            publicUserID: await getHash(Config.config.userID),\n            values: [\"warningReason\"]\n        });\n    } catch (e) {\n        console.error(\"[SB] Caught error while trying to fetch user's active warnings\", e)\n        return;\n    }\n\n    if (userInfo.ok) {\n        const warningReason = JSON.parse(userInfo.responseText)?.warningReason;\n        let userName = \"\";\n        try {\n            const userNameData = await asyncRequestToServer(\"GET\", \"/api/getUsername?userID=\" + Config.config.userID);\n            userName = userNameData.ok ? JSON.parse(userNameData.responseText).userName : \"\";\n        } catch (e) {\n            console.warn(\"[SB] Caught non-fatal error while trying to resolve user's username\", e);\n        }\n        const publicUserID = await getHash(Config.config.userID);\n\n        let notice: GenericNotice = null;\n        const options: NoticeOptions = {\n            title: chrome.i18n.getMessage(\"deArrowMessageRecieved\"),\n            textBoxes: [{\n                text: chrome.i18n.getMessage(\"warningChatInfo\"),\n                icon: null\n            }, ...warningReason.split(\"\\n\").map((reason) => ({\n                text: reason,\n                icon: null\n            }))],\n            buttons: [{\n                    name: chrome.i18n.getMessage(\"questionButton\"),\n                    listener: () => openChat({\n                        displayName: `${userName ? userName : ``}${userName !== publicUserID ? ` | ${publicUserID}` : ``}`\n                    })\n                },\n                {\n                    name: chrome.i18n.getMessage(\"warningConfirmButton\"),\n                    listener: async () => {\n                        let result: FetchResponse;\n                        try {\n                            result = await asyncRequestToServer(\"POST\", \"/api/warnUser\", {\n                                userID: Config.config.userID,\n                                enabled: false\n                            });\n                        } catch (e) {\n                            console.error(\"[SB] Caught error while trying to acknowledge user's active warning\", e);\n                            alert(formatJSErrorMessage(e));\n                        }\n\n                        if (result.ok) {\n                            notice?.close();\n                        } else {\n                            logRequest(result, \"SB\", \"warning acknowledgement\");\n                            alert(getLongErrorMessage(result.status, result.responseText));\n                        }\n                    }\n            }],\n            timed: false\n        };\n\n        notice = new GenericNotice(contentContainer, \"warningNotice\", options);\n    } else {\n        logRequest(userInfo, \"SB\", \"user's active warnings\");\n    }\n}\n\nexport function openChat(config: ChatConfig): void {\n    window.open(\"https://chat.sponsor.ajay.app/#\" + objectToURI(\"\", config, false));\n}\n"
  },
  {
    "path": "src/utils.ts",
    "content": "import Config, { VideoDownvotes } from \"./config\";\nimport { SponsorTime, BackgroundScriptContainer, Registration, VideoID, SponsorHideType } from \"./types\";\n\nimport { getHash, HashedValue } from \"../maze-utils/src/hash\";\nimport { waitFor } from \"../maze-utils/src\";\nimport { findValidElementFromSelector } from \"../maze-utils/src/dom\";\nimport { isSafari } from \"../maze-utils/src/config\";\nimport { asyncRequestToServer } from \"./utils/requests\";\nimport { FetchResponse, logRequest } from \"../maze-utils/src/background-request-proxy\";\nimport { formatJSErrorMessage, getLongErrorMessage } from \"../maze-utils/src/formating\";\n\nexport default class Utils {\n    \n    // Contains functions needed from the background script\n    backgroundScriptContainer: BackgroundScriptContainer | null;\n\n    // Used to add content scripts and CSS required\n    js = [\n        \"./js/content.js\"\n    ];\n    css = [\n        \"content.css\",\n        \"./libs/Source+Sans+Pro.css\",\n        \"popup.css\",\n        \"shared.css\"\n    ];\n\n    constructor(backgroundScriptContainer: BackgroundScriptContainer = null) {\n        this.backgroundScriptContainer = backgroundScriptContainer;\n    }\n\n    async wait<T>(condition: () => T, timeout = 5000, check = 100): Promise<T> {\n        return waitFor(condition, timeout, check);\n    }\n\n    containsPermission(permissions: chrome.permissions.Permissions): Promise<boolean> {\n        return new Promise((resolve) => {\n            chrome.permissions.contains(permissions, resolve)\n        });\n    }\n\n    /**\n     * Asks for the optional permissions required for all extra sites.\n     * It also starts the content script registrations.\n     * \n     * For now, it is just SB.config.invidiousInstances.\n     * \n     * @param {CallableFunction} callback\n     */\n    setupExtraSitePermissions(callback: (granted: boolean) => void): void {\n        const permissions = [];\n        if (isSafari()) {\n            permissions.push(\"webNavigation\");\n        }\n\n        chrome.permissions.request({\n            origins: this.getPermissionRegex(),\n            permissions: permissions\n        }, async (granted) => {\n            if (granted) {\n                this.setupExtraSiteContentScripts();\n            } else {\n                this.removeExtraSiteRegistration();\n            }\n\n            callback(granted);\n        });\n    }\n\n    getExtraSiteRegistration(): Registration {\n        return {\n            message: \"registerContentScript\",\n            id: \"invidious\",\n            allFrames: true,\n            js: this.js,\n            css: this.css,\n            matches: this.getPermissionRegex()\n        };\n    }\n\n    /**\n     * Registers the content scripts for the extra sites.\n     * Will use a different method depending on the browser.\n     * This is called by setupExtraSitePermissions().\n     * \n     * For now, it is just SB.config.invidiousInstances.\n     */\n    setupExtraSiteContentScripts(): void {\n        const registration = this.getExtraSiteRegistration();\n\n        if (this.backgroundScriptContainer) {\n            this.backgroundScriptContainer.registerFirefoxContentScript(registration);\n        } else {\n            chrome.runtime.sendMessage(registration);\n        }\n    }\n\n    /**\n     * Removes the permission and content script registration.\n     */\n    removeExtraSiteRegistration(): void {\n        const id = \"invidious\";\n\n        if (this.backgroundScriptContainer) {\n            this.backgroundScriptContainer.unregisterFirefoxContentScript(id);\n        } else {\n            chrome.runtime.sendMessage({\n                message: \"unregisterContentScript\",\n                id: id\n            });\n        }\n\n        chrome.permissions.remove({\n            origins: this.getPermissionRegex()\n        });\n    }\n\n    applyInvidiousPermissions(enable: boolean, option = \"supportInvidious\"): Promise<boolean> {\n        return new Promise((resolve) => {\n            if (enable) {\n                this.setupExtraSitePermissions((granted) => {\n                    if (!granted) {\n                        Config.config[option] = false;\n                    }\n\n                    resolve(granted);\n                });\n            } else {\n                this.removeExtraSiteRegistration();\n                resolve(false);\n            }\n        });\n    }\n\n    containsInvidiousPermission(): Promise<boolean> {\n        return new Promise((resolve) => {\n            const permissions = [];\n            if (isSafari()) {\n                permissions.push(\"webNavigation\");\n            }\n\n            chrome.permissions.contains({\n                origins: this.getPermissionRegex(),\n                permissions: permissions\n            }, function (result) {\n                resolve(result);\n            });\n        })\n    }\n\n    /**\n     * Merges any overlapping timestamp ranges into single segments and returns them as a new array.\n     */\n    getMergedTimestamps(timestamps: number[][]): [number, number][] {\n        let deduped: [number, number][] = [];\n\n        // Cases ([] = another segment, <> = current range):\n        // [<]>, <[>], <[]>, [<>], [<][>]\n        timestamps.forEach((range) => {\n            // Find segments the current range overlaps\n            const startOverlaps = deduped.findIndex((other) => range[0] >= other[0] && range[0] <= other[1]);\n            const endOverlaps = deduped.findIndex((other) => range[1] >= other[0] && range[1] <= other[1]);\n\n            if (~startOverlaps && ~endOverlaps) {\n                // [<][>] Both the start and end of this range overlap another segment\n                // [<>] This range is already entirely contained within an existing segment\n                if (startOverlaps === endOverlaps) return;\n\n                // Remove the range with the higher index first to avoid the index shifting\n                const other1 = deduped.splice(Math.max(startOverlaps, endOverlaps), 1)[0];\n                const other2 = deduped.splice(Math.min(startOverlaps, endOverlaps), 1)[0];\n\n                // Insert a new segment spanning the start and end of the range\n                deduped.push([Math.min(other1[0], other2[0]), Math.max(other1[1], other2[1])]);\n            } else if (~startOverlaps) {\n                // [<]> The start of this range overlaps another segment, extend its end\n                deduped[startOverlaps][1] = range[1];\n            } else if (~endOverlaps) {\n                // <[>] The end of this range overlaps another segment, extend its beginning\n                deduped[endOverlaps][0] = range[0];\n            } else {\n                // No overlaps, just push in a copy\n                deduped.push(range.slice() as [number, number]);\n            }\n\n            // <[]> Remove other segments contained within this range\n            deduped = deduped.filter((other) => !(other[0] > range[0] && other[1] < range[1]));\n        });\n\n        return deduped;\n    }\n\n    /**\n     * Returns the total duration of the timestamps, taking into account overlaps.\n     */\n    getTimestampsDuration(timestamps: number[][]): number {\n        return this.getMergedTimestamps(timestamps).reduce((acc, range) => {\n            return acc + range[1] - range[0];\n        }, 0);\n    }\n\n    getSponsorIndexFromUUID(sponsorTimes: SponsorTime[], UUID: string): number {\n        for (let i = 0; i < sponsorTimes.length; i++) {\n            if (sponsorTimes[i].UUID && (sponsorTimes[i].UUID.startsWith(UUID) || UUID.startsWith(sponsorTimes[i].UUID))) {\n                return i;\n            }\n        }\n\n        return -1;\n    }\n\n    getSponsorTimeFromUUID(sponsorTimes: SponsorTime[], UUID: string): SponsorTime {\n        return sponsorTimes[this.getSponsorIndexFromUUID(sponsorTimes, UUID)];\n    }\n\n    /**\n     * @returns {String[]} Domains in regex form\n     */\n    getPermissionRegex(domains: string[] = []): string[] {\n        const permissionRegex: string[] = [];\n        if (domains.length === 0) {\n            domains = [...Config.config.invidiousInstances];\n        }\n\n        for (const url of domains) {\n            permissionRegex.push(\"https://*.\" + url + \"/*\");\n            permissionRegex.push(\"http://*.\" + url + \"/*\");\n        }\n\n        return permissionRegex;\n    }\n\n    findReferenceNode(): HTMLElement {\n        const selectors = [\n            \"#player-container-id\", // Mobile YouTube\n            \"#movie_player\",\n            \".html5-video-player\", // May 2023 Card-Based YouTube Layout\n            \"#c4-player\", // Channel Trailer\n            \"#player-container\", // Preview on hover\n            \"#main-panel.ytmusic-player-page\", // YouTube music\n            \"#player-container .video-js\", // Invidious\n            \".main-video-section > .video-container\", // Cloudtube\n            \".shaka-video-container\", // Piped\n            \"#player-container.ytk-player\", // YT Kids\n            \"#id-tv-container\" // YTTV\n        ];\n\n        let referenceNode = findValidElementFromSelector(selectors)\n        if (referenceNode == null) {\n            //for embeds\n            const player = document.getElementById(\"player\");\n            referenceNode = player?.firstChild as HTMLElement;\n            if (referenceNode) {\n                let index = 1;\n\n                //find the child that is the video player (sometimes it is not the first)\n                while (index < player.children.length && (!referenceNode.classList?.contains(\"html5-video-player\") || !referenceNode.classList?.contains(\"ytp-embed\"))) {\n                    referenceNode = player.children[index] as HTMLElement;\n\n                    index++;\n                }\n            }\n        }\n\n        return referenceNode;\n    }\n\n    isContentScript(): boolean {\n        return window.location.protocol === \"http:\" || window.location.protocol === \"https:\";\n    }\n\n    isHex(num: string): boolean {\n        return Boolean(num.match(/^[0-9a-f]+$/i));\n    }\n\n    async addHiddenSegment(videoID: VideoID, segmentUUID: string, hidden: SponsorHideType) {\n        if ((chrome.extension.inIncognitoContext && !Config.config.trackDownvotesInPrivate)\n                || !Config.config.trackDownvotes) return;\n\n        if (segmentUUID.length < 60) {\n            let segmentIDData: FetchResponse;\n            try {\n                segmentIDData = await asyncRequestToServer(\"GET\", \"/api/segmentID\", {\n                    UUID: segmentUUID,\n                    videoID\n                });\n            } catch (e) {\n                console.error(\"[SB] Caught error while trying to resolve the segment UUID to be hidden\", e);\n                alert(`${chrome.i18n.getMessage(\"segmentHideFailed\")}\\n${formatJSErrorMessage(e)}`);\n                return;\n            }\n\n            if (segmentIDData.ok && segmentIDData.responseText) {\n                segmentUUID = segmentIDData.responseText;\n            } else {\n                logRequest(segmentIDData, \"SB\", \"segment UUID resolution\");\n                alert(`${chrome.i18n.getMessage(\"segmentHideFailed\")}\\n${getLongErrorMessage(segmentIDData.status, segmentIDData.responseText)}`);\n                return;\n            }\n        }\n\n        const hashedVideoID = (await getHash(videoID, 1)).slice(0, 4) as VideoID & HashedValue;\n        const UUIDHash = await getHash(segmentUUID, 1);\n\n        const allDownvotes = Config.local.downvotedSegments;\n        const currentVideoData = allDownvotes[hashedVideoID] || { segments: [], lastAccess: 0 };\n\n        currentVideoData.lastAccess = Date.now();\n        const existingData = currentVideoData.segments.find((segment) => segment.uuid === UUIDHash);\n        if (hidden === SponsorHideType.Visible) {\n            currentVideoData.segments.splice(currentVideoData.segments.indexOf(existingData), 1);\n\n            if (currentVideoData.segments.length === 0) {\n                delete allDownvotes[hashedVideoID];\n            }\n        } else {\n            if (existingData) {\n                existingData.hidden = hidden;\n            } else {\n                currentVideoData.segments.push({\n                    uuid: UUIDHash,\n                    hidden\n                });\n            }\n\n            allDownvotes[hashedVideoID] = currentVideoData;\n        }\n\n        const entries = Object.entries(allDownvotes);\n        if (entries.length > 10000) {\n            let min: [string, VideoDownvotes] = null;\n            for (let i = 0; i < entries[0].length; i++) {\n                if (min === null || entries[i][1].lastAccess < min[1].lastAccess) {\n                    min = entries[i];\n                }\n            }\n\n            delete allDownvotes[min[0]];\n        }\n\n        Config.forceLocalUpdate(\"downvotedSegments\");\n    }\n}\n"
  },
  {
    "path": "test/exporter.test.ts",
    "content": "/**\n * @jest-environment jsdom\n */\n\nimport { ActionType, Category, SegmentUUID, SponsorSourceType, SponsorTime } from \"../src/types\";\nimport { exportTimes, importTimes } from \"../src/utils/exporter\";\n\ndescribe(\"Export segments\", () => {\n    it(\"Some segments\", () => {\n        const segments: SponsorTime[] = [{\n            segment: [0, 10],\n            category: \"chapter\" as Category,\n            actionType: ActionType.Chapter,\n            description: \"Chapter 1\",\n            source: SponsorSourceType.Server,\n            UUID: \"1\" as SegmentUUID\n        }, {\n            segment: [20, 20],\n            category: \"poi_highlight\" as Category,\n            actionType: ActionType.Poi,\n            description: \"Highlight\",\n            source: SponsorSourceType.Server,\n            UUID: \"2\" as SegmentUUID\n        }, {\n            segment: [30, 40],\n            category: \"sponsor\" as Category,\n            actionType: ActionType.Skip,\n            description: \"Sponsor\", // Force a description since chrome is not defined\n            source: SponsorSourceType.Server,\n            UUID: \"3\" as SegmentUUID\n        }, {\n            segment: [50, 60],\n            category: \"selfpromo\" as Category,\n            actionType: ActionType.Mute,\n            description: \"Selfpromo\",\n            source: SponsorSourceType.Server,\n            UUID: \"4\" as SegmentUUID\n        }, {\n            segment: [0, 0],\n            category: \"selfpromo\" as Category,\n            actionType: ActionType.Full,\n            description: \"Selfpromo\",\n            source: SponsorSourceType.Server,\n            UUID: \"5\" as SegmentUUID\n        }, {\n            segment: [80, 90],\n            category: \"interaction\" as Category,\n            actionType: ActionType.Skip,\n            description: \"Interaction\",\n            source: SponsorSourceType.YouTube,\n            UUID: \"6\" as SegmentUUID\n        }];\n\n        const result = exportTimes(segments);\n\n        expect(result).toBe(\n            \"0:00.000 - 0:10.000 Chapter 1\\n\" +\n            \"0:20.000 Highlight\\n\" +\n            \"0:30.000 - 0:40.000 Sponsor\"\n        );\n    });\n\n});\n\ndescribe(\"Import segments\", () => {\n    it(\"1:20 to 1:21 thing\", () => {\n        const input = ` 1:20 to 1:21 thing\n                        1:25 to 1:28 another thing`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [80, 81],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 88],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"thing 1:20 to 1:21\", () => {\n        const input = ` thing 1:20 to 1:21\n                        another thing 1:25 to 1:28 ext`;\n\n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [80, 81],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 88],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"1:20 - 1:21 thing\", () => {\n        const input = ` 1:20 - 1:21 thing\n                        1:25 - 1:28 another thing`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [80, 81],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 88],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"1:20 1:21 thing\", () => {\n        const input = ` 1:20 1:21 thing\n                        1:25 1:28 another thing`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [80, 81],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 88],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"1:20 thing\", () => {\n        const input = ` 1:20 thing\n                        1:25 another thing`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [80, 85],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 120],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"1:20: thing\", () => {\n        const input = ` 1:20: thing\n                        1:25: another thing`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [80, 85],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 120],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"1:20 (thing)\", () => {\n        const input = ` 1:20 (thing)\n                        1:25 (another thing)`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [80, 85],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 120],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"thing 1:20\", () => {\n        const input = ` thing 1:20\n                        another thing 1:25`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [80, 85],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 120],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"thing at 1:20\", () => {\n        const input = ` thing at 1:20\n                        another thing at 1:25`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [80, 85],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 120],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"thing at 1s\", () => {\n        const input = ` thing at 1s\n                        another thing at 5s`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [1, 5],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [5, 120],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it(\"thing at 1 second\", () => {\n        const input = ` thing at 1 second\n                        another thing at 5 seconds`;\n                        \n        const result = importTimes(input, 120);\n        expect(result).toMatchObject([{\n            segment: [1, 5],\n            description: \"thing\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [5, 120],\n            description: \"another thing\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it (\"22. 2:04:22 some name\", () => {\n        const input = ` 22. 2:04:22 some name\n                        23. 2:04:22.23 some other name`;\n                        \n        const result = importTimes(input, 8000);\n        expect(result).toMatchObject([{\n            segment: [7462, 7462.23],\n            description: \"some name\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [7462.23, 8000],\n            description: \"some other name\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it (\"00:00\", () => {\n        const input = ` 00:00 Cap 1\n                        00:10 Cap 2\n                        00:12 Cap 3`;\n                        \n        const result = importTimes(input, 8000);\n        expect(result).toMatchObject([{\n            segment: [0, 10],\n            description: \"Cap 1\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [10, 12],\n            description: \"Cap 2\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [12, 8000],\n            description: \"Cap 3\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it (\"0:00 G¹ (Tangent Continuity)\", () => {\n        const input = ` 0:00  G¹ (Tangent Continuity)\n                        0:01 G² (Tangent Continuity)`;\n\n        const result = importTimes(input, 8000);\n        expect(result).toMatchObject([{\n            segment: [0, 1],\n            description: \"G¹ (Tangent Continuity)\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [1, 8000],\n            description: \"G² (Tangent Continuity)\",\n            category: \"chapter\" as Category\n        }]);\n    });\n\n    it (\"((Some name) 1:20)\", () => {\n        const input = ` ((Some name) 1:20)\n                        ((Some other name) 1:25)`;\n\n        const result = importTimes(input, 8000);\n        expect(result).toMatchObject([{\n            segment: [80, 85],\n            description: \"Some name\",\n            category: \"chapter\" as Category\n        }, {\n            segment: [85, 8000],\n            description: \"Some other name\",\n            category: \"chapter\" as Category\n        }]);\n    });\n});"
  },
  {
    "path": "test/previewBar.test.ts",
    "content": "/**\n * @jest-environment jsdom\n */\n\nimport PreviewBar, { PreviewBarSegment } from \"../src/js-components/previewBar\";\n\ndescribe(\"createChapterRenderGroups\", () => {\n    let previewBar: PreviewBar;\n    beforeEach(() => {\n        previewBar = new PreviewBar(null, null, null, null, null, null, true);\n    })\n\n    it(\"Two unrelated times\", () => {\n        previewBar.videoDuration = 315;\n        const groups = previewBar.createChapterRenderGroups([{\n            segment: [2, 30],\n            category: \"sponsor\",\n            actionType: \"skip\",\n            unsubmitted: false,\n            showLarger: false,\n            description: \"\"\n        }, {\n            segment: [50, 80],\n            category: \"sponsor\",\n            actionType: \"skip\",\n            unsubmitted: false,\n            showLarger: false,\n            description: \"\"\n        }] as PreviewBarSegment[]);\n\n        expect(groups).toStrictEqual([{\n            segment: [0, 2],\n            originalDuration: 0,\n            actionType: null\n        }, {\n            segment: [2, 30],\n            originalDuration: 30 - 2,\n            actionType: \"skip\"\n        }, {\n            segment: [30, 50],\n            originalDuration: 0,\n            actionType: null\n        }, {\n            segment: [50, 80],\n            originalDuration: 80 - 50,\n            actionType: \"skip\"\n        }, {\n            segment: [80, 315],\n            originalDuration: 0,\n            actionType: null\n        }]);\n    });\n\n    it(\"Small time in bigger time\", () => {\n        previewBar.videoDuration = 315;\n        const groups = previewBar.createChapterRenderGroups([{\n            segment: [2.52, 30],\n            category: \"sponsor\",\n            actionType: \"skip\",\n            unsubmitted: false,\n            showLarger: false,\n            description: \"\"\n        }, {\n            segment: [20, 25],\n            category: \"sponsor\",\n            actionType: \"skip\",\n            unsubmitted: false,\n            showLarger: false,\n            description: \"\"\n        }] as PreviewBarSegment[]);\n\n        expect(groups).toStrictEqual([{\n            segment: [0, 2.52],\n            originalDuration: 0,\n            actionType: null\n        }, {\n            segment: [2.52, 20],\n            originalDuration: 30 - 2.52,\n            actionType: \"skip\"\n        }, {\n            segment: [20, 25],\n            originalDuration: 25 - 20,\n            actionType: \"skip\"\n        }, {\n            segment: [25, 30],\n            originalDuration: 30 - 2.52,\n            actionType: \"skip\"\n        }, {\n            segment: [30, 315],\n            originalDuration: 0,\n            actionType: null\n        }]);\n    });\n\n    it(\"Same start time\", () => {\n        previewBar.videoDuration = 315;\n        const groups = previewBar.createChapterRenderGroups([{\n            segment: [2.52, 30],\n            category: \"sponsor\",\n            actionType: \"skip\",\n            unsubmitted: false,\n            showLarger: false,\n            description: \"\"\n        }, {\n            segment: [2.52, 40],\n            category: \"sponsor\",\n            actionType: \"skip\",\n            unsubmitted: false,\n            showLarger: false,\n            description: \"\"\n        }] as PreviewBarSegment[]);\n\n        expect(groups).toStrictEqual([{\n            segment: [0, 2.52],\n            originalDuration: 0,\n            actionType: null\n        }, {\n            segment: [2.52, 30],\n            originalDuration: 30 - 2.52,\n            actionType: \"skip\"\n        }, {\n            segment: [30, 40],\n            originalDuration: 40 - 2.52,\n            actionType: \"skip\"\n        }, {\n            segment: [40, 315],\n            originalDuration: 0,\n            actionType: null\n        }]);\n    });\n\n    it(\"Lots of overlapping segments\", () => {\n        previewBar.videoDuration = 315.061;\n        const groups = previewBar.createChapterRenderGroups([\n            {\n                \"category\": \"chapter\",\n                \"actionType\": \"chapter\",\n                \"segment\": [\n                    0,\n                    49.977\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"b1919787a85cd422af07136a913830eda1364d32e8a9e12104cf5e3bad8f6f45\",\n                \"description\": \"Start of video\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    2.926,\n                    5\n                ],\n                \"locked\": 1,\n                \"votes\": 2,\n                \"videoDuration\": 316,\n                \"userID\": \"938444fccfdb7272fd871b7f98c27ea9e5e806681db421bb69452e6a42730c20\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"chapter\",\n                \"actionType\": \"chapter\",\n                \"segment\": [\n                    14.487,\n                    37.133\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"b1919787a85cd422af07136a913830eda1364d32e8a9e12104cf5e3bad8f6f45\",\n                \"description\": \"Subset of start\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    23.450537,\n                    34.486084\n                ],\n                \"locked\": 0,\n                \"votes\": -1,\n                \"videoDuration\": 315.061,\n                \"userID\": \"938444fccfdb7272fd871b7f98c27ea9e5e806681db421bb69452e6a42730c20\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"interaction\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    50.015343,\n                    56.775314\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"b2a85e8cdfbf21dd504babbcaca7f751b55a5a2df8179c1a83a121d0f5d56c0e\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    62.51888,\n                    74.33331\n                ],\n                \"locked\": 0,\n                \"votes\": -1,\n                \"videoDuration\": 316,\n                \"userID\": \"938444fccfdb7272fd871b7f98c27ea9e5e806681db421bb69452e6a42730c20\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    88.71328,\n                    96.05933\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"6c08c092db2b7a31210717cc1f2652e7e97d032e03c82b029a27c81cead1f90c\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    101.50703,\n                    115.088326\n                ],\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"2db207ad4b7a535a548fab293f4567bf97373997e67aadb47df8f91b673f6e53\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    122.211845,\n                    137.42178\n                ],\n                \"locked\": 0,\n                \"votes\": 1,\n                \"videoDuration\": 0,\n                \"userID\": \"0312cbfa514d9d2dfb737816dc45f52aba7c23f0a3f0911273a6993b2cb57fcc\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    144.08913,\n                    160.14084\n                ],\n                \"locked\": 0,\n                \"votes\": -1,\n                \"videoDuration\": 316,\n                \"userID\": \"938444fccfdb7272fd871b7f98c27ea9e5e806681db421bb69452e6a42730c20\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    164.22084,\n                    170.98082\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"845c4377060d5801f5324f8e1be1e8373bfd9addcf6c68fc5a3c038111b506a3\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    180.56674,\n                    189.16516\n                ],\n                \"locked\": 0,\n                \"votes\": -1,\n                \"videoDuration\": 315.061,\n                \"userID\": \"7c6b015687db7800c05756a0fd226fd8d101f5a1e1bfb1e5d97c440331fd6cb7\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    204.10468,\n                    211.87865\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"3472e8ee00b5da957377ae32d59ddd3095c2b634c2c0c970dfabfb81d143699f\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    214.92064,\n                    222.0186\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 0,\n                \"userID\": \"62a00dffb344d27de7adf8ea32801c2fc0580087dc8d282837923e4bda6a1745\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    233.0754,\n                    244.56734\n                ],\n                \"locked\": 0,\n                \"votes\": -1,\n                \"videoDuration\": 315,\n                \"userID\": \"dcf7fb0a6c071d5a93273ebcbecaee566e0ff98181057a36ed855e9b92bf25ea\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    260.64053,\n                    269.35938\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"e0238059ae4e711567af5b08a3afecfe45601c995b0ea2f37ca43f15fca4e298\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    288.686,\n                    301.96\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"e0238059ae4e711567af5b08a3afecfe45601c995b0ea2f37ca43f15fca4e298\",\n                \"description\": \"\"\n            },\n            {\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"segment\": [\n                    288.686,\n                    295\n                ],\n                \"locked\": 0,\n                \"votes\": 0,\n                \"videoDuration\": 315.061,\n                \"userID\": \"e0238059ae4e711567af5b08a3afecfe45601c995b0ea2f37ca43f15fca4e298\",\n                \"description\": \"\"\n            }] as unknown as PreviewBarSegment[]);\n\n        expect(groups).toStrictEqual([\n            {\n                \"segment\": [\n                    0,\n                    2.926\n                ],\n                \"originalDuration\": 49.977,\n                \"actionType\": \"chapter\"\n            },\n            {\n                \"segment\": [\n                    2.926,\n                    5\n                ],\n                \"originalDuration\": 2.074,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    5,\n                    14.487\n                ],\n                \"originalDuration\": 49.977,\n                \"actionType\": \"chapter\"\n            },\n            {\n                \"segment\": [\n                    14.487,\n                    23.450537\n                ],\n                \"originalDuration\": 22.646,\n                \"actionType\": \"chapter\"\n            },\n            {\n                \"segment\": [\n                    23.450537,\n                    34.486084\n                ],\n                \"originalDuration\": 11.035546999999998,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    34.486084,\n                    37.133\n                ],\n                \"originalDuration\": 22.646,\n                \"actionType\": \"chapter\"\n            },\n            {\n                \"segment\": [\n                    37.133,\n                    49.977\n                ],\n                \"originalDuration\": 49.977,\n                \"actionType\": \"chapter\"\n            },\n            {\n                \"segment\": [\n                    49.977,\n                    50.015343\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    50.015343,\n                    56.775314\n                ],\n                \"originalDuration\": 6.759971,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    56.775314,\n                    62.51888\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    62.51888,\n                    74.33331\n                ],\n                \"originalDuration\": 11.814429999999994,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    74.33331,\n                    88.71328\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    88.71328,\n                    96.05933\n                ],\n                \"originalDuration\": 7.346050000000005,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    96.05933,\n                    101.50703\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    101.50703,\n                    115.088326\n                ],\n                \"originalDuration\": 13.581295999999995,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    115.088326,\n                    122.211845\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    122.211845,\n                    137.42178\n                ],\n                \"originalDuration\": 15.209935000000016,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    137.42178,\n                    144.08913\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    144.08913,\n                    160.14084\n                ],\n                \"originalDuration\": 16.051709999999986,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    160.14084,\n                    164.22084\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    164.22084,\n                    170.98082\n                ],\n                \"originalDuration\": 6.759979999999985,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    170.98082,\n                    180.56674\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    180.56674,\n                    189.16516\n                ],\n                \"originalDuration\": 8.598419999999976,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    189.16516,\n                    204.10468\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    204.10468,\n                    211.87865\n                ],\n                \"originalDuration\": 7.773969999999991,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    211.87865,\n                    214.92064\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    214.92064,\n                    222.0186\n                ],\n                \"originalDuration\": 7.0979600000000005,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    222.0186,\n                    233.0754\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    233.0754,\n                    244.56734\n                ],\n                \"originalDuration\": 11.49194,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    244.56734,\n                    260.64053\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    260.64053,\n                    269.35938\n                ],\n                \"originalDuration\": 8.718849999999975,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    269.35938,\n                    288.686\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    288.686,\n                    295\n                ],\n                \"originalDuration\": 6.314000000000021,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    295,\n                    301.96\n                ],\n                \"originalDuration\": 13.274000000000001,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    301.96,\n                    315.061\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            }\n        ]);\n    })\n\n    it(\"Multiple overlapping\", () => {\n        previewBar.videoDuration = 3615.161;\n        const groups = previewBar.createChapterRenderGroups([{\n                \"segment\": [\n                    160,\n                    2797.323\n                ],\n                \"category\": \"chooseACategory\",\n                \"actionType\": \"skip\",\n                \"unsubmitted\": true,\n                \"showLarger\": false,\n            },{\n                \"segment\": [\n                    169,\n                    3432.255\n                ],\n                \"category\": \"chooseACategory\",\n                \"actionType\": \"skip\",\n                \"unsubmitted\": true,\n                \"showLarger\": false,\n            },{\n                \"segment\": [\n                    169,\n                    3412.413\n                ],\n                \"category\": \"chooseACategory\",\n                \"actionType\": \"skip\",\n                \"unsubmitted\": true,\n                \"showLarger\": false,\n            },{\n                \"segment\": [\n                    1594.92,\n                    1674.286\n                ],\n                \"category\": \"sponsor\",\n                \"actionType\": \"skip\",\n                \"unsubmitted\": false,\n                \"showLarger\": false,\n            }\n        ] as unknown as PreviewBarSegment[]);\n\n        expect(groups).toStrictEqual([\n            {\n                \"segment\": [\n                    0,\n                    160\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            },\n            {\n                \"segment\": [\n                    160,\n                    169\n                ],\n                \"originalDuration\": 2637.323,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    169,\n                    1594.92\n                ],\n                \"originalDuration\": 3243.413,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    1594.92,\n                    1674.286\n                ],\n                \"originalDuration\": 79.36599999999999,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    1674.286,\n                    3412.413\n                ],\n                \"originalDuration\": 3243.413,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    3412.413,\n                    3432.255\n                ],\n                \"originalDuration\": 3263.255,\n                \"actionType\": \"skip\"\n            },\n            {\n                \"segment\": [\n                    3432.255,\n                    3615.161\n                ],\n                \"originalDuration\": 0,\n                \"actionType\": null\n            }\n        ]);\n    });\n})"
  },
  {
    "path": "test/selenium.test.ts",
    "content": "import { Builder, By, until, WebDriver, WebElement } from \"selenium-webdriver\";\nimport * as Chrome from \"selenium-webdriver/chrome\";\nimport * as Path from \"path\";\n\nimport * as fs from \"fs\";\n\nxtest(\"Selenium Chrome test\", async () => {\n    let driver: WebDriver;\n    try {\n        driver = await setup();   \n    } catch (e) {\n        console.warn(\"A browser is probably not installed, skipping selenium tests\");\n        console.warn(e);\n\n        if (String(e).includes(\"This version of ChromeDriver only supports\")) {\n            // Count as failure\n            throw e;\n        }\n\n        return;\n    }\n\n    try {\n        await waitForInstall(driver);\n        // This video has no ads\n        await goToVideo(driver, \"jNQXAC9IVRw\");\n\n        await createSegment(driver, \"4\", \"10.33\", \"0:04.000 to 0:10.330\");\n\n        await editSegments(driver, 0, \"0:04.000\", \"0:10.330\", \"5\", \"13.211\", \"0:05.000 to 0:13.211\", false);\n        await autoskipSegment(driver, 5, 13.211);\n\n        await setSegmentCategory(driver, 0, 1, false);\n        await setSegmentActionType(driver, 0, 1, false);\n        await editSegments(driver, 0, \"0:05.000\", \"0:13.211\", \"5\", \"7.5\", \"0:05.000 to 0:07.500\", false);\n        await muteSkipSegment(driver, 5, 7.5);\n\n        // Full video\n        await setSegmentActionType(driver, 0, 2, false);\n        await driver.wait(until.elementIsNotVisible(await getDisplayTimeBox(driver, 0)));\n\n        await toggleWhitelist(driver);\n        await toggleWhitelist(driver);\n\n    } catch (e) {\n        // Save file incase there is a layout change\n        const source = await driver.getPageSource();\n\n        if (!fs.existsSync(\"./test-results\")) fs.mkdirSync(\"./test-results\"); \n        fs.writeFileSync(\"./test-results/source.html\", source);\n        \n        throw e;\n    } finally {\n        await driver.quit();\n    }\n}, 100_000);\n\nasync function setup(): Promise<WebDriver> {\n    const options = new Chrome.Options();\n    options.addArguments(\"--load-extension=\" + Path.join(__dirname, \"../dist/\"));\n    options.addArguments(\"--mute-audio\");\n    options.addArguments(\"--disable-features=PreloadMediaEngagementData, MediaEngagementBypassAutoplayPolicies\");\n    options.addArguments(\"--headless=new\");\n    options.addArguments(\"--window-size=1920,1080\");\n\n    const driver = await new Builder().forBrowser(\"chrome\").setChromeOptions(options).build();\n    driver.manage().setTimeouts({\n        implicit: 5000\n    });\n\n    return driver;\n}\n\nasync function waitForInstall(driver: WebDriver, startingTab = 0): Promise<void> {\n    // Selenium only knows about the one tab it's on,\n    // so we can't wait for the help page to appear\n    await driver.sleep(3000);\n\n    const handles = await driver.getAllWindowHandles();\n    await driver.switchTo().window(handles[startingTab]);\n}\n\nasync function goToVideo(driver: WebDriver, videoId: string): Promise<void> {\n    await driver.get(\"https://www.youtube.com/watch?v=\" + videoId);\n    await driver.wait(until.elementIsVisible(await driver.findElement(By.css(\".ytd-video-primary-info-renderer, #above-the-fold\"))));\n}\n\nasync function createSegment(driver: WebDriver, startTime: string, endTime: string, expectedDisplayedTime: string): Promise<void> {\n    const startSegmentButton = await driver.findElement(By.id(\"startSegmentButton\"));\n    const cancelSegmentButton = await driver.findElement(By.id(\"cancelSegmentButton\"));\n    await driver.executeScript(\"document.querySelector('video').currentTime = \" + startTime);\n\n    await startSegmentButton.click();\n    await driver.wait(until.elementIsVisible(cancelSegmentButton));\n\n    await driver.executeScript(\"document.querySelector('video').currentTime = \" + endTime);\n\n    await startSegmentButton.click();\n    await driver.wait(until.elementIsNotVisible(cancelSegmentButton));\n\n    const submitButton = await driver.findElement(By.id(\"submitButton\"));\n    await submitButton.click();\n\n    const sponsorTimeDisplays = await driver.findElements(By.className(\"sponsorTimeDisplay\"));\n    const sponsorTimeDisplay = sponsorTimeDisplays[sponsorTimeDisplays.length - 1];\n    await driver.wait(until.elementTextIs(sponsorTimeDisplay, expectedDisplayedTime));\n}\n\nasync function editSegments(driver: WebDriver, index: number, expectedStartTimeBox: string, expectedEndTimeBox: string,\n    startTime: string, endTime: string, expectedDisplayedTime: string, openSubmitBox: boolean): Promise<void> {\n    \n    if (openSubmitBox) {\n        const submitButton = await driver.findElement(By.id(\"submitButton\"));\n        await submitButton.click();\n    }\n\n    let editButton = await driver.findElement(By.id(\"sponsorTimeEditButtonSubmissionNotice\" + index));\n    const sponsorTimeDisplay = await getDisplayTimeBox(driver, index);\n    await sponsorTimeDisplay.click();\n    // Ensure edit time appears\n    await driver.findElement(By.id(\"submittingTime0SubmissionNotice\" + index));\n\n    // Try the edit button too\n    await editButton.click();\n    await editButton.click();\n\n    const startTimeBox = await getStartTimeBox(driver, index, expectedStartTimeBox);\n    await startTimeBox.clear();\n    await startTimeBox.sendKeys(startTime);\n\n    const endTimeBox = await getEndTimeBox(driver, index, expectedEndTimeBox);\n    await endTimeBox.clear();\n    await endTimeBox.sendKeys(endTime);\n\n    await driver.sleep(1000);\n\n    editButton = await driver.findElement(By.id(\"sponsorTimeEditButtonSubmissionNotice\" + index));\n    await editButton.click();\n\n    await getDisplayTimeBox(driver, index, expectedDisplayedTime);\n}\n\nasync function getStartTimeBox(driver: WebDriver, index: number, expectedStartTimeBox: string): Promise<WebElement> {\n    const startTimeBox = await driver.findElement(By.id(\"submittingTime0SubmissionNotice\" + index));\n    expect((await startTimeBox.getAttribute(\"value\"))).toBe(expectedStartTimeBox);\n    return startTimeBox;\n}\n\nasync function getEndTimeBox(driver: WebDriver, index: number, expectedEndTimeBox: string): Promise<WebElement> {\n    const endTimeBox = await driver.findElement(By.id(\"submittingTime1SubmissionNotice\" + index));\n    expect((await endTimeBox.getAttribute(\"value\"))).toBe(expectedEndTimeBox);\n    return endTimeBox;\n}\n\nasync function getDisplayTimeBox(driver: WebDriver, index: number, expectedDisplayedTime?: string): Promise<WebElement> {\n    const sponsorTimeDisplay = (await driver.findElements(By.className(\"sponsorTimeDisplay\")))[index];\n    if (expectedDisplayedTime) {\n        driver.wait(until.elementTextIs(sponsorTimeDisplay, expectedDisplayedTime));\n    }\n\n    return sponsorTimeDisplay;\n}\n\nasync function setSegmentCategory(driver: WebDriver, index: number, categoryIndex: number, openSubmitBox: boolean): Promise<void> {\n    if (openSubmitBox) {\n        const submitButton = await driver.findElement(By.id(\"submitButton\"));\n        await submitButton.click();\n    }\n\n    const categorySelection = await driver.findElement(By.css(`#sponsorTimeCategoriesSubmissionNotice${index} > option:nth-child(${categoryIndex + 1})`));\n    await categorySelection.click();\n}\n\nasync function setSegmentActionType(driver: WebDriver, index: number, actionTypeIndex: number, openSubmitBox: boolean): Promise<void> {\n    if (openSubmitBox) {\n        const submitButton = await driver.findElement(By.id(\"submitButton\"));\n        await submitButton.click();\n    }\n\n    const actionTypeSelection = await driver.findElement(By.css(`#sponsorTimeActionTypesSubmissionNotice${index} > option:nth-child(${actionTypeIndex + 1})`));\n    await actionTypeSelection.click();\n}\n\nasync function autoskipSegment(driver: WebDriver, startTime: number, endTime: number): Promise<void> {\n    const video = await driver.findElement(By.css(\"video\"));\n\n    await driver.executeScript(\"document.querySelector('video').currentTime = \" + (startTime - 0.5));\n    await driver.executeScript(\"document.querySelector('video').play()\");\n\n    await driver.sleep(1300);\n    expect(parseFloat(await video.getAttribute(\"currentTime\"))).toBeGreaterThan(endTime);\n    await driver.executeScript(\"document.querySelector('video').pause()\");\n}\n\nasync function muteSkipSegment(driver: WebDriver, startTime: number, endTime: number): Promise<void> {\n    const duration = endTime - startTime;\n    const video = await driver.findElement(By.css(\"video\"));\n\n    await driver.executeScript(\"document.querySelector('video').currentTime = \" + (startTime - 0.5));\n    await driver.executeScript(\"document.querySelector('video').play()\");\n\n    await driver.sleep(1300);\n    expect(await video.getAttribute(\"muted\")).toEqual(\"true\");\n\n    await driver.sleep(duration * 1000 + 300);\n    expect(await video.getAttribute(\"muted\")).toBeNull(); // Default is null for some reason\n    await driver.executeScript(\"document.querySelector('video').pause()\");\n}\n\nasync function toggleWhitelist(driver: WebDriver): Promise<void> {\n    const popupButton = await driver.findElement(By.id(\"infoButton\"));\n    const rightControls = await driver.findElement(By.css(\".ytp-right-controls\"));\n    await driver.actions().move({ origin: rightControls }).perform();\n    if ((await popupButton.getCssValue(\"display\")) !== \"none\") {\n        await driver.actions().move({ origin: popupButton }).perform();\n        await popupButton.click();\n    }\n\n    const popupFrame = await driver.findElement(By.css(\"#sponsorBlockPopupContainer iframe\"));\n    await driver.switchTo().frame(popupFrame);\n\n    const whitelistButton = await driver.findElement(By.id(\"whitelistButton\"));\n    await driver.wait(until.elementIsVisible(whitelistButton));\n\n    const whitelistText = await driver.findElement(By.id(\"whitelistChannel\"));\n    const whitelistDisplayed = await whitelistText.isDisplayed();\n\n    await whitelistButton.click();\n    if (whitelistDisplayed) {\n        const unwhitelistText = await driver.findElement(By.id(\"unwhitelistChannel\"));\n        await driver.wait(until.elementIsVisible(unwhitelistText));\n    } else {\n        await driver.wait(until.elementIsVisible(whitelistText));\n    }\n\n    await driver.switchTo().defaultContent();\n}"
  },
  {
    "path": "test/urlParser.test.ts",
    "content": "import { getStartTimeFromUrl } from '../src/utils/urlParser';\n\ndescribe(\"getStartTimeFromUrl\", () => {\n    it(\"parses with a number\", () => {\n        expect(getStartTimeFromUrl(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=123\")).toBe(123);\n    });\n\n    it(\"parses with seconds\", () => {\n        expect(getStartTimeFromUrl(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=123s\")).toBe(123);\n    });\n\n    it(\"parses with minutes\", () => {\n        expect(getStartTimeFromUrl(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=23m3s\")).toBe(23 * 60 + 3);\n    });\n\n    it(\"parses with hours\", () => {\n        expect(getStartTimeFromUrl(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s\")).toBe(1 * 60 * 60 + 2 * 60 + 3);\n    });\n\n    it(\"works with time_continue\", () => {\n        expect(getStartTimeFromUrl(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ&time_continue=123\")).toBe(123);\n    });\n\n    it(\"works with no time\", () => {\n        expect(getStartTimeFromUrl(\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\")).toBe(0);\n    });\n});"
  },
  {
    "path": "tsconfig-production.json",
    "content": "{\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"target\": \"es6\",\n        \"noImplicitAny\": false,\n        \"noImplicitReturns\": true,\n        \"noFallthroughCasesInSwitch\": true,\n        \"sourceMap\": true,\n        \"outDir\": \"dist/js\",\n        \"noEmitOnError\": false,\n        \"typeRoots\": [ \"node_modules/@types\" ],\n        \"resolveJsonModule\": true,\n        \"jsx\": \"react\",\n        \"lib\": [\n            \"es2019\",\n            \"dom\",\n            \"dom.iterable\"\n        ]\n    },\n    \"include\": [\n        \"src/**/*\"\n    ]\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n    \"compilerOptions\": {\n        \"module\": \"commonjs\",\n        \"target\": \"es6\",\n        \"noImplicitAny\": false,\n        \"noImplicitReturns\": true,\n        \"noFallthroughCasesInSwitch\": true,\n        \"sourceMap\": true,\n        \"outDir\": \"dist/js\",\n        \"noEmitOnError\": false,\n        \"typeRoots\": [ \"node_modules/@types\" ],\n        \"resolveJsonModule\": true,\n        \"jsx\": \"react\",\n        \"lib\": [\n            \"es2020\",\n            \"dom\",\n            \"dom.iterable\"\n        ]\n    },\n    \"include\": [\n        \"src/**/*\"\n    ]\n}"
  },
  {
    "path": "webpack/configDiffPlugin.js",
    "content": "/* eslint-disable @typescript-eslint/no-var-requires */\nconst { readFile } = require(\"fs/promises\")\nlet logger;\n\nconst readFileContents = (name) => readFile(name)\n  .then(data => JSON.parse(data))\n\n// partialDeepEquals from ajayyy/SponsorBlockServer\nfunction partialDeepEquals (actual, expected, logger) {\n  // loop over key, value of expected\n  let failed = false;\n  for (const [ key, value ] of Object.entries(expected)) {\n    if (key === \"serverAddress\" || key === \"testingServerAddress\" || key === \"serverAddressComment\" || key === \"freeChapterAccess\") continue\n    // if value is object, recurse\n    const actualValue = actual?.[key]\n    if (typeof value !== \"string\" && Array.isArray(value)) {\n      if (!arrayPartialDeepEquals(actualValue, value)) {\n        printActualExpected(key, actualValue, value, logger)\n        failed = true\n      }\n    } else if (typeof value === \"object\") {\n      if (partialDeepEquals(actualValue, value, logger)) {\n        console.log(\"obj failed\")\n        printActualExpected(key, actualValue, value, logger)\n        failed = true\n      }\n    } else if (actualValue !== value) {\n      printActualExpected(key, actualValue, value, logger)\n      failed = true\n    }\n  }\n  return failed\n}\n\nconst arrayPartialDeepEquals = (actual, expected) =>\n  expected.every(a => actual?.includes(a))\n\nfunction printActualExpected(key, actual, expected, logger) {\n  logger.error(`Differing value for: ${key}`)\n  logger.error(`Actual: ${JSON.stringify(actual)}`)\n  logger.error(`Expected: ${JSON.stringify(expected)}`)\n}\n\nclass configDiffPlugin {\n  apply(compiler) {\n    // eslint-disable-next-line @typescript-eslint/no-unused-vars\n    compiler.hooks.done.tapAsync(\"configDiffPlugin\", async (stats, callback) => {\n      logger = compiler.getInfrastructureLogger('configDiffPlugin')\n      logger.log('Checking for config.json diff...')\n      \n      // check example\n      const exampleConfig = await readFileContents(\"./config.json.example\")\n      const currentConfig = await readFileContents(\"./config.json\")\n\n      const difference = partialDeepEquals(currentConfig, exampleConfig, logger)\n      if (difference) {\n        logger.warn(\"config.json is missing values from config.json.example\")\n      } else {\n        logger.info(\"config.json is not missing any values from config.json.example\")\n      }\n      callback()\n    })\n  }\n}\n\nmodule.exports = configDiffPlugin;\n"
  },
  {
    "path": "webpack/webpack.common.js",
    "content": "/* eslint-disable @typescript-eslint/no-var-requires */\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst webpack = require(\"webpack\");\nconst path = require('path');\nconst CopyPlugin = require('copy-webpack-plugin');\nconst BuildManifest = require('./webpack.manifest');\nconst srcDir = '../src/';\nconst fs = require(\"fs\");\nconst ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');\nconst configDiffPlugin = require('./configDiffPlugin');\n\nconst edgeLanguages = [\n    \"de\",\n    \"en\",\n    \"es\",\n    \"fr\",\n    \"pl\",\n    \"pt_BR\",\n    \"ro\",\n    \"ru\",\n    \"sk\",\n    \"sv\",\n    \"tr\",\n    \"uk\",\n    \"zh_CN\"\n]\n\n\n\nmodule.exports = env => {\n    const documentScriptBuild = webpack({\n        entry: {\n            document: path.join(__dirname, srcDir + 'document.ts')\n        },\n        output: {\n            path: path.join(__dirname, '../dist/js'),\n        },\n        module: {\n            rules: [\n                {\n                    test: /\\.tsx?$/,\n                    loader: 'ts-loader',\n                    exclude: /node_modules/,\n                    resourceQuery: { not: [/raw/] },\n                    options: {\n                        // disable type checker for user in fork plugin\n                        transpileOnly: true,\n                        configFile: env.mode === \"production\" ? \"tsconfig-production.json\" : \"tsconfig.json\"\n                    }\n                },\n            ]\n        },\n        resolve: {\n            extensions: ['.ts', '.tsx', '.js']\n        },\n        plugins: [\n            // Don't fork TS checker for document script to speed up\n            // new ForkTsCheckerWebpackPlugin()\n        ]\n    });\n\n    class DocumentScriptCompiler {\n        currentWatching = null;\n\n        /**\n         * \n         * @param {webpack.Compiler} compiler \n         */\n        apply(compiler) {\n            compiler.hooks.beforeCompile.tapAsync({ name: 'DocumentScriptCompiler' }, (compiler, callback) => {\n                if (env.WEBPACK_WATCH) {\n                    let first = true;\n                    if (!this.currentWatching) {\n                        this.currentWatching = documentScriptBuild.watch({}, () => {\n                            if (first) {\n                                first = false;\n                                callback();\n                            }\n                        });\n                    } else {\n                        callback();\n                    }\n                } else {\n                    documentScriptBuild.close(() => {\n                        documentScriptBuild.run(() => {\n                            callback();\n                        });\n                    });\n                }\n            });\n        }\n    }\n\n    return {\n        entry: {\n            popup: path.join(__dirname, srcDir + 'popup/popup.tsx'),\n            background: path.join(__dirname, srcDir + 'background.ts'),\n            content: path.join(__dirname, srcDir + 'content.ts'),\n            options: path.join(__dirname, srcDir + 'options.ts'),\n            help: path.join(__dirname, srcDir + 'help.ts'),\n            permissions: path.join(__dirname, srcDir + 'permissions.ts'),\n        },\n        output: {\n            path: path.join(__dirname, '../dist/js'),\n        },\n        module: {\n            rules: [\n                {\n                    test: /\\.tsx?$/,\n                    loader: 'ts-loader',\n                    exclude: /node_modules/,\n                    resourceQuery: { not: [/raw/] },\n                    options: {\n                        // disable type checker for user in fork plugin\n                        transpileOnly: true,\n                        configFile: env.mode === \"production\" ? \"tsconfig-production.json\" : \"tsconfig.json\"\n                    }\n                },\n                {\n                    test: /js(\\/|\\\\)document\\.js$/,\n                    type: 'asset/source'\n                }\n            ]\n        },\n        resolve: {\n            extensions: ['.ts', '.tsx', '.js'],\n            symlinks: false\n        },\n        plugins: [\n            // Prehook to start building document script before normal build\n            new DocumentScriptCompiler(),\n            // fork TS checker\n            new ForkTsCheckerWebpackPlugin(),\n            // exclude locale files in moment\n            new CopyPlugin({\n                patterns: [\n                    {\n                        from: '.',\n                        to: '../',\n                        globOptions: {\n                            ignore: ['manifest.json', '**/.git/**', '**/crowdin.yml'],\n                        },\n                        context: './public',\n                        filter: async (path) => {\n                            if (path.match(/(\\/|\\\\)_locales(\\/|\\\\).+/)) {\n                                if (env.browser.toLowerCase() === \"edge\" \n                                        && !edgeLanguages.includes(path.match(/(?<=\\/_locales\\/)[^/]+(?=\\/[^/]+$)/)[0])) {\n                                    return false;\n                                }\n\n                                const data = await fs.promises.readFile(path);\n                                const parsed = JSON.parse(data.toString());\n\n                                return parsed.fullName && parsed.Description;\n                            } else {\n                                return true;\n                            }\n                        },\n                        transform(content, path) {\n                            if (path.match(/(\\/|\\\\)_locales(\\/|\\\\).+/)) {\n                                const parsed = JSON.parse(content.toString());\n                                if (env.browser.toLowerCase() === \"safari\") {\n                                    parsed.fullName.message = parsed.fullName.message.match(/^.+(?= [-–])/)?.[0] || parsed.fullName.message;\n                                    if (parsed.fullName.message.length > 50) {\n                                        parsed.fullName.message = parsed.fullName.message.slice(0, 47) + \"...\";\n                                    }\n\n                                    parsed.Description.message = parsed.Description.message.match(/^.+(?=\\. )/)?.[0] || parsed.Description.message;\n                                    if (parsed.Description.message.length > 80) {\n                                        parsed.Description.message = parsed.Description.message.slice(0, 77) + \"...\";\n                                    }\n                                }\n\n                                if (env.browser.toLowerCase() === \"edge\") {\n                                    parsed.Description.message = parsed.Description.message.match(/^.+(?=\\. )/)?.[0] || parsed.Description.message;\n                                    if (parsed.Description.message.length > 132) {\n                                        parsed.Description.message = parsed.Description.message.slice(0, 129) + \"...\";\n                                    }\n                                }\n                \n                                return Buffer.from(JSON.stringify(parsed));\n                            }\n\n                            return content;\n                        }\n                    }\n                ]\n            }),\n            new BuildManifest({\n                browser: env.browser,\n                pretty: env.mode === \"production\",\n                stream: env.stream,\n                autoupdate: env.autoupdate,\n            }),\n            new configDiffPlugin()\n        ],\n        performance: {\n            hints: false,\n            maxEntrypointSize: 512000,\n            maxAssetSize: 512000\n        }\n\n    };\n};\n"
  },
  {
    "path": "webpack/webpack.dev.js",
    "content": "/* eslint-disable @typescript-eslint/no-var-requires */\nconst { merge } = require('webpack-merge');\nconst common = require('./webpack.common.js');\n\nmodule.exports = env => merge(common(env), {\n    devtool: 'inline-source-map',\n    mode: 'development'\n});"
  },
  {
    "path": "webpack/webpack.manifest.js",
    "content": "/* eslint-disable @typescript-eslint/no-var-requires */\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst webpack = require(\"webpack\");\nconst path = require('path');\nconst { validate } = require('schema-utils');\nconst fs = require('fs');\n\nconst manifest = require(\"../manifest/manifest.json\");\nconst firefoxManifestExtra = require(\"../manifest/firefox-manifest-extra.json\");\nconst chromeManifestExtra = require(\"../manifest/chrome-manifest-extra.json\");\nconst safariManifestExtra = require(\"../manifest/safari-manifest-extra.json\");\nconst betaManifestExtra = require(\"../manifest/beta-manifest-extra.json\");\nconst firefoxBetaManifestExtra = require(\"../manifest/firefox-beta-manifest-extra.json\");\nconst manifestV2ManifestExtra = require(\"../manifest/manifest-v2-extra.json\");\n\n// schema for options object\nconst schema = {\n    type: 'object',\n    properties: {\n        browser: {\n            type: 'string'\n        },\n        pretty: {\n            type: 'boolean'\n        },\n        stream: {\n            type: 'string'\n        },\n        autoupdate: {\n            type: 'boolean',\n        }\n    }\n};\n\nclass BuildManifest {\n    constructor (options = {}) {\n        validate(schema, options, \"Build Manifest Plugin\");\n\n        this.options = options;\n    }\n\n    apply() {\n        const distFolder = path.resolve(__dirname, \"../dist/\");\n        const distManifestFile = path.resolve(distFolder, \"manifest.json\");\n        const [owner, repo_name] = (process.env.GITHUB_REPOSITORY ?? \"ajayyy/SponsorBlock\").split(\"/\");\n\n        // Add missing manifest elements\n        if (this.options.browser.toLowerCase() === \"firefox\") {\n            mergeObjects(manifest, manifestV2ManifestExtra);\n            mergeObjects(manifest, firefoxManifestExtra);\n        } else if (this.options.browser.toLowerCase() === \"chrome\" \n                || this.options.browser.toLowerCase() === \"chromium\"\n                || this.options.browser.toLowerCase() === \"edge\") {\n            mergeObjects(manifest, chromeManifestExtra);\n        }  else if (this.options.browser.toLowerCase() === \"safari\") {\n            mergeObjects(manifest, manifestV2ManifestExtra);\n            mergeObjects(manifest, safariManifestExtra);\n            manifest.optional_permissions = manifest.optional_permissions.filter((a) => a !== \"*://*/*\");\n        }\n\n        if (this.options.stream === \"beta\") {\n            mergeObjects(manifest, betaManifestExtra);\n\n            if (this.options.browser.toLowerCase() === \"firefox\") {\n                mergeObjects(manifest, firefoxBetaManifestExtra);\n            }\n        }\n\n        if (this.options.autoupdate === true && this.options.browser.toLowerCase() === \"firefox\") {\n            manifest.browser_specific_settings.gecko.update_url = `https://${owner.toLowerCase()}.github.io/${repo_name}/updates.json`;\n        }\n\n        let result = JSON.stringify(manifest);\n        if (this.options.pretty) result = JSON.stringify(manifest, null, 2);\n\n        fs.mkdirSync(distFolder, {recursive: true});\n        fs.writeFileSync(distManifestFile, result);\n    }\n}\n\nfunction mergeObjects(object1, object2) {\n    for (const key in object2) {\n        if (key in object1) {\n            if (Array.isArray(object1[key])) {\n                object1[key] = object1[key].concat(object2[key]);\n            } else if (typeof object1[key] == 'object') {\n                mergeObjects(object1[key], object2[key]);\n            } else {\n                object1[key] = object2[key];\n            }\n        } else {\n            object1[key] = object2[key];\n        }\n    }\n}\n\nmodule.exports = BuildManifest;\n"
  },
  {
    "path": "webpack/webpack.prod.js",
    "content": "/* eslint-disable @typescript-eslint/no-var-requires */\nconst { SourceMapDevToolPlugin } = require('webpack');\nconst { merge } = require('webpack-merge');\nconst common = require('./webpack.common.js');\n\nasync function createGHPSourceMapURL(env) {\n    const manifest = require(\"../manifest/manifest.json\");\n    const version = manifest.version;\n    const [owner, repo_name] = (process.env.GITHUB_REPOSITORY ?? \"ajayyy/SponsorBlock\").split(\"/\");\n    const ghpUrl = `https://${owner.toLowerCase()}.github.io/${repo_name}/${env.browser}${env.stream === \"beta\" ? \"-beta\" : \"\"}/${version}/`;\n    // make a request to the url and check if we got redirected\n    // firefox doesn't seem to like getting redirected on a source map request\n    try {\n        const resp = await fetch(ghpUrl);\n        return resp.url;\n    } catch {\n        return ghpUrl;\n    }\n}\n\nmodule.exports = async env => {\n    let mode = \"production\";\n    env.mode = mode;\n\n    return merge(common(env), {\n        mode,\n        ...(env.ghpSourceMaps\n            ? {\n                devtool: false,\n                plugins: [new SourceMapDevToolPlugin({\n                    publicPath: await createGHPSourceMapURL(env),\n                    filename: '[file].map[query]',\n                })],\n            }\n            : {\n                devtool: \"source-map\",\n            }\n        ),\n    });\n};\n"
  }
]