├── .all-contributorsrc ├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── stale.yml └── workflows │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .vscode ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TODO.md ├── bun.lockb ├── crowdin.yml ├── eslint.config.mjs ├── generateReleaseHashes.sh ├── nodemon.json ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── prettier.config.cjs ├── privacy.md ├── public ├── contentStyle.css ├── icons │ ├── icon_128.png │ ├── icon_16.png │ ├── icon_19.png │ ├── icon_38.png │ └── icon_48.png └── locales │ ├── ca-ES.json │ ├── cs-CZ.json │ ├── de-DE.json │ ├── en-GB.json │ ├── en-US.json │ ├── en-US.json.d.ts │ ├── es-ES.json │ ├── fa-IR.json │ ├── fr-FR.json │ ├── he-IL.json │ ├── hi-IN.json │ ├── it-IT.json │ ├── ja-JP.json │ ├── ko-KR.json │ ├── pl-PL.json │ ├── pt-BR.json │ ├── ru-RU.json │ ├── sv-SE.json │ ├── tr-TR.json │ ├── uk-UA.json │ ├── vi-VN.json │ ├── zh-CN.json │ └── zh-TW.json ├── release.config.cjs ├── src ├── assets │ ├── img │ │ ├── featureMenuGrid.svg │ │ ├── loopOff.svg │ │ ├── loopOn.svg │ │ ├── maximize.svg │ │ ├── minimize.svg │ │ ├── volumeBoostOff.svg │ │ └── volumeBoostOn.svg │ └── styles │ │ └── tailwind.css ├── components │ ├── Inputs │ │ ├── CSSEditor │ │ │ ├── CSSEditor.tsx │ │ │ ├── EditorProblems │ │ │ │ ├── index.css │ │ │ │ └── index.tsx │ │ │ ├── ExpandButton │ │ │ │ └── index.tsx │ │ │ ├── editorOptions.ts │ │ │ ├── editorWorkerConfig.ts │ │ │ └── index.tsx │ │ ├── CheckBox │ │ │ ├── CheckBox.tsx │ │ │ └── index.tsx │ │ ├── ColorPicker │ │ │ ├── ColorPicker.tsx │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── Number │ │ │ ├── Arrow.tsx │ │ │ ├── Number.css │ │ │ ├── Number.tsx │ │ │ └── index.tsx │ │ ├── Select │ │ │ ├── Select.tsx │ │ │ └── index.tsx │ │ ├── Slider │ │ │ ├── Slider.tsx │ │ │ └── index.tsx │ │ ├── TextInput │ │ │ ├── TextInput.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ ├── Link.tsx │ ├── Loader.tsx │ └── Settings │ │ ├── Settings.css │ │ ├── Settings.tsx │ │ └── components │ │ ├── Setting.tsx │ │ ├── SettingNotifications.tsx │ │ ├── SettingSearch.tsx │ │ ├── SettingSection.tsx │ │ └── SettingTitle.tsx ├── deepDarkMaterialCSS.ts ├── deepDarkPresets.ts ├── defaults.ts ├── features │ ├── automaticTheaterMode │ │ └── index.ts │ ├── automaticallyDisableClosedCaptions │ │ └── index.ts │ ├── buttonPlacement │ │ ├── index.ts │ │ └── utils.ts │ ├── copyTimestampUrlButton │ │ └── index.ts │ ├── customCSS │ │ ├── index.ts │ │ └── utils.ts │ ├── deepDarkCSS │ │ ├── index.ts │ │ └── utils.ts │ ├── featureMenu │ │ ├── index.ts │ │ └── utils.ts │ ├── forwardRewindButtons │ │ └── index.ts │ ├── hideEndScreenCards │ │ ├── index.css │ │ └── index.ts │ ├── hideLiveStreamChat │ │ ├── index.css │ │ └── index.ts │ ├── hideOfficialArtistVideosFromHomePage │ │ ├── index.css │ │ ├── index.ts │ │ └── utils.ts │ ├── hidePaidPromotionBanner │ │ ├── index.css │ │ └── index.ts │ ├── hideScrollBar │ │ ├── index.ts │ │ └── utils.ts │ ├── hideShorts │ │ ├── index.ts │ │ └── utils.ts │ ├── hideTranslateComment │ │ ├── index.css │ │ ├── index.ts │ │ └── utils.ts │ ├── index.ts │ ├── loopButton │ │ ├── index.ts │ │ └── utils.ts │ ├── maximizePlayerButton │ │ ├── index.ts │ │ └── utils.ts │ ├── openTranscriptButton │ │ ├── index.ts │ │ └── utils.ts │ ├── openYouTubeSettingsOnHover │ │ └── index.ts │ ├── pauseBackgroundPlayers │ │ └── index.ts │ ├── playbackSpeedButtons │ │ └── index.ts │ ├── playerQuality │ │ └── index.ts │ ├── playerSpeed │ │ └── index.ts │ ├── playlistLength │ │ ├── index.ts │ │ └── utils.ts │ ├── remainingTime │ │ ├── index.ts │ │ └── utils.ts │ ├── rememberVolume │ │ ├── index.ts │ │ └── utils.ts │ ├── removeRedirect │ │ └── index.ts │ ├── screenshotButton │ │ └── index.ts │ ├── scrollWheelSpeedControl │ │ ├── index.ts │ │ └── utils.ts │ ├── scrollWheelVolumeControl │ │ ├── index.ts │ │ └── utils.ts │ ├── shareShortener │ │ └── index.ts │ ├── shortsAutoScroll │ │ ├── index.ts │ │ └── utils.ts │ ├── skipContinueWatching │ │ └── index.ts │ ├── videoHistory │ │ ├── index.ts │ │ └── utils.ts │ └── volumeBoost │ │ └── index.ts ├── global.d.ts ├── hooks │ ├── index.ts │ ├── useClickOutside.ts │ ├── useComponentVisible.ts │ ├── useNotifications │ │ ├── context.ts │ │ ├── index.ts │ │ └── provider.tsx │ ├── useRunAfterUpdate.ts │ ├── useSectionTitle │ │ ├── context.ts │ │ ├── index.ts │ │ └── provider.tsx │ ├── useSettingsFilter │ │ ├── context.ts │ │ ├── index.ts │ │ └── provider.tsx │ └── useStorage.ts ├── i18n │ ├── constants.ts │ ├── i18n.d.ts │ └── index.ts ├── icons.ts ├── manifest.ts ├── pages │ ├── background │ │ ├── index.html │ │ └── index.ts │ ├── content │ │ └── index.ts │ ├── embedded │ │ ├── index.ts │ │ └── style.css │ ├── options │ │ ├── Options.css │ │ ├── Options.tsx │ │ ├── index.css │ │ ├── index.html │ │ └── index.tsx │ └── popup │ │ ├── Popup.tsx │ │ ├── index.css │ │ ├── index.html │ │ └── index.tsx ├── reset.d.ts ├── types │ └── index.ts ├── utils │ ├── EventManager.ts │ ├── OnScreenDisplayManager.ts │ ├── SVGElementAttributes.ts │ ├── checkLocalesForMissingKeys.ts │ ├── constants.ts │ ├── log.ts │ ├── monaco.ts │ ├── plugins │ │ ├── build-content-script.ts │ │ ├── copy-build.ts │ │ ├── copy-public.ts │ │ ├── make-manifest.ts │ │ ├── make-release-zips.ts │ │ ├── replace-dev-mode-const.ts │ │ └── utils.ts │ ├── updateAvailableLocales.ts │ ├── updateLocalePercentages.ts │ ├── updateStoredSettings.ts │ └── utilities.ts └── vite-env.d.ts ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | end_of_line = crlf 5 | indent_style = tab 6 | insert_final_newline = true 7 | indent_size = 2 8 | charset = utf-8 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /assets export-ignore 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | target-branch: "dev" 13 | versioning-strategy: "increase-if-necessary" 14 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 14 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 3 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: true 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | "on": 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build-and-release: 8 | runs-on: ubuntu-latest 9 | permissions: write-all 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v2 13 | - name: Set up Node.js 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: 20 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Run Semantic Release 20 | run: | 21 | touch .env 22 | echo CROWDIN_TOKEN=$CROWDIN_TOKEN >> .env 23 | npx semantic-release 24 | env: 25 | GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 26 | CROWDIN_TOKEN: "${{ secrets.CROWDIN_TOKEN }}" 27 | - name: Push changes to main branch 28 | run: | 29 | # Ensure we are on the main branch 30 | git checkout main 31 | git add ./src/i18n/index.ts 32 | git commit -m "Update i18n" 33 | # Push local changes to the main branch 34 | git push origin main 35 | - name: Merge changes from main into dev 36 | run: | 37 | # Switch to the dev branch 38 | git checkout dev 39 | 40 | # Merge changes from the main branch into dev 41 | git merge main 42 | 43 | # Push changes to the dev branch 44 | git push origin dev 45 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.8.1 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .all-contributorsrc 4 | CHANGELOG.md 5 | README.md 6 | package.json 7 | package-lock.json 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "i18n-ally.localesPaths": ["public/locales"], 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll": "always" 6 | }, 7 | "eslint.codeActionsOnSave.mode": "all", 8 | "editor.snippets.codeActions.enabled": true, 9 | "typescript.validate.enable": true, 10 | "typescript.tsserver.experimental.enableProjectDiagnostics": true 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "eslint", 6 | "problemMatcher": ["$eslint-stylish"], 7 | "label": "eslint: lint whole folder", 8 | "options": { 9 | "shell": { 10 | "executable": "cmd.exe", 11 | "args": ["/d", "/c"] 12 | } 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Nathaniel Farley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YouTube-Enhancer/extension/1e203d8737b597f120a42004aada0fe888e00405/TODO.md -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YouTube-Enhancer/extension/1e203d8737b597f120a42004aada0fe888e00405/bun.lockb -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /public/locales/en-US.json 3 | translation: /public/locales/%locale%.json 4 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslintPluginReact from "eslint-plugin-react"; 2 | import eslintPluginNoSecrets from "eslint-plugin-no-secrets"; 3 | import eslintPluginImport from "eslint-plugin-import"; 4 | import eslintPluginTailwindCSS from "eslint-plugin-tailwindcss"; 5 | import eslintPluginPromise from "eslint-plugin-promise"; 6 | import eslintPluginPerfectionist from "eslint-plugin-perfectionist"; 7 | import eslintPluginReactHooks from "eslint-plugin-react-hooks"; 8 | import eslintPluginPrettier from "eslint-plugin-prettier/recommended"; 9 | import eslintTypeScriptParser from "@typescript-eslint/parser"; 10 | import eslintJavascript from "@eslint/js"; 11 | import typescriptEslint from "typescript-eslint"; 12 | import { fixupPluginRules } from "@eslint/compat"; 13 | import globals from "globals"; 14 | export default [ 15 | { 16 | ignores: ["**/watch.js", "dist/**/*", "releases/**/*"] 17 | }, 18 | eslintJavascript.configs.recommended, 19 | ...typescriptEslint.configs.recommended, 20 | ...eslintPluginTailwindCSS.configs["flat/recommended"], 21 | eslintPluginImport.flatConfigs.recommended, 22 | eslintPluginImport.flatConfigs.typescript, 23 | eslintPluginPromise.configs["flat/recommended"], 24 | eslintPluginPrettier, 25 | { 26 | plugins: { 27 | react: eslintPluginReact, 28 | "no-secrets": eslintPluginNoSecrets, 29 | perfectionist: eslintPluginPerfectionist, 30 | "react-hooks": fixupPluginRules(eslintPluginReactHooks) 31 | }, 32 | languageOptions: { 33 | ecmaVersion: "latest", 34 | sourceType: "module", 35 | globals: { 36 | ...globals.browser, 37 | ...globals.node, 38 | chrome: "readonly" 39 | }, 40 | parser: eslintTypeScriptParser, 41 | parserOptions: { 42 | ecmaFeatures: { 43 | jsx: true 44 | }, 45 | project: "./tsconfig.json", 46 | tsconfigRootDir: "." 47 | } 48 | }, 49 | settings: { 50 | tailwindcss: { 51 | callees: ["cn"], 52 | config: "./tailwind.config.ts" 53 | }, 54 | react: { 55 | version: "detect" 56 | } 57 | }, 58 | rules: { 59 | "react/react-in-jsx-scope": "off", 60 | "@typescript-eslint/no-unused-vars": [ 61 | "error", 62 | { 63 | varsIgnorePattern: "^_", 64 | argsIgnorePattern: "^_" 65 | } 66 | ], 67 | "@typescript-eslint/no-explicit-any": "off", 68 | "@typescript-eslint/explicit-module-boundary-types": "off", 69 | "@typescript-eslint/restrict-template-expressions": "off", 70 | quotes: ["error", "double", { avoidEscape: true, allowTemplateLiterals: true }], 71 | semi: ["error", "always"], 72 | "prefer-const": ["error", { destructuring: "any", ignoreReadBeforeAssign: false }], 73 | "prefer-destructuring": ["error", { array: true, object: true }, { enforceForRenamedProperties: true }], 74 | "no-useless-escape": "off", 75 | "no-empty": ["error", { allowEmptyCatch: true }], 76 | "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], 77 | "import/first": ["error"], 78 | "no-secrets/no-secrets": ["error", { tolerance: 5.0 }], 79 | "import/no-unresolved": "off", 80 | "tailwindcss/no-custom-classname": "off", 81 | "tailwindcss/classnames-order": "error", 82 | "@typescript-eslint/no-floating-promises": "error", 83 | "import/no-named-as-default-member": "off" 84 | } 85 | }, 86 | { 87 | files: ["**/*.ts", "**/*.tsx", "**/*.js", "**/.d.ts", "**/.spec.ts"], 88 | languageOptions: { 89 | parser: eslintTypeScriptParser 90 | } 91 | } 92 | ]; 93 | -------------------------------------------------------------------------------- /generateReleaseHashes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | directory="releases" # Path to the "releases" directory 4 | 5 | echo "## Release Artifacts" 6 | echo "| File Name | SHA-256 Hash |" 7 | echo "| :--- | :---: |" 8 | 9 | for subdirectory in "$directory"/*; do 10 | if [ -d "$subdirectory" ]; then 11 | for file in "$subdirectory"/*; do 12 | if [ -f "$file" ]; then 13 | filename=$(basename "$file") 14 | sha256=$(sha256sum "$file" | awk '{print $1}') 15 | echo "| $filename | $sha256 |" 16 | fi 17 | done 18 | fi 19 | done -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "__DEV__": "true" 4 | }, 5 | "exec": "concurrently \"vite build\" \"ts-json-as-const public/locales/en-US.json\" \"prettier --write public/locales/en-US.json.d.ts\" \"eslint --fix public/locales/en-US.json.d.ts\" \"eslint --fix src/i18n/constants.ts\" \"prettier --write src/i18n/constants.ts\"", 6 | "ext": "ts,tsx,css,html,json", 7 | "ignore": ["src/**/*.spec.ts", "public/locales/en-US.json.d.ts", "src/i18n/constants.ts"], 8 | "watch": ["src", "utils", "vite.config.ts", "public", "public/locales"] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-enhancer", 3 | "author": { 4 | "name": "VampireChicken12" 5 | }, 6 | "displayName": "YouTube Enhancer", 7 | "version": "1.27.0", 8 | "description": "YouTube Enhancer is a simple extension that adds some useful features to YouTube.", 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/YouTube-Enhancer/extension.git" 13 | }, 14 | "scripts": { 15 | "build": "concurrently \"vite build\" \"eslint --fix src/i18n/constants.ts\" \"prettier --write src/i18n/constants.ts\"", 16 | "dev": "nodemon", 17 | "format": "prettier --write .", 18 | "lint": "eslint .", 19 | "lint:fix": "eslint . --fix" 20 | }, 21 | "type": "module", 22 | "optionalDependencies": { 23 | "@rollup/rollup-linux-x64-gnu": "^4.14.3", 24 | "ts-json-as-const": "^1.0.7" 25 | }, 26 | "dependencies": { 27 | "@formkit/auto-animate": "^0.8.1", 28 | "@monaco-editor/react": "^4.6.0", 29 | "@tanstack/react-query": "^5.18.0", 30 | "dotenv": "^16.3.1", 31 | "globals": "^15.11.0", 32 | "i18next": "^23.7.3", 33 | "monaco-editor": "^0.51.0", 34 | "react": "^18.2.0", 35 | "react-colorful": "^5.6.1", 36 | "react-dom": "^18.2.0", 37 | "react-icons": "^5.0.1", 38 | "safe-units": "^2.0.1", 39 | "tailwindcss-multi": "^0.4.0", 40 | "use-debouncy": "^5.0.1", 41 | "vite-plugin-css-injected-by-js": "^3.3.0", 42 | "webextension-polyfill": "^0.12.0" 43 | }, 44 | "devDependencies": { 45 | "@eslint/compat": "^1.2.0", 46 | "@eslint/js": "^9.12.0", 47 | "@semantic-release/changelog": "^6.0.3", 48 | "@semantic-release/exec": "^6.0.3", 49 | "@semantic-release/git": "^10.0.1", 50 | "@thedutchcoder/postcss-rem-to-px": "^0.0.2", 51 | "@total-typescript/ts-reset": "^0.6.0", 52 | "@types/archiver": "^6.0.1", 53 | "@types/chrome": "^0.0.273", 54 | "@types/eslint__js": "^8.42.3", 55 | "@types/node": "^22.1.0", 56 | "@types/react": "^18.2.37", 57 | "@types/react-dom": "^18.2.15", 58 | "@types/webextension-polyfill": "^0.12.1", 59 | "@types/youtube-player": "^5.5.10", 60 | "@typescript-eslint/eslint-plugin": "^8.8.1", 61 | "@typescript-eslint/parser": "^8.8.1", 62 | "@vitejs/plugin-react-swc": "^3.4.1", 63 | "archiver": "^7.0.1", 64 | "autoprefixer": "^10.4.16", 65 | "clsx": "^2.0.0", 66 | "concurrently": "^8.2.2", 67 | "eslint": "^9.12.0", 68 | "eslint-config-prettier": "^9.0.0", 69 | "eslint-plugin-import": "^2.31.0", 70 | "eslint-plugin-jsx-a11y": "^6.8.0", 71 | "eslint-plugin-no-secrets": "^1.0.2", 72 | "eslint-plugin-perfectionist": "^3.8.0", 73 | "eslint-plugin-prettier": "^5.2.1", 74 | "eslint-plugin-promise": "^7.1.0", 75 | "eslint-plugin-react": "^7.33.2", 76 | "eslint-plugin-react-hooks": "^5.0.0", 77 | "eslint-plugin-tailwindcss": "^3.13.0", 78 | "fs-extra": "^11.1.1", 79 | "get-installed-browsers": "^0.1.7", 80 | "nodemon": "^3.0.1", 81 | "postcss": "^8.4.31", 82 | "prettier": "^3.0.3", 83 | "semantic-release": "^24.0.0", 84 | "tailwind-merge": "^2.0.0", 85 | "tailwindcss": "^3.3.6", 86 | "ts-json-as-const": "^1.0.7", 87 | "ts-node": "^10.9.1", 88 | "typescript": "5.5", 89 | "typescript-eslint": "^8.8.1", 90 | "vite": "^5.4.8", 91 | "zod": "^3.22.4", 92 | "zod-error": "^1.5.0" 93 | } 94 | } -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable perfectionist/sort-objects */ 2 | module.exports = { 3 | plugins: { 4 | "@thedutchcoder/postcss-rem-to-px": {}, 5 | "tailwindcss/nesting": {}, 6 | autoprefixer: {}, 7 | tailwindcss: {} 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | arrowParens: "always", 4 | endOfLine: "crlf", 5 | experimentalTernaries: true, 6 | printWidth: 150, 7 | semi: true, 8 | singleQuote: false, 9 | tabWidth: 2, 10 | trailingComma: "none", 11 | useTabs: true 12 | }; 13 | -------------------------------------------------------------------------------- /privacy.md: -------------------------------------------------------------------------------- 1 | # YouTube Enhancer Extension 🚀 2 | 3 | Thank you for choosing YouTube Enhancer! This Privacy Policy outlines how we collect, use, and safeguard your information when you use our browser extension. 4 | 5 | ## Information We Collect 6 | 7 | YouTube Enhancer only collects anonymous usage data related to the features of the extension. This data includes, but is not limited to, the frequency of feature usage, preferences selected within the extension, and general interaction with the extension’s interface. 8 | 9 | ## How We Use Collected Information 10 | 11 | The anonymous usage data collected by YouTube Enhancer is used solely for the purpose of improving the functionality and user experience of the extension. This information helps us understand how our users engage with the features offered by the extension, allowing us to make informed decisions about enhancements and optimizations. 12 | 13 | ## Data Security 14 | 15 | We prioritize the security of your information and have implemented appropriate technical and organizational measures to safeguard it. All collected data is stored securely and is accessible only to authorized personnel who require access to perform their duties related to the improvement and maintenance of the extension. 16 | 17 | ## Data Sharing 18 | 19 | We do not share, sell, or distribute any collected data to third parties. The anonymous usage data is strictly used for internal purposes to enhance the YouTube Enhancer extension. 20 | 21 | ## Third-Party Services 22 | 23 | YouTube Enhancer may integrate with third-party services for analytics and improvement purposes. While these services may collect their own data, they are governed by their respective privacy policies and are not under the control of YouTube Enhancer. 24 | 25 | ## Children’s Privacy 26 | 27 | YouTube Enhancer is not intended for use by individuals under the age of 13. We do not knowingly collect personal information from children. If you believe that we have inadvertently collected personal information from a child under the age of 13, please contact us immediately, and we will take steps to remove such information from our systems. 28 | 29 | ## Changes to This Privacy Policy 30 | 31 | We reserve the right to update or modify this Privacy Policy at any time. Any changes will be reflected on this page, and we encourage you to review this Privacy Policy periodically for any updates. 32 | 33 | ## Contact Us 34 | 35 | If you have any questions or concerns about this Privacy Policy or the practices of YouTube Enhancer, please contact us at [support@yt-enhancr.dev](mailto:support@yt-enhancr.dev). 36 | 37 | By using YouTube Enhancer, you signify your acceptance of this Privacy Policy. If you do not agree to this Privacy Policy, please do not use the extension. 38 | 39 | This Privacy Policy was last updated on March 20th 2024. 40 | -------------------------------------------------------------------------------- /public/contentStyle.css: -------------------------------------------------------------------------------- 1 | .yte-button-tooltip { 2 | font-size: 13px !important; 3 | font-weight: 500 !important; 4 | line-height: 15px !important; 5 | position: fixed !important; 6 | padding: 5px 9px; 7 | transform: translate(-50%, -150%) !important; 8 | pointer-events: none !important; 9 | color: #eee !important; 10 | border-radius: 2px !important; 11 | background-color: rgba(28, 28, 28, 0.9) !important; 12 | text-shadow: 0 0 2px rgb(0, 0, 0, 0.5) !important; 13 | } 14 | 15 | body.no-scroll .yte-button-tooltip { 16 | font-size: 20px !important; 17 | line-height: 22px !important; 18 | padding: 8px 9px !important; 19 | transform: translate(-50%, -75%) !important; 20 | } 21 | 22 | .maximized_chapter { 23 | width: 100% !important; 24 | } 25 | 26 | .maximized_video { 27 | width: 100vw !important; 28 | height: 100vh !important; 29 | left: 0 !important; 30 | top: 0 !important; 31 | object-fit: contain !important; 32 | background: black !important; 33 | } 34 | 35 | .maximized_video_container { 36 | position: fixed !important; 37 | top: 0 !important; 38 | left: 0 !important; 39 | width: 100vw !important; 40 | height: 100vh !important; 41 | z-index: 2020 !important; 42 | } 43 | 44 | .maximized_controls { 45 | width: 97vw !important; 46 | right: 12px !important; 47 | } 48 | 49 | .yte-maximize-player-button { 50 | opacity: 0.9; 51 | display: inline-block; 52 | width: 48px; 53 | padding: 0 2px; 54 | -webkit-transition: opacity 0.1s cubic-bezier(0.4, 0, 1, 1); 55 | -o-transition: opacity 0.1s cubic-bezier(0.4, 0, 1, 1); 56 | transition: opacity 0.1s cubic-bezier(0.4, 0, 1, 1); 57 | overflow: hidden; 58 | } 59 | 60 | .ytp-chapter-container { 61 | flex: 1 !important; 62 | } 63 | 64 | .ytp-right-controls { 65 | display: flex !important; 66 | } 67 | 68 | .yte-hide-shorts { 69 | display: none !important; 70 | } 71 | -------------------------------------------------------------------------------- /public/icons/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YouTube-Enhancer/extension/1e203d8737b597f120a42004aada0fe888e00405/public/icons/icon_128.png -------------------------------------------------------------------------------- /public/icons/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YouTube-Enhancer/extension/1e203d8737b597f120a42004aada0fe888e00405/public/icons/icon_16.png -------------------------------------------------------------------------------- /public/icons/icon_19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YouTube-Enhancer/extension/1e203d8737b597f120a42004aada0fe888e00405/public/icons/icon_19.png -------------------------------------------------------------------------------- /public/icons/icon_38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YouTube-Enhancer/extension/1e203d8737b597f120a42004aada0fe888e00405/public/icons/icon_38.png -------------------------------------------------------------------------------- /public/icons/icon_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YouTube-Enhancer/extension/1e203d8737b597f120a42004aada0fe888e00405/public/icons/icon_48.png -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | [ 4 | "@semantic-release/commit-analyzer", 5 | { 6 | preset: "angular", 7 | releaseRules: [ 8 | { type: "translations", release: "patch" }, 9 | { 10 | type: "refactor", 11 | release: "patch" 12 | } 13 | ] 14 | } 15 | ], 16 | "@semantic-release/release-notes-generator", 17 | "@semantic-release/changelog", 18 | [ 19 | "@semantic-release/github", 20 | { 21 | assets: [ 22 | { 23 | path: "releases/**/*.zip" 24 | } 25 | ] 26 | } 27 | ], 28 | [ 29 | "@semantic-release/git", 30 | { 31 | assets: ["CHANGELOG.md", "package.json", "package-lock.json"], 32 | message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 33 | } 34 | ], 35 | [ 36 | "@semantic-release/exec", 37 | { 38 | verifyReleaseCmd: 39 | "node -e \"let packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));packageJson.version = '${nextRelease.version}';fs.writeFileSync('package.json', JSON.stringify(packageJson, null, 2));\";npm run build", 40 | generateNotesCmd: "bash generateReleaseHashes.sh" 41 | } 42 | ] 43 | ], 44 | preset: "angular", 45 | branches: ["main", "dev"] 46 | }; 47 | -------------------------------------------------------------------------------- /src/assets/img/featureMenuGrid.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/img/loopOff.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/img/loopOn.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/img/maximize.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/img/minimize.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/img/volumeBoostOff.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/img/volumeBoostOn.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | ::-webkit-scrollbar { 5 | height: 1rem; 6 | width: 0.5rem; 7 | } 8 | ::-webkit-scrollbar { 9 | background-color: #202324; 10 | color: #aba499; 11 | } 12 | ::-webkit-scrollbar-corner { 13 | background-color: #181a1b; 14 | } 15 | ::-webkit-scrollbar-thumb { 16 | background-color: rgba(43, 46, 48, 0.8); 17 | border-color: rgb(48, 52, 54); 18 | } 19 | ::-webkit-scrollbar-thumb { 20 | background-color: rgba(217, 217, 227, 0.8); 21 | border-radius: 9999px; 22 | border-width: 1px; 23 | } 24 | ::-webkit-scrollbar-thumb { 25 | background-color: #454a4d; 26 | } 27 | ::-webkit-scrollbar-track { 28 | background-color: transparent; 29 | } 30 | ::-webkit-scrollbar-track { 31 | background-color: transparent; 32 | border-radius: 9999px; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Inputs/CSSEditor/EditorProblems/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --problemsHintIcon: #3794ff; 3 | --problemsInfoIcon: #3794ff; 4 | --problemsWarningIcon: #d19a66; 5 | --problemsErrorIcon: #c24038; 6 | } 7 | .marker-icon { 8 | height: 22px; 9 | margin: 0 6px; 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | } 14 | .codicon-error { 15 | color: var(--problemsErrorIcon); 16 | } 17 | .codicon-warning { 18 | color: var(--problemsWarningIcon); 19 | } 20 | .codicon-info { 21 | color: var(--problemsInfoIcon); 22 | } 23 | .codicon-hint { 24 | color: var(--problemsHintIcon); 25 | } 26 | .marker-message-details-container { 27 | flex: 1; 28 | overflow: hidden; 29 | } 30 | .marker-message-line.details-container { 31 | display: flex; 32 | line-height: 22px; 33 | } 34 | .marker-message { 35 | overflow: hidden; 36 | text-overflow: ellipsis; 37 | white-space: nowrap; 38 | } 39 | .marker-source, 40 | .marker-line { 41 | opacity: 0.7; 42 | margin-left: 6px; 43 | } 44 | .marker-code { 45 | opacity: 0.7; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/Inputs/CSSEditor/EditorProblems/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSettings } from "@/src/components/Settings/Settings"; 2 | import { type Nullable } from "@/src/types"; 3 | import { MarkerSeverity, type editor } from "@/src/utils/monaco"; 4 | import { cn } from "@/src/utils/utilities"; 5 | import React, { forwardRef } from "react"; 6 | 7 | import "./index.css"; 8 | type EditorProblemsProps = { 9 | className: string; 10 | editor: Nullable; 11 | problems: editor.IMarker[]; 12 | }; 13 | const EditorProblems = forwardRef(({ className, editor, problems }, ref) => { 14 | const { 15 | i18nInstance: { t } 16 | } = useSettings(); 17 | const getIcon = (severity: MarkerSeverity) => { 18 | switch (severity) { 19 | case MarkerSeverity.Hint: 20 | return "hint"; 21 | case MarkerSeverity.Info: 22 | return "info"; 23 | case MarkerSeverity.Warning: 24 | return "warning"; 25 | case MarkerSeverity.Error: 26 | return "error"; 27 | default: 28 | return ""; 29 | } 30 | }; 31 | return ( 32 |
33 | {problems.length === 0 &&
{t("settings.sections.customCSS.editor.noProblems")}
} 34 | {problems.map((problem, index) => ( 35 |
{ 39 | if (!editor) return; 40 | editor.focus(); 41 | editor.revealLine(problem.startLineNumber); 42 | editor.setPosition({ 43 | column: problem.startColumn, 44 | lineNumber: problem.startLineNumber 45 | }); 46 | }} 47 | > 48 |
49 |
50 |
51 |
52 |
53 |
54 | {problem.message} 55 |
56 | {problem.source && ( 57 | <> 58 |
59 | {problem.source} 60 |
61 |
62 | {problem.code && typeof problem.code === "string" ? `(${problem.code})` : ""} 63 |
64 | 65 | )} 66 | {`[Ln ${problem.startLineNumber}, Col ${problem.startColumn}]`} 67 |
68 |
69 |
70 | ))} 71 |
72 | ); 73 | }); 74 | EditorProblems.displayName = "EditorProblems"; 75 | export default EditorProblems; 76 | -------------------------------------------------------------------------------- /src/components/Inputs/CSSEditor/ExpandButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSettings } from "@/src/components/Settings/Settings"; 2 | import { cn } from "@/src/utils/utilities"; 3 | import { forwardRef } from "react"; 4 | type ExpandButtonProps = { 5 | isExpanded: boolean; 6 | onToggle: () => void; 7 | }; 8 | const ExpandButton = forwardRef(({ isExpanded, onToggle }, ref) => { 9 | const { 10 | i18nInstance: { t } 11 | } = useSettings(); 12 | const buttonValue = isExpanded ? t("settings.sections.customCSS.editor.collapse") : t("settings.sections.customCSS.editor.expand"); 13 | 14 | return ( 15 | 24 | ); 25 | }); 26 | ExpandButton.displayName = "ExpandButton"; 27 | export default ExpandButton; 28 | -------------------------------------------------------------------------------- /src/components/Inputs/CSSEditor/editorOptions.ts: -------------------------------------------------------------------------------- 1 | import type { editor } from "@/src/utils/monaco"; 2 | export const editorOptions: editor.IStandaloneEditorConstructionOptions = { 3 | acceptSuggestionOnCommitCharacter: true, 4 | acceptSuggestionOnEnter: "on", 5 | accessibilitySupport: "auto", 6 | autoIndent: "full", 7 | autoSurround: "languageDefined", 8 | automaticLayout: true, 9 | bracketPairColorization: { 10 | enabled: true, 11 | independentColorPoolPerBracketType: true 12 | }, 13 | codeActionsOnSaveTimeout: 750, 14 | codeLens: true, 15 | colorDecorators: true, 16 | contextmenu: true, 17 | cursorBlinking: "smooth", 18 | cursorSmoothCaretAnimation: "on", 19 | cursorStyle: "line", 20 | disableLayerHinting: false, 21 | disableMonospaceOptimizations: false, 22 | dragAndDrop: false, 23 | fixedOverflowWidgets: true, 24 | folding: true, 25 | foldingHighlight: true, 26 | foldingStrategy: "auto", 27 | fontLigatures: true, 28 | formatOnPaste: true, 29 | formatOnType: true, 30 | guides: { 31 | bracketPairs: "active", 32 | bracketPairsHorizontal: false, 33 | highlightActiveBracketPair: true, 34 | highlightActiveIndentation: true, 35 | indentation: true 36 | }, 37 | hideCursorInOverviewRuler: false, 38 | inlineSuggest: { 39 | enabled: true, 40 | keepOnBlur: true, 41 | mode: "prefix", 42 | showToolbar: "always" 43 | }, 44 | links: true, 45 | mouseWheelZoom: true, 46 | multiCursorMergeOverlapping: true, 47 | multiCursorModifier: "alt", 48 | overviewRulerBorder: true, 49 | overviewRulerLanes: 2, 50 | quickSuggestions: true, 51 | quickSuggestionsDelay: 100, 52 | readOnly: false, 53 | renderControlCharacters: false, 54 | renderFinalNewline: "on", 55 | renderLineHighlight: "all", 56 | renderWhitespace: "none", 57 | revealHorizontalRightPadding: 30, 58 | roundedSelection: true, 59 | rulers: [], 60 | scrollBeyondLastColumn: 5, 61 | scrollBeyondLastLine: false, 62 | selectOnLineNumbers: true, 63 | selectionClipboard: true, 64 | selectionHighlight: true, 65 | "semanticHighlighting.enabled": true, 66 | showFoldingControls: "always", 67 | smoothScrolling: true, 68 | suggestOnTriggerCharacters: true, 69 | suggestSelection: "recentlyUsedByPrefix", 70 | wordBasedSuggestions: "currentDocument", 71 | wordSeparators: "~!@#$%^&*()-=+[{]}|;:'\",.<>/?", 72 | wordWrap: "on", 73 | wordWrapBreakAfterCharacters: "\t})]?|&,;", 74 | wordWrapBreakBeforeCharacters: "{([+", 75 | wordWrapColumn: 80, 76 | wrappingIndent: "same" 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/Inputs/CSSEditor/editorWorkerConfig.ts: -------------------------------------------------------------------------------- 1 | import { monaco } from "@/src/utils/monaco"; 2 | import { loader } from "@monaco-editor/react"; 3 | import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; 4 | import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; 5 | self.MonacoEnvironment = { 6 | getWorker(_, label) { 7 | const castLabel = label as "css" | "html" | "javascript" | "typescript"; 8 | switch (castLabel) { 9 | case "css": { 10 | return new cssWorker(); 11 | } 12 | default: { 13 | return new editorWorker(); 14 | } 15 | } 16 | } 17 | }; 18 | loader.config({ monaco }); 19 | -------------------------------------------------------------------------------- /src/components/Inputs/CSSEditor/index.tsx: -------------------------------------------------------------------------------- 1 | import CSSEditor from "./CSSEditor"; 2 | export { CSSEditor }; 3 | -------------------------------------------------------------------------------- /src/components/Inputs/CheckBox/CheckBox.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/src/utils/utilities"; 2 | import React, { type ChangeEvent } from "react"; 3 | 4 | import { useSettings } from "../../Settings/Settings"; 5 | 6 | export type CheckboxProps = { 7 | checked: boolean; 8 | className?: string; 9 | id?: string; 10 | label: string; 11 | onChange: (event: ChangeEvent) => void; 12 | title: string; 13 | }; 14 | 15 | const Checkbox: React.FC = ({ checked, className, id, label, onChange, title }) => { 16 | const { direction } = useSettings(); 17 | return ( 18 |
19 | 27 | 30 |
31 | ); 32 | }; 33 | 34 | export default Checkbox; 35 | -------------------------------------------------------------------------------- /src/components/Inputs/CheckBox/index.tsx: -------------------------------------------------------------------------------- 1 | import Checkbox from "./CheckBox"; 2 | 3 | export { Checkbox }; 4 | -------------------------------------------------------------------------------- /src/components/Inputs/ColorPicker/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { useComponentVisible } from "@/src/hooks"; 2 | import useClickOutside from "@/src/hooks/useClickOutside"; 3 | import { cn } from "@/src/utils/utilities"; 4 | import React, { type ChangeEvent, useRef } from "react"; 5 | import { HexAlphaColorPicker, HexColorInput } from "react-colorful"; 6 | import { useDebouncyFn } from "use-debouncy"; 7 | 8 | import "./index.css"; 9 | export type ColorPickerProps = { 10 | className?: string; 11 | disabled: boolean; 12 | id?: string; 13 | label: string; 14 | onChange: (event: ChangeEvent) => void; 15 | title: string; 16 | value: string; 17 | }; 18 | const ColorPicker: React.FC = ({ className, disabled, id, label, onChange, title, value }) => { 19 | const handleChange = useDebouncyFn((value: string) => onChange({ currentTarget: { value } } as ChangeEvent), 200); 20 | const colorPickerRef = useRef(null); 21 | const { isComponentVisible: isColorPickerVisible, setIsComponentVisible: setIsColorPickerVisible } = useComponentVisible( 22 | colorPickerRef, 23 | false 24 | ); 25 | const togglePickerVisibility = () => setIsColorPickerVisible(!isColorPickerVisible); 26 | useClickOutside(colorPickerRef, () => (isColorPickerVisible ? togglePickerVisibility() : void 0)); 27 | return ( 28 |
29 | 37 |
38 | <> 39 | 53 | {isColorPickerVisible && ( 54 |
55 | 56 | 64 |
65 | )} 66 | 67 |
68 |
69 | ); 70 | }; 71 | export default ColorPicker; 72 | -------------------------------------------------------------------------------- /src/components/Inputs/ColorPicker/index.css: -------------------------------------------------------------------------------- 1 | .react-colorful { 2 | cursor: pointer !important; 3 | width: 150px !important; 4 | height: 150px !important; 5 | margin: 4px !important; 6 | } 7 | 8 | .react-colorful__saturation { 9 | border-radius: 6px 6px 0 0 !important; 10 | } 11 | 12 | .react-colorful__hue, 13 | .react-colorful__alpha { 14 | height: 16px !important; 15 | } 16 | 17 | .react-colorful__alpha { 18 | border-radius: 0 0 6px 6px !important; 19 | } 20 | 21 | .react-colorful__pointer { 22 | height: 18px !important; 23 | width: 18px !important; 24 | border: 1px solid #fff !important; 25 | } 26 | 27 | input#color-picker-input { 28 | margin: 4px; 29 | width: 150px; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Inputs/ColorPicker/index.tsx: -------------------------------------------------------------------------------- 1 | import ColorPicker from "./ColorPicker"; 2 | 3 | export { ColorPicker }; 4 | -------------------------------------------------------------------------------- /src/components/Inputs/Number/Arrow.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/src/utils/utilities"; 2 | 3 | type RotationDirection = "down" | "left" | "right" | "up"; 4 | export default function Arrow({ rotation }: { rotation: RotationDirection }) { 5 | return ( 6 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Inputs/Number/Number.css: -------------------------------------------------------------------------------- 1 | input::-webkit-outer-spin-button, 2 | input::-webkit-inner-spin-button { 3 | -webkit-appearance: none; 4 | margin: 0; 5 | } 6 | 7 | /* Firefox */ 8 | input[type="number"] { 9 | -moz-appearance: textfield; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Inputs/Number/Number.tsx: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from "clsx"; 2 | import type { ChangeEvent } from "react"; 3 | 4 | import { type Nullable } from "@/src/types"; 5 | import { cn } from "@/src/utils/utilities"; 6 | import React, { useRef } from "react"; 7 | 8 | import { useSettings } from "../../Settings/Settings"; 9 | import Arrow from "./Arrow"; 10 | import "./Number.css"; 11 | export type NumberInputProps = { 12 | className?: string; 13 | disabled: boolean; 14 | id?: string; 15 | label: string; 16 | max?: number; 17 | min?: number; 18 | onChange: (event: ChangeEvent) => void; 19 | step?: number; 20 | value: number; 21 | }; 22 | 23 | const NumberInput: React.FC = ({ className, disabled, id, label, max = undefined, min = 0, onChange, step = 1, value }) => { 24 | const inputElement = useRef>(null); 25 | const inputDiv = useRef>(null); 26 | const { direction } = useSettings(); 27 | const NumberPlus = () => { 28 | if (inputElement.current) { 29 | inputElement.current.stepUp(); 30 | handleChange(inputElement.current.value); 31 | } 32 | }; 33 | 34 | const NumberMinus = () => { 35 | if (inputElement.current) { 36 | inputElement.current.stepDown(); 37 | handleChange(inputElement.current.value); 38 | } 39 | }; 40 | 41 | const handleChange = (value: string) => { 42 | if (min && parseFloat(value) < min) value = min + ""; 43 | if (max && parseFloat(value) > max) value = max + ""; 44 | 45 | if (!isNaN(parseFloat(value))) { 46 | onChange({ currentTarget: { value } } as ChangeEvent); 47 | } 48 | }; 49 | 50 | const disabledButtonClasses = { 51 | "cursor-pointer": !disabled, 52 | "dark:hover:bg-transparent": disabled, 53 | "dark:text-[#4b5563]": disabled, 54 | "hover:bg-transparent": disabled, 55 | "text-[#4b5563]": disabled 56 | } satisfies ClassValue; 57 | const buttonClasses = 58 | "flex h-1/2 w-full cursor-default justify-center p-1 items-center text-black hover:bg-[rgba(24,26,27,0.5)] dark:bg-[#23272a] dark:text-white" satisfies ClassValue; 59 | return ( 60 |
61 | 64 |
65 | handleChange(e.currentTarget.value)} 76 | ref={inputElement} 77 | step={step} 78 | style={{ 79 | borderBottomLeftRadius: "0.375rem", 80 | borderTopLeftRadius: "0.375rem" 81 | }} 82 | type="number" 83 | value={value} 84 | > 85 |
91 | 105 | 119 |
120 |
121 |
122 | ); 123 | }; 124 | 125 | export default NumberInput; 126 | -------------------------------------------------------------------------------- /src/components/Inputs/Number/index.tsx: -------------------------------------------------------------------------------- 1 | import NumberInput from "./Number"; 2 | export { NumberInput }; 3 | -------------------------------------------------------------------------------- /src/components/Inputs/Select/index.tsx: -------------------------------------------------------------------------------- 1 | import Select from "./Select"; 2 | 3 | export { Select }; 4 | -------------------------------------------------------------------------------- /src/components/Inputs/Slider/Slider.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ChangeEvent, useState } from "react"; 2 | 3 | export type SliderProps = { 4 | initialValue?: number; 5 | max: number; 6 | min: number; 7 | onChange: (value: ChangeEvent) => void; 8 | step: number; 9 | }; 10 | 11 | const Slider: React.FC = ({ initialValue, max, min, onChange, step }) => { 12 | const [value, setValue] = useState(initialValue ?? 1); 13 | 14 | const handleChange = (event: React.ChangeEvent) => { 15 | const newValue = parseInt(event.target.value, 10); 16 | setValue(newValue); 17 | onChange(event); 18 | }; 19 | 20 | return ( 21 |
22 | 31 |
32 | ); 33 | }; 34 | 35 | export default Slider; 36 | -------------------------------------------------------------------------------- /src/components/Inputs/Slider/index.tsx: -------------------------------------------------------------------------------- 1 | import Slider from "./Slider"; 2 | 3 | export { Slider }; 4 | -------------------------------------------------------------------------------- /src/components/Inputs/TextInput/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import type { Nullable } from "@/src/types"; 2 | import type { ChangeEvent } from "react"; 3 | 4 | import { cn, debounce } from "@/src/utils/utilities"; 5 | import React, { useCallback, useRef, useState } from "react"; 6 | import { IoMdEye, IoMdEyeOff } from "react-icons/io"; 7 | 8 | export type TextInputProps = { 9 | className?: string; 10 | id: string; 11 | input_type: "password" | "text"; 12 | label: string; 13 | onChange: (event: ChangeEvent) => void; 14 | title: string; 15 | value: string; 16 | }; 17 | 18 | const TextInput: React.FC = ({ className, id, input_type, label, onChange, title, value }) => { 19 | const [showPassword, setShowPassword] = useState(false); 20 | const debouncedOnChange = useCallback(debounce(onChange, 300), []); 21 | const inputRef = useRef>(null); 22 | const handleInputWrapperClick = () => { 23 | inputRef.current?.focus(); 24 | }; 25 | // FIXME: cursor not being restored to position it was in when value is saved 26 | return ( 27 |
28 | 29 |
33 | {input_type === "password" && ( 34 | 43 | )} 44 | { 48 | debouncedOnChange({ currentTarget: { value } }); 49 | }} 50 | ref={inputRef} 51 | type={showPassword && input_type === "password" ? "text" : input_type} 52 | value={value} 53 | /> 54 |
55 |
56 | ); 57 | }; 58 | 59 | export default TextInput; 60 | -------------------------------------------------------------------------------- /src/components/Inputs/TextInput/index.tsx: -------------------------------------------------------------------------------- 1 | import TextInput from "./TextInput"; 2 | 3 | export { TextInput }; 4 | -------------------------------------------------------------------------------- /src/components/Inputs/index.tsx: -------------------------------------------------------------------------------- 1 | import type { SelectOption } from "./Select/Select"; 2 | 3 | import { CSSEditor } from "./CSSEditor"; 4 | import { Checkbox } from "./CheckBox"; 5 | import { ColorPicker } from "./ColorPicker"; 6 | import { NumberInput } from "./Number"; 7 | import { Select } from "./Select"; 8 | import { Slider } from "./Slider"; 9 | import { TextInput } from "./TextInput"; 10 | export { CSSEditor, Checkbox, ColorPicker, NumberInput, Select, type SelectOption, Slider, TextInput }; 11 | -------------------------------------------------------------------------------- /src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | 3 | import { cn } from "@/src/utils/utilities"; 4 | 5 | type LinkProps = { 6 | children: ReactNode; 7 | className?: string; 8 | } & React.DetailedHTMLProps, HTMLAnchorElement>; 9 | export default function Link({ children, className, ...props }: LinkProps) { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from "clsx"; 2 | 3 | import { cn } from "../utils/utilities"; 4 | 5 | type LoaderProps = { 6 | className?: ClassValue; 7 | }; 8 | export default function Loader({ className }: LoaderProps) { 9 | return ( 10 | // eslint-disable-next-line tailwindcss/enforces-shorthand 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Settings/components/Setting.tsx: -------------------------------------------------------------------------------- 1 | import type { configurationId } from "@/src/types"; 2 | 3 | import useSectionTitle from "@/src/hooks/useSectionTitle"; 4 | import useSettingsFilter from "@/src/hooks/useSettingsFilter"; 5 | 6 | import type { CSSEditorProps } from "../../Inputs/CSSEditor/CSSEditor"; 7 | import type { CheckboxProps } from "../../Inputs/CheckBox/CheckBox"; 8 | import type { ColorPickerProps } from "../../Inputs/ColorPicker/ColorPicker"; 9 | import type { NumberInputProps } from "../../Inputs/Number/Number"; 10 | import type { SelectProps } from "../../Inputs/Select/Select"; 11 | import type { SliderProps } from "../../Inputs/Slider/Slider"; 12 | import type { TextInputProps } from "../../Inputs/TextInput/TextInput"; 13 | 14 | import { CSSEditor, Checkbox, ColorPicker, NumberInput, Select, Slider, TextInput } from "../../Inputs"; 15 | 16 | type SettingInputProps = { 17 | id: ID; 18 | label?: string; 19 | title?: string; 20 | } & ( 21 | | ({ type: "checkbox" } & CheckboxProps) 22 | | ({ type: "color-picker" } & ColorPickerProps) 23 | | ({ type: "css-editor" } & CSSEditorProps) 24 | | ({ type: "number" } & NumberInputProps) 25 | | ({ type: "select" } & SelectProps) 26 | | ({ type: "slider" } & SliderProps) 27 | | ({ type: "text-input" } & TextInputProps) 28 | ); 29 | function SettingInput(settingProps: SettingInputProps) { 30 | const { type } = settingProps; 31 | switch (type) { 32 | case "checkbox": { 33 | const { checked, className, id, label, onChange, title } = settingProps; 34 | return ; 35 | } 36 | case "number": { 37 | const { className, disabled, id, label, max, min, onChange, step, value } = settingProps; 38 | return ( 39 | 50 | ); 51 | } 52 | case "select": { 53 | const { className, disabled, id, label, loading, onChange, options, selectedOption, title } = settingProps; 54 | return ( 55 | ) => setFilter(e.target.value)} 18 | placeholder={t("settings.sections.settingSearch.placeholder")} 19 | ref={inputRef} 20 | type="text" 21 | value={filter} 22 | /> 23 | inputRef.current?.focus()} 27 | /> 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Settings/components/SettingSection.tsx: -------------------------------------------------------------------------------- 1 | import { SectionTitleProvider } from "@/src/hooks/useSectionTitle/provider"; 2 | import useSettingsFilter from "@/src/hooks/useSettingsFilter"; 3 | 4 | interface SettingSectionProps { 5 | children: React.ReactNode[]; 6 | className?: string; 7 | title: string; 8 | } 9 | export default function SettingSection({ children, className = "", title: sectionTitle = "" }: SettingSectionProps) { 10 | const { filter } = useSettingsFilter(); 11 | if (children.length === 0) return null; 12 | if (filter === "") 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | const shouldSectionBeVisible = 19 | (sectionTitle && sectionTitle.toLowerCase().includes(filter.toLowerCase())) || 20 | (children as React.ReactElement<{ label?: string; title?: string }>[]).filter((child) => { 21 | if (!child) return false; 22 | if (!child.props) return false; 23 | return ( 24 | (child.props.label !== undefined && child.props.label.toLowerCase().includes(filter.toLowerCase())) || 25 | (child.props.title !== undefined && child.props.title.toLowerCase().includes(filter.toLowerCase())) 26 | ); 27 | }).length > 0; 28 | return shouldSectionBeVisible ? 29 | 30 | {children} 31 | 32 | : null; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Settings/components/SettingTitle.tsx: -------------------------------------------------------------------------------- 1 | import useSectionTitle from "@/src/hooks/useSectionTitle"; 2 | import useSettingsFilter from "@/src/hooks/useSettingsFilter"; 3 | 4 | export default function SettingTitle() { 5 | const { filter } = useSettingsFilter(); 6 | const { title } = useSectionTitle(); 7 | const shouldSettingTitleBeVisible = filter === "" ? true : title.toLowerCase().includes(filter.toLowerCase()); 8 | return shouldSettingTitleBeVisible ? {title} : null; 9 | } 10 | -------------------------------------------------------------------------------- /src/defaults.ts: -------------------------------------------------------------------------------- 1 | import { deepMerge } from "@/src/utils/utilities"; 2 | 3 | import { defaultConfiguration } from "./utils/constants"; 4 | 5 | function setDefaultValues() { 6 | // Iterate over each option in the default configuration 7 | for (const option of Object.keys(defaultConfiguration)) { 8 | // Get the stored value from local storage 9 | const storedValueString = localStorage.getItem(option); 10 | // Destructure the default value for the current option 11 | const { [option]: defaultValue } = defaultConfiguration; 12 | // Check if the stored value is missing or an empty string 13 | if (storedValueString === null) { 14 | // Set the default value in localStorage after stringifying 15 | localStorage.setItem(option, typeof defaultValue === "string" ? defaultValue : JSON.stringify(defaultValue)); 16 | // Set the default value in chrome storage 17 | void chrome.storage.local.set({ [option]: defaultValue }); 18 | continue; 19 | } 20 | try { 21 | // Parse the stored value to check its type 22 | const storedValue = 23 | ( 24 | typeof defaultConfiguration[option] === "object" || 25 | typeof defaultConfiguration[option] === "boolean" || 26 | typeof defaultConfiguration[option] === "number" 27 | ) ? 28 | JSON.parse(storedValueString) 29 | : storedValueString; 30 | // Check if the parsed value is an object and has properties 31 | if (typeof storedValue === "object" && storedValue !== null) { 32 | // Deep merge missing keys with their default values 33 | const updatedValue = deepMerge(defaultValue as Record, storedValue as Record); 34 | // Set the updated value in localStorage 35 | localStorage.setItem(option, JSON.stringify(updatedValue)); 36 | // Set the updated value in chrome storage 37 | void chrome.storage.local.set({ [option]: updatedValue }); 38 | } 39 | } catch (error) { 40 | // Handle errors during JSON parsing 41 | console.error(`Error parsing stored value for option ${option}:`, error); 42 | } 43 | } 44 | } 45 | setDefaultValues(); 46 | -------------------------------------------------------------------------------- /src/features/automaticTheaterMode/index.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv } from "@/src/types"; 2 | 3 | import { isLivePage, isWatchPage, waitForSpecificMessage } from "@/src/utils/utilities"; 4 | 5 | export async function enableAutomaticTheaterMode() { 6 | // Wait for the "options" message from the content script 7 | const { 8 | data: { 9 | options: { enable_automatic_theater_mode } 10 | } 11 | } = await waitForSpecificMessage("options", "request_data", "content"); 12 | // If automatic theater mode isn't enabled return 13 | if (!enable_automatic_theater_mode) return; 14 | // Get the player element 15 | const playerContainer = isWatchPage() || isLivePage() ? document.querySelector("div#movie_player") : null; 16 | // If player element is not available, return 17 | if (!playerContainer) return; 18 | const { width } = await playerContainer.getSize(); 19 | const { 20 | body: { clientWidth } 21 | } = document; 22 | const isTheaterMode = width === clientWidth; 23 | // Get the size button 24 | const sizeButton = document.querySelector("button.ytp-size-button"); 25 | // If the size button is not available return 26 | if (!sizeButton) return; 27 | if (!isTheaterMode) { 28 | sizeButton.click(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/features/automaticallyDisableClosedCaptions/index.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv } from "@/src/types"; 2 | import { isLivePage, isWatchPage, waitForAllElements, waitForSpecificMessage } from "@/src/utils/utilities"; 3 | let captionsWhereEnabled = false; 4 | export async function enableAutomaticallyDisableClosedCaptions() { 5 | const { 6 | data: { 7 | options: { enable_automatically_disable_closed_captions } 8 | } 9 | } = await waitForSpecificMessage("options", "request_data", "content"); 10 | if (!enable_automatically_disable_closed_captions) return; 11 | await waitForAllElements(["div#player", "div#player-wide-container", "div#video-container", "div#player-container"]); 12 | // Get the player element 13 | const playerContainer = isWatchPage() || isLivePage() ? document.querySelector("div#movie_player") : null; 14 | const subtitlesButton = document.querySelector("button.ytp-subtitles-button"); 15 | // If player element is not available, return 16 | if (!playerContainer || !subtitlesButton) return; 17 | captionsWhereEnabled = subtitlesButton.getAttribute("aria-pressed") === "true"; 18 | // Disable captions 19 | playerContainer.unloadModule("captions"); 20 | } 21 | export async function disableAutomaticallyDisableClosedCaptions() { 22 | await waitForAllElements(["div#player", "div#player-wide-container", "div#video-container", "div#player-container"]); 23 | // Get the player element 24 | const playerContainer = isWatchPage() || isLivePage() ? document.querySelector("div#movie_player") : null; 25 | // If player element is not available, return 26 | if (!playerContainer) return; 27 | // If captions weren't enabled, return 28 | if (!captionsWhereEnabled) return; 29 | // Re-enable captions 30 | playerContainer.loadModule("captions"); 31 | } 32 | -------------------------------------------------------------------------------- /src/features/buttonPlacement/index.ts: -------------------------------------------------------------------------------- 1 | import type { GetIconType } from "@/src/icons"; 2 | import type { AllButtonNames, ButtonPlacement, MultiButtonNames, SingleButtonFeatureNames } from "@/src/types"; 3 | 4 | import { addFeatureItemToMenu, removeFeatureItemFromMenu } from "@/src/features/featureMenu/utils"; 5 | import { findKeyByValue, removeTooltip, waitForSpecificMessage } from "@/src/utils/utilities"; 6 | 7 | import { type ListenerType, getFeatureButtonId, makeFeatureButton, placeButton } from "./utils"; 8 | export const featuresInControls = new Set(); 9 | 10 | export async function addFeatureButton( 11 | buttonName: Name, 12 | placement: Placement, 13 | label: Label, 14 | icon: GetIconType, 15 | listener: ListenerType, 16 | isToggle: boolean, 17 | initialChecked: boolean = false 18 | ) { 19 | switch (placement) { 20 | case "feature_menu": { 21 | if (icon instanceof SVGSVGElement) await addFeatureItemToMenu(buttonName, label, icon, listener, isToggle, initialChecked); 22 | break; 23 | } 24 | case "below_player": 25 | case "player_controls_left": 26 | case "player_controls_right": { 27 | // Add the feature name to the set of features in the controls 28 | featuresInControls.add(buttonName); 29 | const button = makeFeatureButton(buttonName, placement, label, icon, listener, isToggle, initialChecked); 30 | placeButton(button, placement); 31 | break; 32 | } 33 | } 34 | } 35 | export async function removeFeatureButton(buttonName: Name, placement?: ButtonPlacement) { 36 | const featureName = findKeyByValue(buttonName as MultiButtonNames) ?? (buttonName as SingleButtonFeatureNames); 37 | if (placement === undefined) { 38 | // Wait for the "options" message from the content script 39 | ({ 40 | data: { 41 | options: { 42 | button_placements: { [buttonName]: placement } 43 | } 44 | } 45 | } = await waitForSpecificMessage("options", "request_data", "content")); 46 | } 47 | switch (placement) { 48 | case "feature_menu": { 49 | removeFeatureItemFromMenu(buttonName); 50 | break; 51 | } 52 | case "below_player": 53 | case "player_controls_left": 54 | case "player_controls_right": { 55 | // Remove the feature name from the set of features in the controls 56 | featuresInControls.delete(buttonName); 57 | const button = document.querySelector(`#${getFeatureButtonId(buttonName)}`); 58 | if (!button) return; 59 | button.remove(); 60 | removeTooltip(`yte-feature-${featureName}-tooltip`); 61 | break; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/features/copyTimestampUrlButton/index.ts: -------------------------------------------------------------------------------- 1 | import type { AddButtonFunction, RemoveButtonFunction } from "@/src/features"; 2 | 3 | import { addFeatureButton, removeFeatureButton } from "@/src/features/buttonPlacement"; 4 | import { getFeatureButton } from "@/src/features/buttonPlacement/utils"; 5 | import { getFeatureIcon } from "@/src/icons"; 6 | import eventManager from "@/src/utils/EventManager"; 7 | import { createTooltip, waitForSpecificMessage } from "@/src/utils/utilities"; 8 | 9 | export const addCopyTimestampUrlButton: AddButtonFunction = async () => { 10 | const { 11 | data: { 12 | options: { 13 | button_placements: { copyTimestampUrlButton: copyTimestampUrlButtonPlacement }, 14 | enable_copy_timestamp_url_button: enableCopyTimestampUrlButton 15 | } 16 | } 17 | } = await waitForSpecificMessage("options", "request_data", "content"); 18 | if (!enableCopyTimestampUrlButton) return; 19 | function copyTimestampUrlButtonClickListener() { 20 | const videoElement = document.querySelector("video"); 21 | if (!videoElement) return; 22 | const videoId = new URLSearchParams(window.location.search).get("v"); 23 | const timestampUrl = `https://youtu.be/${videoId}?t=${videoElement.currentTime.toFixed()}`; 24 | void navigator.clipboard.writeText(timestampUrl); 25 | const button = getFeatureButton("copyTimestampUrlButton"); 26 | if (!button) return; 27 | const { remove, update } = createTooltip({ 28 | direction: copyTimestampUrlButtonPlacement === "below_player" ? "down" : "up", 29 | element: button, 30 | featureName: "copyTimestampUrlButton", 31 | id: "yte-feature-copyTimestampUrlButton-tooltip" 32 | }); 33 | button.dataset.title = window.i18nextInstance.t("pages.content.features.copyTimestampUrlButton.button.copied"); 34 | update(); 35 | setTimeout(() => { 36 | remove(); 37 | button.dataset.title = window.i18nextInstance.t("pages.content.features.copyTimestampUrlButton.button.label"); 38 | update(); 39 | }, 1000); 40 | } 41 | await addFeatureButton( 42 | "copyTimestampUrlButton", 43 | copyTimestampUrlButtonPlacement, 44 | window.i18nextInstance.t("pages.content.features.copyTimestampUrlButton.button.label"), 45 | getFeatureIcon("copyTimestampUrlButton", copyTimestampUrlButtonPlacement), 46 | copyTimestampUrlButtonClickListener, 47 | false 48 | ); 49 | }; 50 | 51 | export const removeCopyTimestampUrlButton: RemoveButtonFunction = async (placement) => { 52 | await removeFeatureButton("copyTimestampUrlButton", placement); 53 | eventManager.removeEventListeners("copyTimestampUrlButton"); 54 | }; 55 | -------------------------------------------------------------------------------- /src/features/customCSS/index.ts: -------------------------------------------------------------------------------- 1 | import { waitForSpecificMessage } from "@/src/utils/utilities"; 2 | 3 | import { createCustomCSSElement, customCSSExists, updateCustomCSS } from "./utils"; 4 | export const customCssID = "yte-custom-css"; 5 | export async function enableCustomCSS() { 6 | // Wait for the "options" message from the content script 7 | const { 8 | data: { 9 | options: { custom_css_code, enable_custom_css } 10 | } 11 | } = await waitForSpecificMessage("options", "request_data", "content"); 12 | // Check if custom CSS is enabled 13 | if (!enable_custom_css) return; 14 | if (customCSSExists()) { 15 | updateCustomCSS({ 16 | custom_css_code 17 | }); 18 | return; 19 | } 20 | // Create the custom CSS style element 21 | const customCSSStyleElement = createCustomCSSElement({ 22 | custom_css_code 23 | }); 24 | // Insert the custom CSS style element 25 | document.head.appendChild(customCSSStyleElement); 26 | } 27 | export function disableCustomCSS() { 28 | // Get the custom CSS style element 29 | const customCSSStyleElement = document.querySelector(`#${customCssID}`); 30 | // Check if the custom CSS style element exists 31 | if (!customCSSStyleElement) return; 32 | // Remove the custom CSS style element 33 | customCSSStyleElement.remove(); 34 | } 35 | -------------------------------------------------------------------------------- /src/features/customCSS/utils.ts: -------------------------------------------------------------------------------- 1 | import type { configuration } from "@/src/types"; 2 | 3 | import { customCssID } from "@/src/features/customCSS"; 4 | 5 | export function updateCustomCSS({ custom_css_code }: Pick) { 6 | // Get the custom CSS style element 7 | const customCSSStyleElement = document.querySelector(`#${customCssID}`); 8 | // Check if the custom CSS style element exists 9 | if (!customCSSStyleElement) return; 10 | customCSSStyleElement.replaceWith(createCustomCSSElement({ custom_css_code })); 11 | } 12 | export function createCustomCSSElement({ custom_css_code }: Pick) { 13 | // Create the custom CSS style element 14 | const customCSSStyleElement = document.createElement("style"); 15 | customCSSStyleElement.id = customCssID; 16 | customCSSStyleElement.textContent = custom_css_code; 17 | return customCSSStyleElement; 18 | } 19 | export function customCSSExists() { 20 | // Get the custom CSS style element 21 | const customCSSStyleElement = document.querySelector(`#${customCssID}`); 22 | // Check if the custom CSS style element exists 23 | if (!customCSSStyleElement) return false; 24 | return true; 25 | } 26 | -------------------------------------------------------------------------------- /src/features/deepDarkCSS/index.ts: -------------------------------------------------------------------------------- 1 | import { deepDarkPresets } from "@/src/deepDarkPresets"; 2 | import { waitForSpecificMessage } from "@/src/utils/utilities"; 3 | 4 | import { createDeepDarkCSSElement, deepDarkCSSExists, getDeepDarkCustomThemeStyle, updateDeepDarkCSS } from "./utils"; 5 | export const deepDarkCssID = "yte-deep-dark-css"; 6 | export async function enableDeepDarkCSS() { 7 | // Wait for the "options" message from the content script 8 | const { 9 | data: { 10 | options: { deep_dark_custom_theme_colors, deep_dark_preset, enable_deep_dark_theme } 11 | } 12 | } = await waitForSpecificMessage("options", "request_data", "content"); 13 | // Check if deep dark theme is enabled 14 | if (!enable_deep_dark_theme) return; 15 | if (deepDarkCSSExists()) { 16 | updateDeepDarkCSS(deep_dark_preset === "Custom" ? getDeepDarkCustomThemeStyle(deep_dark_custom_theme_colors) : deepDarkPresets[deep_dark_preset]); 17 | return; 18 | } 19 | // Create the deep dark theme style element 20 | const deepDarkThemeStyleElement = createDeepDarkCSSElement( 21 | deep_dark_preset === "Custom" ? getDeepDarkCustomThemeStyle(deep_dark_custom_theme_colors) : deepDarkPresets[deep_dark_preset] 22 | ); 23 | // Insert the deep dark theme style element 24 | document.head.appendChild(deepDarkThemeStyleElement); 25 | } 26 | 27 | export function disableDeepDarkCSS() { 28 | // Get the deep dark theme style element 29 | const deepDarkThemeStyleElement = document.querySelector(`#${deepDarkCssID}`); 30 | // Check if the deep dark theme style element exists 31 | if (!deepDarkThemeStyleElement) return; 32 | // Remove the deep dark theme style element 33 | deepDarkThemeStyleElement.remove(); 34 | } 35 | -------------------------------------------------------------------------------- /src/features/deepDarkCSS/utils.ts: -------------------------------------------------------------------------------- 1 | import type { DeepDarkCustomThemeColors } from "@/src/types"; 2 | 3 | import { deepDarkMaterial } from "@/src/deepDarkMaterialCSS"; 4 | import { deepDarkCssID } from "@/src/features/deepDarkCSS"; 5 | 6 | export function updateDeepDarkCSS(css_code: string) { 7 | // Get the custom CSS style element 8 | const customCSSStyleElement = document.querySelector(`#${deepDarkCssID}`); 9 | // Check if the custom CSS style element exists 10 | if (!customCSSStyleElement) return; 11 | customCSSStyleElement.replaceWith(createDeepDarkCSSElement(css_code)); 12 | } 13 | export function createDeepDarkCSSElement(css_code: string) { 14 | // Create the custom CSS style element 15 | const customCSSStyleElement = document.createElement("style"); 16 | customCSSStyleElement.id = deepDarkCssID; 17 | customCSSStyleElement.textContent = `${deepDarkMaterial}\n${css_code}`; 18 | return customCSSStyleElement; 19 | } 20 | export function deepDarkCSSExists() { 21 | // Get the custom CSS style element 22 | const customCSSStyleElement = document.querySelector(`#${deepDarkCssID}`); 23 | // Check if the custom CSS style element exists 24 | if (!customCSSStyleElement) return false; 25 | return true; 26 | } 27 | 28 | export function getDeepDarkCustomThemeStyle({ 29 | colorShadow, 30 | dimmerText, 31 | hoverBackground, 32 | mainBackground, 33 | mainColor, 34 | mainText, 35 | secondBackground 36 | }: DeepDarkCustomThemeColors) { 37 | return `:root { 38 | --main-color: ${mainColor}; 39 | --main-background: ${mainBackground}; 40 | --second-background: ${secondBackground}; 41 | --hover-background: ${hoverBackground}; 42 | --main-text: ${mainText}; 43 | --dimmer-text: ${dimmerText}; 44 | --shadow: 0 1px 0.5px ${colorShadow}; 45 | }`; 46 | } 47 | -------------------------------------------------------------------------------- /src/features/forwardRewindButtons/index.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv } from "@/src/types"; 2 | 3 | import { addFeatureButton, removeFeatureButton } from "@/src/features/buttonPlacement"; 4 | import { getFeatureIcon } from "@/src/icons"; 5 | import eventManager from "@/src/utils/EventManager"; 6 | import { isWatchPage, waitForSpecificMessage } from "@/src/utils/utilities"; 7 | import { Measure, seconds } from "safe-units"; 8 | 9 | import type { AddButtonFunction, RemoveButtonFunction } from "../index"; 10 | const speedButtonListener = async (direction: "backward" | "forward", timeAdjustment: number) => { 11 | // Get the player element 12 | const playerContainer = document.querySelector("div#movie_player"); 13 | // If player element is not available, return 14 | if (!playerContainer) return; 15 | if (!playerContainer.seekTo) return; 16 | const currentTime = await playerContainer.getCurrentTime(); 17 | await playerContainer.seekTo(currentTime + timeAdjustment * (direction === "forward" ? 1 : -1), true); 18 | }; 19 | export const addForwardButton: AddButtonFunction = async () => { 20 | const { 21 | data: { 22 | options: { 23 | button_placements: { forwardButton: forwardButtonPlacement }, 24 | enable_forward_rewind_buttons, 25 | forward_rewind_buttons_time 26 | } 27 | } 28 | } = await waitForSpecificMessage("options", "request_data", "content"); 29 | if (!enable_forward_rewind_buttons) return; 30 | if (!isWatchPage()) return; 31 | // Get the player element 32 | const playerContainer = document.querySelector("div#movie_player"); 33 | // If player element is not available, return 34 | if (!playerContainer) return; 35 | const playerVideoData = await playerContainer.getVideoData(); 36 | // If the video is live return 37 | if (playerVideoData.isLive) return; 38 | await addFeatureButton( 39 | "forwardButton", 40 | forwardButtonPlacement, 41 | window.i18nextInstance.t("pages.content.features.forwardRewindButtons.buttons.forwardButton.label", { 42 | TIME: Measure.of(forward_rewind_buttons_time, seconds).toString() 43 | }), 44 | getFeatureIcon("forwardButton", forwardButtonPlacement), 45 | () => void speedButtonListener("forward", forward_rewind_buttons_time), 46 | false 47 | ); 48 | }; 49 | export const addRewindButton: AddButtonFunction = async () => { 50 | const { 51 | data: { 52 | options: { 53 | button_placements: { rewindButton: rewindButtonPlacement }, 54 | enable_forward_rewind_buttons, 55 | forward_rewind_buttons_time 56 | } 57 | } 58 | } = await waitForSpecificMessage("options", "request_data", "content"); 59 | if (!enable_forward_rewind_buttons) return; 60 | if (!isWatchPage()) return; 61 | // Get the player element 62 | const playerContainer = document.querySelector("div#movie_player"); 63 | // If player element is not available, return 64 | if (!playerContainer) return; 65 | const playerVideoData = await playerContainer.getVideoData(); 66 | // If the video is live return 67 | if (playerVideoData.isLive) return; 68 | await addFeatureButton( 69 | "rewindButton", 70 | rewindButtonPlacement, 71 | window.i18nextInstance.t("pages.content.features.forwardRewindButtons.buttons.rewindButton.label", { 72 | TIME: Measure.of(forward_rewind_buttons_time, seconds).toString() 73 | }), 74 | getFeatureIcon("rewindButton", rewindButtonPlacement), 75 | () => void speedButtonListener("backward", forward_rewind_buttons_time), 76 | false 77 | ); 78 | }; 79 | 80 | export const removeForwardButton: RemoveButtonFunction = async (placement) => { 81 | await removeFeatureButton("forwardButton", placement); 82 | eventManager.removeEventListeners("forwardRewindButtons"); 83 | }; 84 | export const removeRewindButton: RemoveButtonFunction = async (placement) => { 85 | await removeFeatureButton("rewindButton", placement); 86 | eventManager.removeEventListeners("forwardRewindButtons"); 87 | }; 88 | -------------------------------------------------------------------------------- /src/features/hideEndScreenCards/index.css: -------------------------------------------------------------------------------- 1 | .yte-hide-end-screen-cards { 2 | display: none !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/features/hideEndScreenCards/index.ts: -------------------------------------------------------------------------------- 1 | import type { AddButtonFunction, RemoveButtonFunction } from "@/src/features"; 2 | import type { ButtonPlacement, YouTubePlayerDiv } from "@/src/types"; 3 | 4 | import { addFeatureButton, removeFeatureButton } from "@/src/features/buttonPlacement"; 5 | import { updateFeatureButtonTitle } from "@/src/features/buttonPlacement/utils"; 6 | import { getFeatureIcon } from "@/src/icons"; 7 | import eventManager from "@/src/utils/EventManager"; 8 | import { isWatchPage, modifyElementsClassList, waitForAllElements, waitForSpecificMessage } from "@/src/utils/utilities"; 9 | 10 | import "./index.css"; 11 | export async function enableHideEndScreenCards() { 12 | const { 13 | data: { 14 | options: { enable_hide_end_screen_cards: enableHideEndScreenCards } 15 | } 16 | } = await waitForSpecificMessage("options", "request_data", "content"); 17 | if (!enableHideEndScreenCards) return; 18 | if (!isWatchPage()) return; 19 | await waitForAllElements(["div#player", "div#player-wide-container", "div#video-container", "div#player-container"]); 20 | hideEndScreenCards(); 21 | } 22 | 23 | export async function disableHideEndScreenCards() { 24 | if (!isWatchPage()) return; 25 | await waitForAllElements(["div#player", "div#player-wide-container", "div#video-container", "div#player-container"]); 26 | showEndScreenCards(); 27 | } 28 | export const addHideEndScreenCardsButton: AddButtonFunction = async () => { 29 | const { 30 | data: { 31 | options: { 32 | button_placements: { hideEndScreenCardsButton: hideEndScreenCardsButtonPlacement }, 33 | enable_hide_end_screen_cards_button: enableHideEndScreenCardsButton 34 | } 35 | } 36 | } = await waitForSpecificMessage("options", "request_data", "content"); 37 | if (!enableHideEndScreenCardsButton) return; 38 | if (!isWatchPage()) return; 39 | await waitForAllElements(["div#player", "div#player-wide-container", "div#video-container", "div#player-container"]); 40 | // Get the player container element 41 | const playerContainer = document.querySelector("div#movie_player"); 42 | if (!playerContainer) return; 43 | const videoData = await playerContainer.getVideoData(); 44 | if (videoData.isLive) return; 45 | const endScreenCardsAreHidden = isEndScreenCardsHidden(); 46 | const handleButtonClick = (placement: ButtonPlacement, checked?: boolean) => { 47 | if (placement === "feature_menu") { 48 | if (checked && !isEndScreenCardsHidden()) hideEndScreenCards(); 49 | else if (!checked && isEndScreenCardsHidden()) showEndScreenCards(); 50 | } else { 51 | updateFeatureButtonTitle( 52 | "hideEndScreenCardsButton", 53 | window.i18nextInstance.t(`pages.content.features.hideEndScreenCardsButton.button.toggle.${checked ? "on" : "off"}`) 54 | ); 55 | if (checked && isEndScreenCardsHidden()) showEndScreenCards(); 56 | else if (!checked && !isEndScreenCardsHidden()) hideEndScreenCards(); 57 | } 58 | }; 59 | await addFeatureButton( 60 | "hideEndScreenCardsButton", 61 | hideEndScreenCardsButtonPlacement, 62 | window.i18nextInstance.t( 63 | hideEndScreenCardsButtonPlacement === "feature_menu" ? 64 | "pages.content.features.hideEndScreenCardsButton.button.label" 65 | : `pages.content.features.hideEndScreenCardsButton.button.toggle.${!endScreenCardsAreHidden ? "on" : "off"}` 66 | ), 67 | getFeatureIcon("hideEndScreenCardsButton", hideEndScreenCardsButtonPlacement), 68 | (checked) => handleButtonClick(hideEndScreenCardsButtonPlacement, checked), 69 | true, 70 | hideEndScreenCardsButtonPlacement !== "feature_menu" ? !endScreenCardsAreHidden : endScreenCardsAreHidden 71 | ); 72 | }; 73 | export const removeHideEndScreenCardsButton: RemoveButtonFunction = async (placement) => { 74 | if (!isWatchPage()) return; 75 | await removeFeatureButton("hideEndScreenCardsButton", placement); 76 | eventManager.removeEventListeners("hideEndScreenCardsButton"); 77 | }; 78 | function hideEndScreenCards() { 79 | modifyElementsClassList( 80 | "add", 81 | Array.from(document.querySelectorAll(".ytp-ce-element")).map((element) => ({ 82 | className: "yte-hide-end-screen-cards", 83 | element 84 | })) 85 | ); 86 | } 87 | function showEndScreenCards() { 88 | modifyElementsClassList( 89 | "remove", 90 | Array.from(document.querySelectorAll(".ytp-ce-element")).map((element) => ({ 91 | className: "yte-hide-end-screen-cards", 92 | element 93 | })) 94 | ); 95 | } 96 | export function isEndScreenCardsHidden(): boolean { 97 | const endCards = document.querySelectorAll(".ytp-ce-element.yte-hide-end-screen-cards"); 98 | return endCards.length > 0; 99 | } 100 | -------------------------------------------------------------------------------- /src/features/hideLiveStreamChat/index.css: -------------------------------------------------------------------------------- 1 | .yte-hide-live-stream-chat { 2 | display: none !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/features/hideLiveStreamChat/index.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv } from "@/src/types"; 2 | 3 | import { modifyElementsClassList, waitForAllElements, waitForSpecificMessage } from "@/src/utils/utilities"; 4 | 5 | import "./index.css"; 6 | 7 | export async function enableHideLiveStreamChat() { 8 | const { 9 | data: { 10 | options: { enable_hide_live_stream_chat: enableHideLiveStreamChat } 11 | } 12 | } = await waitForSpecificMessage("options", "request_data", "content"); 13 | if (!enableHideLiveStreamChat) return; 14 | await waitForAllElements(["div#player", "div#player-wide-container", "div#video-container", "div#player-container"]); 15 | const player = document.querySelector("div#movie_player"); 16 | if (!player) return; 17 | const playerData = await player.getVideoData(); 18 | if (!playerData.isLive) return; 19 | modifyElementsClassList("add", [ 20 | { 21 | className: "yte-hide-live-stream-chat", 22 | element: document.querySelector("div#chat-container") 23 | }, 24 | { 25 | className: "yte-hide-live-stream-chat", 26 | element: document.querySelector("div#chat-container #chat") 27 | }, 28 | { 29 | className: "yte-hide-live-stream-chat", 30 | element: document.querySelector("#full-bleed-container #panels-full-bleed-container") 31 | } 32 | ]); 33 | } 34 | 35 | export async function disableHideLiveStreamChat() { 36 | const player = document.querySelector("div#movie_player"); 37 | if (!player) return; 38 | const playerData = await player.getVideoData(); 39 | if (!playerData.isLive) return; 40 | modifyElementsClassList("remove", [ 41 | { 42 | className: "yte-hide-live-stream-chat", 43 | element: document.querySelector("div#chat-container") 44 | }, 45 | { 46 | className: "yte-hide-live-stream-chat", 47 | element: document.querySelector("div#chat-container #chat") 48 | }, 49 | { 50 | className: "yte-hide-live-stream-chat", 51 | element: document.querySelector("#full-bleed-container #panels-full-bleed-container") 52 | } 53 | ]); 54 | } 55 | -------------------------------------------------------------------------------- /src/features/hideOfficialArtistVideosFromHomePage/index.css: -------------------------------------------------------------------------------- 1 | .yte-hide-official-artist-videos-from-home-page { 2 | display: none !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/features/hideOfficialArtistVideosFromHomePage/index.ts: -------------------------------------------------------------------------------- 1 | import type { Nullable } from "@/src/types"; 2 | import "./index.css"; 3 | import { waitForSpecificMessage } from "@/src/utils/utilities"; 4 | import { 5 | hideOfficialArtistVideosFromHomePage, 6 | observeOfficialArtistVideosFromHomePage, 7 | showOfficialArtistVideosFromHomePage 8 | } from "@/src/features/hideOfficialArtistVideosFromHomePage/utils"; 9 | let officialArtistVideosObserver: Nullable = null; 10 | export async function enableHideOfficialArtistVideosFromHomePage() { 11 | // Wait for the "options" message from the content script 12 | const { 13 | data: { 14 | options: { enable_hide_official_artist_videos_from_home_page: enableHideOfficialArtistVideosFromHomePage } 15 | } 16 | } = await waitForSpecificMessage("options", "request_data", "content"); 17 | if (!enableHideOfficialArtistVideosFromHomePage) return; 18 | hideOfficialArtistVideosFromHomePage(); 19 | if (officialArtistVideosObserver) officialArtistVideosObserver.disconnect(); 20 | officialArtistVideosObserver = observeOfficialArtistVideosFromHomePage(); 21 | } 22 | 23 | export function disableHideOfficialArtistVideosFromHomePage() { 24 | showOfficialArtistVideosFromHomePage(); 25 | if (officialArtistVideosObserver) { 26 | officialArtistVideosObserver.disconnect(); 27 | officialArtistVideosObserver = null; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/features/hideOfficialArtistVideosFromHomePage/utils.ts: -------------------------------------------------------------------------------- 1 | import { modifyElementsClassList } from "@/src/utils/utilities"; 2 | 3 | export function observeOfficialArtistVideosFromHomePage() { 4 | const observer = new MutationObserver(() => { 5 | hideOfficialArtistVideosFromHomePage(); 6 | }); 7 | observer.observe(document.body, { childList: true, subtree: true }); 8 | return observer; 9 | } 10 | export function hideOfficialArtistVideosFromHomePage() { 11 | const officialArtistVideos = document.querySelectorAll( 12 | "ytd-rich-item-renderer:has(#byline-container #channel-name .badge-style-type-verified-artist)" 13 | ); 14 | modifyElementsClassList( 15 | "add", 16 | Array.from(officialArtistVideos).map((element) => ({ className: "yte-hide-official-artist-videos-from-home-page", element })) 17 | ); 18 | } 19 | export function showOfficialArtistVideosFromHomePage() { 20 | const officialArtistVideos = document.querySelectorAll( 21 | "ytd-rich-item-renderer:has(#byline-container #channel-name .badge-style-type-verified-artist)" 22 | ); 23 | modifyElementsClassList( 24 | "remove", 25 | Array.from(officialArtistVideos).map((element) => ({ className: "yte-hide-official-artist-videos-from-home-page", element })) 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/features/hidePaidPromotionBanner/index.css: -------------------------------------------------------------------------------- 1 | .yte-hide-paid-promotion-banner { 2 | display: none !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/features/hidePaidPromotionBanner/index.ts: -------------------------------------------------------------------------------- 1 | import { modifyElementClassList, waitForSpecificMessage } from "@/src/utils/utilities"; 2 | 3 | import "./index.css"; 4 | 5 | export async function enableHidePaidPromotionBanner() { 6 | const { 7 | data: { 8 | options: { enable_hide_paid_promotion_banner } 9 | } 10 | } = await waitForSpecificMessage("options", "request_data", "content"); 11 | if (!enable_hide_paid_promotion_banner) return; 12 | modifyElementClassList("add", { 13 | className: "yte-hide-paid-promotion-banner", 14 | element: document.querySelector(".ytp-paid-content-overlay") 15 | }); 16 | } 17 | 18 | export function disableHidePaidPromotionBanner() { 19 | modifyElementClassList("remove", { 20 | className: "yte-hide-paid-promotion-banner", 21 | element: document.querySelector(".ytp-paid-content-overlay") 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/features/hideScrollBar/index.ts: -------------------------------------------------------------------------------- 1 | import { waitForSpecificMessage } from "@/src/utils/utilities"; 2 | 3 | import { hideScrollBar } from "./utils"; 4 | 5 | export async function enableHideScrollBar() { 6 | // Wait for the "options" message from the content script 7 | const { 8 | data: { 9 | options: { enable_hide_scrollbar } 10 | } 11 | } = await waitForSpecificMessage("options", "request_data", "content"); 12 | // If the hide scroll bar option is disabled, return 13 | if (!enable_hide_scrollbar) return; 14 | hideScrollBar(); 15 | } 16 | -------------------------------------------------------------------------------- /src/features/hideScrollBar/utils.ts: -------------------------------------------------------------------------------- 1 | export function hideScrollBar() { 2 | const style = document.createElement("style"); 3 | style.textContent = ` 4 | ::-webkit-scrollbar { 5 | width: 0px; 6 | height: 0px; 7 | } 8 | html { 9 | scrollbar-width: none; 10 | } 11 | `; 12 | style.id = "yte-hide-scroll-bar"; 13 | document.head.appendChild(style); 14 | } 15 | export function showScrollBar() { 16 | let style = document.getElementById("yte-hide-scroll-bar"); 17 | while (style) { 18 | style.remove(); 19 | style = document.getElementById("yte-hide-scroll-bar"); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/features/hideShorts/index.ts: -------------------------------------------------------------------------------- 1 | import { hideShorts, observeShortsElements, showShorts } from "@/src/features/hideShorts/utils"; 2 | import { type Nullable } from "@/src/types"; 3 | import { waitForSpecificMessage } from "@/src/utils/utilities"; 4 | let shortsObserver: Nullable = null; 5 | export async function enableHideShorts() { 6 | // Wait for the "options" message from the content script 7 | const { 8 | data: { 9 | options: { enable_hide_shorts } 10 | } 11 | } = await waitForSpecificMessage("options", "request_data", "content"); 12 | // If the hide shorts option is disabled, return 13 | if (!enable_hide_shorts) return; 14 | hideShorts(); 15 | // Observe changes to the short sections and hides them 16 | shortsObserver = observeShortsElements(); 17 | } 18 | 19 | export function disableHideShorts() { 20 | showShorts(); 21 | // Disconnect the observer 22 | if (shortsObserver) { 23 | shortsObserver.disconnect(); 24 | shortsObserver = null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/features/hideTranslateComment/index.css: -------------------------------------------------------------------------------- 1 | .yte-hide-translate-comment { 2 | display: none !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/features/hideTranslateComment/index.ts: -------------------------------------------------------------------------------- 1 | import type { Nullable } from "@/src/types"; 2 | 3 | import { 4 | type EngagementPanelVisibility, 5 | observeCommentsPanelVisibilityChange, 6 | observeTranslateComment, 7 | translateButtonSelector 8 | } from "@/src/features/hideTranslateComment/utils"; 9 | import { isNewYouTubeVideoLayout, modifyElementClassList, waitForAllElements, waitForSpecificMessage } from "@/src/utils/utilities"; 10 | 11 | import "./index.css"; 12 | export const commentsPanelSelector = "ytd-engagement-panel-section-list-renderer[target-id='engagement-panel-comments-section']"; 13 | export const commentsHeaderSelector = "ytd-item-section-renderer.ytd-comments div#header div#leading-section"; 14 | let translateCommentObserver: Nullable = null; 15 | let commentsPanelObserver: Nullable = null; 16 | type ObserverType = "commentsPanel" | "translateComment"; 17 | export function setHideTranslateCommentObserver(observerType: ObserverType, observer: MutationObserver) { 18 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 19 | observerType === "translateComment" ? (translateCommentObserver = observer) : (commentsPanelObserver = observer); 20 | } 21 | export function cleanUpHideTranslateCommentObserver(observerType: ObserverType) { 22 | if (observerType === "translateComment") { 23 | translateCommentObserver?.disconnect(); 24 | translateCommentObserver = null; 25 | } else { 26 | commentsPanelObserver?.disconnect(); 27 | commentsPanelObserver = null; 28 | } 29 | } 30 | export function toggleHideTranslateCommentButtonsVisibility(visible: boolean) { 31 | const translateCommentButtons = document.querySelectorAll(translateButtonSelector); 32 | translateCommentButtons.forEach((button) => 33 | modifyElementClassList(!visible ? "add" : "remove", { className: "yte-hide-translate-comment", element: button }) 34 | ); 35 | } 36 | export async function enableHideTranslateComment() { 37 | const { 38 | data: { 39 | options: { enable_hide_translate_comment } 40 | } 41 | } = await waitForSpecificMessage("options", "request_data", "content"); 42 | if (!enable_hide_translate_comment) return; 43 | const isNewVideLayout = isNewYouTubeVideoLayout(); 44 | if (isNewVideLayout) { 45 | await waitForAllElements([commentsPanelSelector]); 46 | const commentsPanelElement = document.querySelector(commentsPanelSelector); 47 | if ( 48 | commentsPanelElement && 49 | (commentsPanelElement.getAttribute("visibility") as EngagementPanelVisibility) === "ENGAGEMENT_PANEL_VISIBILITY_EXPANDED" 50 | ) 51 | toggleHideTranslateCommentButtonsVisibility(false); 52 | const observer = observeCommentsPanelVisibilityChange(); 53 | if (observer) setHideTranslateCommentObserver("commentsPanel", observer); 54 | } else { 55 | await waitForAllElements([commentsHeaderSelector]); 56 | toggleHideTranslateCommentButtonsVisibility(false); 57 | const observer = observeTranslateComment(); 58 | if (observer) setHideTranslateCommentObserver("translateComment", observer); 59 | } 60 | } 61 | 62 | export async function disableHideTranslateComment() { 63 | cleanUpHideTranslateCommentObserver("commentsPanel"); 64 | cleanUpHideTranslateCommentObserver("translateComment"); 65 | const isNewVideLayout = isNewYouTubeVideoLayout(); 66 | if (isNewVideLayout) { 67 | await waitForAllElements([commentsPanelSelector]); 68 | const commentsPanelElement = document.querySelector(commentsPanelSelector); 69 | if ( 70 | commentsPanelElement && 71 | (commentsPanelElement.getAttribute("visibility") as EngagementPanelVisibility) === "ENGAGEMENT_PANEL_VISIBILITY_EXPANDED" 72 | ) 73 | toggleHideTranslateCommentButtonsVisibility(true); 74 | } else { 75 | await waitForAllElements([commentsHeaderSelector]); 76 | toggleHideTranslateCommentButtonsVisibility(true); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/features/hideTranslateComment/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cleanUpHideTranslateCommentObserver, 3 | commentsPanelSelector, 4 | setHideTranslateCommentObserver, 5 | toggleHideTranslateCommentButtonsVisibility 6 | } from "@/src/features/hideTranslateComment"; 7 | import { isNewYouTubeVideoLayout, modifyElementClassList } from "@/src/utils/utilities"; 8 | export const translateButtonSelector = "ytd-tri-state-button-view-model.translate-button"; 9 | export const engagementPanelVisibility = ["ENGAGEMENT_PANEL_VISIBILITY_HIDDEN", "ENGAGEMENT_PANEL_VISIBILITY_EXPANDED"] as const; 10 | export type EngagementPanelVisibility = (typeof engagementPanelVisibility)[number]; 11 | export function observeTranslateComment(): MutationObserver { 12 | const observer = new MutationObserver((mutationList) => { 13 | mutationList 14 | .filter((mutation) => mutation.type === "childList") 15 | .filter( 16 | (mutation) => 17 | (mutation.target instanceof Element && 18 | mutation.target.matches("ytd-comment-thread-renderer #replies ytd-comment-replies-renderer #expander #contents")) || 19 | Array.from(mutation.addedNodes).some( 20 | (addedNode) => 21 | addedNode instanceof Element && 22 | addedNode.matches("ytd-comment-thread-renderer") && 23 | addedNode.querySelector(translateButtonSelector) !== null 24 | ) 25 | ) 26 | .forEach((mutation) => { 27 | mutation.addedNodes.forEach((addedNode) => { 28 | modifyElementClassList("add", { 29 | className: "yte-hide-translate-comment", 30 | element: (addedNode as Element).querySelector(translateButtonSelector) 31 | }); 32 | }); 33 | }); 34 | }); 35 | const commentsSection = document.querySelector( 36 | isNewYouTubeVideoLayout() ? 37 | "ytd-comments ytd-item-section-renderer#sections.ytd-comments div#contents" 38 | : "ytd-comments.ytd-watch-flexy ytd-item-section-renderer#sections.ytd-comments div#contents" 39 | ) as Element; 40 | observer.observe(commentsSection, { childList: true, subtree: true }); 41 | return observer; 42 | } 43 | 44 | export function observeCommentsPanelVisibilityChange(): MutationObserver { 45 | const observer = new MutationObserver((mutationList) => { 46 | mutationList.forEach((mutation) => { 47 | if (mutation.attributeName === "visibility") { 48 | const target = mutation.target as HTMLElement; 49 | if (!target) return; 50 | const visibility = target.getAttribute("visibility"); 51 | if (!visibility) return; 52 | if (!engagementPanelVisibility.includes(visibility)) return; 53 | const castVisibility = visibility as EngagementPanelVisibility; 54 | if (castVisibility === "ENGAGEMENT_PANEL_VISIBILITY_EXPANDED") { 55 | toggleHideTranslateCommentButtonsVisibility(false); 56 | const observer = observeTranslateComment(); 57 | if (observer) setHideTranslateCommentObserver("translateComment", observer); 58 | } else { 59 | cleanUpHideTranslateCommentObserver("translateComment"); 60 | toggleHideTranslateCommentButtonsVisibility(false); 61 | } 62 | } 63 | }); 64 | }); 65 | const commentsPanel = document.querySelector(commentsPanelSelector) as Element; 66 | observer.observe(commentsPanel, { attributeFilter: ["visibility"] }); 67 | return observer; 68 | } 69 | -------------------------------------------------------------------------------- /src/features/index.ts: -------------------------------------------------------------------------------- 1 | import type { AllButtonNames, ButtonPlacement } from "@/src/types"; 2 | 3 | import { addCopyTimestampUrlButton, removeCopyTimestampUrlButton } from "@/src/features/copyTimestampUrlButton"; 4 | import { addForwardButton, addRewindButton, removeForwardButton, removeRewindButton } from "@/src/features/forwardRewindButtons"; 5 | import { addHideEndScreenCardsButton, removeHideEndScreenCardsButton } from "@/src/features/hideEndScreenCards"; 6 | import { addLoopButton, removeLoopButton } from "@/src/features/loopButton"; 7 | import { addMaximizePlayerButton, removeMaximizePlayerButton } from "@/src/features/maximizePlayerButton"; 8 | import { addOpenTranscriptButton, removeOpenTranscriptButton } from "@/src/features/openTranscriptButton/utils"; 9 | import { 10 | addDecreasePlaybackSpeedButton, 11 | addIncreasePlaybackSpeedButton, 12 | removeDecreasePlaybackSpeedButton, 13 | removeIncreasePlaybackSpeedButton 14 | } from "@/src/features/playbackSpeedButtons"; 15 | import { addScreenshotButton, removeScreenshotButton } from "@/src/features/screenshotButton"; 16 | import { addVolumeBoostButton, removeVolumeBoostButton } from "@/src/features/volumeBoost"; 17 | 18 | export type FeatureFuncRecord = { 19 | add: AddButtonFunction; 20 | remove: RemoveButtonFunction; 21 | }; 22 | 23 | export const featureButtonFunctions = { 24 | copyTimestampUrlButton: { 25 | add: addCopyTimestampUrlButton, 26 | remove: removeCopyTimestampUrlButton 27 | }, 28 | decreasePlaybackSpeedButton: { 29 | add: addDecreasePlaybackSpeedButton, 30 | remove: removeDecreasePlaybackSpeedButton 31 | }, 32 | forwardButton: { 33 | add: addForwardButton, 34 | remove: removeForwardButton 35 | }, 36 | hideEndScreenCardsButton: { 37 | add: addHideEndScreenCardsButton, 38 | remove: removeHideEndScreenCardsButton 39 | }, 40 | increasePlaybackSpeedButton: { 41 | add: addIncreasePlaybackSpeedButton, 42 | remove: removeIncreasePlaybackSpeedButton 43 | }, 44 | loopButton: { 45 | add: addLoopButton, 46 | remove: removeLoopButton 47 | }, 48 | maximizePlayerButton: { 49 | add: addMaximizePlayerButton, 50 | remove: removeMaximizePlayerButton 51 | }, 52 | openTranscriptButton: { 53 | add: addOpenTranscriptButton, 54 | remove: removeOpenTranscriptButton 55 | }, 56 | rewindButton: { 57 | add: addRewindButton, 58 | remove: removeRewindButton 59 | }, 60 | screenshotButton: { 61 | add: addScreenshotButton, 62 | remove: removeScreenshotButton 63 | }, 64 | volumeBoostButton: { 65 | add: addVolumeBoostButton, 66 | remove: removeVolumeBoostButton 67 | } 68 | } satisfies Record; 69 | export type AddButtonFunction = () => Promise; 70 | export type RemoveButtonFunction = (placement?: ButtonPlacement) => Promise; 71 | -------------------------------------------------------------------------------- /src/features/loopButton/index.ts: -------------------------------------------------------------------------------- 1 | import type { SingleButtonFeatureNames } from "@/src/types"; 2 | 3 | import { addFeatureButton, removeFeatureButton } from "@/src/features/buttonPlacement"; 4 | import { getFeatureButton, getFeatureButtonId } from "@/src/features/buttonPlacement/utils"; 5 | import { getFeatureIds } from "@/src/features/featureMenu/utils"; 6 | import { getFeatureIcon } from "@/src/icons"; 7 | import eventManager from "@/src/utils/EventManager"; 8 | import { waitForSpecificMessage } from "@/src/utils/utilities"; 9 | 10 | import type { AddButtonFunction, RemoveButtonFunction } from "../index"; 11 | 12 | import { loopButtonClickListener } from "./utils"; 13 | 14 | export const addLoopButton: AddButtonFunction = async () => { 15 | // Wait for the "options" message from the content script 16 | const { 17 | data: { 18 | options: { 19 | button_placements: { loopButton: loopButtonPlacement }, 20 | enable_loop_button 21 | } 22 | } 23 | } = await waitForSpecificMessage("options", "request_data", "content"); 24 | // If the loop button option is disabled, return 25 | if (!enable_loop_button) return; 26 | // Get the volume control element 27 | const volumeControl = document.querySelector("div.ytp-chrome-controls > div.ytp-left-controls > span.ytp-volume-area"); 28 | // If volume control element is not available, return 29 | if (!volumeControl) return; 30 | const videoElement = document.querySelector("video.html5-main-video"); 31 | if (!videoElement) return; 32 | await addFeatureButton( 33 | "loopButton", 34 | loopButtonPlacement, 35 | loopButtonPlacement === "feature_menu" ? 36 | window.i18nextInstance.t("pages.content.features.loopButton.button.label") 37 | : window.i18nextInstance.t("pages.content.features.loopButton.button.toggle.off"), 38 | getFeatureIcon("loopButton", loopButtonPlacement), 39 | loopButtonClickListener, 40 | true 41 | ); 42 | const loopChangedHandler = (mutationList: MutationRecord[]) => { 43 | const loopSVG = getFeatureIcon("loopButton", loopButtonPlacement); 44 | for (const mutation of mutationList) { 45 | if (mutation.type === "attributes") { 46 | const { attributeName, target } = mutation; 47 | if (attributeName === "loop") { 48 | const { loop } = target as HTMLVideoElement; 49 | const featureName: SingleButtonFeatureNames = "loopButton"; 50 | // Get the feature menu 51 | const featureMenu = document.querySelector("#yte-feature-menu"); 52 | // Check if the feature item already exists in the menu 53 | const featureExistsInMenu = 54 | featureMenu && featureMenu.querySelector(`#${getFeatureIds(featureName).featureMenuItemId}`) !== null; 55 | if (featureExistsInMenu) { 56 | const menuItem = getFeatureButton(featureName); 57 | if (!menuItem) return; 58 | menuItem.ariaChecked = loop ? "true" : "false"; 59 | } 60 | const button = document.querySelector(`#${getFeatureButtonId(featureName)}`); 61 | if (!button) return; 62 | switch (loopButtonPlacement) { 63 | case "feature_menu": { 64 | if (loopSVG instanceof SVGSVGElement) { 65 | button.firstChild?.replaceWith(loopSVG); 66 | } 67 | break; 68 | } 69 | case "below_player": 70 | case "player_controls_left": 71 | case "player_controls_right": { 72 | if (typeof loopSVG === "object" && "off" in loopSVG && "on" in loopSVG) { 73 | button.firstChild?.replaceWith(loop ? loopSVG.on : loopSVG.off); 74 | } 75 | break; 76 | } 77 | } 78 | } 79 | } 80 | } 81 | }; 82 | const loopChangeMutationObserver = new MutationObserver(loopChangedHandler); 83 | loopChangeMutationObserver.observe(videoElement, { attributeFilter: ["loop"], attributes: true }); 84 | }; 85 | export const removeLoopButton: RemoveButtonFunction = async (placement) => { 86 | await removeFeatureButton("loopButton", placement); 87 | eventManager.removeEventListeners("loopButton"); 88 | }; 89 | -------------------------------------------------------------------------------- /src/features/loopButton/utils.ts: -------------------------------------------------------------------------------- 1 | import { updateFeatureButtonTitle } from "@/src/features/buttonPlacement/utils"; 2 | 3 | export function loopButtonClickListener(checked?: boolean) { 4 | if (checked !== undefined) { 5 | updateFeatureButtonTitle("loopButton", window.i18nextInstance.t(`pages.content.features.loopButton.button.toggle.${checked ? "on" : "off"}`)); 6 | } 7 | const videoElement = document.querySelector("video.html5-main-video"); 8 | if (!videoElement) return; 9 | const loop = videoElement.hasAttribute("loop"); 10 | if (loop) { 11 | videoElement.removeAttribute("loop"); 12 | } else { 13 | videoElement.setAttribute("loop", ""); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/features/openTranscriptButton/index.ts: -------------------------------------------------------------------------------- 1 | import { removeFeatureButton } from "@/src/features/buttonPlacement"; 2 | import { getFeatureButton } from "@/src/features/buttonPlacement/utils"; 3 | import { waitForAllElements, waitForSpecificMessage } from "@/src/utils/utilities"; 4 | 5 | import { addOpenTranscriptButton } from "./utils"; 6 | 7 | export async function openTranscriptButton() { 8 | // Wait for the "options" message from the content script 9 | const { 10 | data: { 11 | options: { enable_open_transcript_button: enableOpenTranscriptButton } 12 | } 13 | } = await waitForSpecificMessage("options", "request_data", "content"); 14 | // If the open transcript button option is disabled, return 15 | if (!enableOpenTranscriptButton) return; 16 | await waitForAllElements(["ytd-video-description-transcript-section-renderer button"]); 17 | const transcriptButton = document.querySelector("ytd-video-description-transcript-section-renderer button"); 18 | const transcriptButtonMenuItem = getFeatureButton("openTranscriptButton"); 19 | // If the transcript button is not found and the "openTranscriptButton" menu item exists, remove the transcript button menu item 20 | if (!transcriptButton && transcriptButtonMenuItem) await removeFeatureButton("openTranscriptButton"); 21 | // If the transcript button isn't found return 22 | if (!transcriptButton) return; 23 | // If the transcript button is found and the "openTranscriptButton" menu item does not exist, add the transcript button menu item 24 | void addOpenTranscriptButton(); 25 | } 26 | -------------------------------------------------------------------------------- /src/features/openTranscriptButton/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AddButtonFunction, RemoveButtonFunction } from "@/src/features"; 2 | 3 | import { addFeatureButton, removeFeatureButton } from "@/src/features/buttonPlacement"; 4 | import { getFeatureIcon } from "@/src/icons"; 5 | import eventManager from "@/src/utils/EventManager"; 6 | import { waitForSpecificMessage } from "@/src/utils/utilities"; 7 | 8 | export const addOpenTranscriptButton: AddButtonFunction = async () => { 9 | // Wait for the "options" message from the content script 10 | const { 11 | data: { 12 | options: { 13 | button_placements: { openTranscriptButton: openTranscriptButtonPlacement } 14 | } 15 | } 16 | } = await waitForSpecificMessage("options", "request_data", "content"); 17 | function transcriptButtonClickerListener() { 18 | const transcriptButton = document.querySelector("ytd-video-description-transcript-section-renderer button"); 19 | if (!transcriptButton) return; 20 | transcriptButton.click(); 21 | } 22 | await addFeatureButton( 23 | "openTranscriptButton", 24 | openTranscriptButtonPlacement, 25 | window.i18nextInstance.t("pages.content.features.openTranscriptButton.button.label"), 26 | getFeatureIcon("openTranscriptButton", openTranscriptButtonPlacement), 27 | transcriptButtonClickerListener, 28 | false 29 | ); 30 | }; 31 | export const removeOpenTranscriptButton: RemoveButtonFunction = async (placement) => { 32 | await removeFeatureButton("openTranscriptButton", placement); 33 | eventManager.removeEventListeners("openTranscriptButton"); 34 | }; 35 | -------------------------------------------------------------------------------- /src/features/openYouTubeSettingsOnHover/index.ts: -------------------------------------------------------------------------------- 1 | import type { Nullable } from "@/src/types"; 2 | 3 | import eventManager from "@/src/utils/EventManager"; 4 | import { isNewYouTubeVideoLayout, isWatchPage, waitForSpecificMessage } from "@/src/utils/utilities"; 5 | 6 | export async function enableOpenYouTubeSettingsOnHover() { 7 | // Wait for the "options" message from the content script 8 | const { 9 | data: { 10 | options: { enable_open_youtube_settings_on_hover: enableOpenYouTubeSettingsOnHover } 11 | } 12 | } = await waitForSpecificMessage("options", "request_data", "content"); 13 | // If the open YouTube settings on hover option is disabled, return 14 | if (!enableOpenYouTubeSettingsOnHover) return; 15 | const settingsButton = document.querySelector(".ytp-button.ytp-settings-button"); 16 | if (!settingsButton) return; 17 | const featureMenuButton = document.querySelector("#yte-feature-menu-button"); 18 | if (!featureMenuButton) return; 19 | const settingsMenu = document.querySelector("div.ytp-settings-menu:not(#yte-feature-menu)"); 20 | if (!settingsMenu) return; 21 | const featureMenu = document.querySelector("div.ytp-settings-menu#yte-feature-menu"); 22 | if (!featureMenu) return; 23 | // Get the player element 24 | const playerContainer = 25 | isWatchPage() ? 26 | document.querySelector( 27 | isNewYouTubeVideoLayout() ? "div#player-container.ytd-watch-grid" : "div#player-container.ytd-watch-flexy" 28 | ) 29 | : null; 30 | // If player element is not available, return 31 | if (!playerContainer) return; 32 | const showSettings = () => { 33 | if (settingsMenu.style.display !== "none") return; 34 | settingsButton.click(); 35 | }; 36 | const hideSettings = (event: Event) => { 37 | if (settingsMenu.style.display === "none") return; 38 | if (event.target && (event.target as HTMLDivElement).classList.contains("ytp-popup-animating")) return; 39 | settingsButton.click(); 40 | }; 41 | const settingsButtonMouseLeaveListener = (event: Event) => { 42 | if (event.target === settingsButton) return; 43 | if (settingsMenu.contains(event.target as Nullable)) return; 44 | hideSettings(event); 45 | }; 46 | eventManager.addEventListener(settingsButton, "mouseenter", showSettings, "openYouTubeSettingsOnHover"); 47 | eventManager.addEventListener(settingsButton, "mouseleave", settingsButtonMouseLeaveListener, "openYouTubeSettingsOnHover"); 48 | eventManager.addEventListener(settingsMenu, "mouseleave", hideSettings, "openYouTubeSettingsOnHover"); 49 | eventManager.addEventListener(playerContainer, "mouseleave", hideSettings, "openYouTubeSettingsOnHover"); 50 | } 51 | export function disableOpenYouTubeSettingsOnHover() { 52 | eventManager.removeEventListeners("openYouTubeSettingsOnHover"); 53 | } 54 | -------------------------------------------------------------------------------- /src/features/pauseBackgroundPlayers/index.ts: -------------------------------------------------------------------------------- 1 | import { browserColorLog, sendContentToBackgroundMessage, waitForSpecificMessage } from "@/src/utils/utilities"; 2 | 3 | const PauseBackgroundPlayers = () => { 4 | sendContentToBackgroundMessage("pauseBackgroundPlayers").catch((error) => { 5 | throw new Error(`Failed to pause background players: ${error}`); 6 | }); 7 | }; 8 | 9 | export async function enablePauseBackgroundPlayers() { 10 | const { 11 | data: { 12 | options: { enable_pausing_background_players: pauseBackgroundPlayersEnabled } 13 | } 14 | } = await waitForSpecificMessage("options", "request_data", "content"); 15 | if (!pauseBackgroundPlayersEnabled) return; 16 | // ignore home page and channel pages 17 | if (window.location.href.match(/^https?:\/\/(?:www\.)?youtube\.com(\/?|\/channel\/.+|\/\@.+)$/gm)) return; 18 | browserColorLog("Enabling pauseBackgroundPlayers", "FgMagenta"); 19 | let videoPlayerContainer: HTMLVideoElement | null = null; 20 | if (!videoPlayerContainer) { 21 | videoPlayerContainer = document.querySelector(".html5-main-video"); 22 | } 23 | function detectPlaying() { 24 | if (videoPlayerContainer) { 25 | videoPlayerContainer.addEventListener("playing", PauseBackgroundPlayers); 26 | } 27 | } 28 | let debounceTimeout: null | number = null; 29 | const observer = new MutationObserver((mutationsList: MutationRecord[]) => { 30 | if (debounceTimeout) clearTimeout(debounceTimeout); 31 | // @ts-expect-error - doesn't recognize browser environment properly 32 | debounceTimeout = setTimeout(() => { 33 | for (const mutation of mutationsList) { 34 | if (mutation.addedNodes.length) { 35 | detectPlaying(); 36 | } 37 | } 38 | }, 100); 39 | }); 40 | if (videoPlayerContainer) { 41 | observer.observe(videoPlayerContainer, { childList: true, subtree: true }); 42 | } 43 | if (!videoPlayerContainer?.paused) { 44 | PauseBackgroundPlayers(); 45 | } 46 | detectPlaying(); 47 | } 48 | 49 | export function disablePauseBackgroundPlayers() { 50 | const videoPlayerContainer: HTMLElement | null = document.querySelector(".html5-main-video"); 51 | if (videoPlayerContainer) { 52 | videoPlayerContainer.removeEventListener("playing", PauseBackgroundPlayers); 53 | } 54 | browserColorLog("Disabling pauseBackgroundPlayers", "FgMagenta"); 55 | } 56 | -------------------------------------------------------------------------------- /src/features/playerQuality/index.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv, YoutubePlayerQualityLevel } from "@/src/types"; 2 | 3 | import { browserColorLog, chooseClosestQuality, isLivePage, isShortsPage, isWatchPage, waitForSpecificMessage } from "@/src/utils/utilities"; 4 | 5 | /** 6 | * Sets the player quality based on the options received from a specific message. 7 | * It automatically sets the quality if the option is enabled and the specified quality is available. 8 | * 9 | * @returns {Promise} A promise that resolves once the player quality is set. 10 | */ 11 | export default async function setPlayerQuality(): Promise { 12 | // Wait for the "options" message from the content script 13 | const { 14 | data: { 15 | options: { enable_automatically_set_quality, player_quality, player_quality_fallback_strategy } 16 | } 17 | } = await waitForSpecificMessage("options", "request_data", "content"); 18 | // If automatically set quality option is disabled, return 19 | if (!enable_automatically_set_quality) return; 20 | // If player quality is not specified, return 21 | if (!player_quality) return; 22 | // Get the player element 23 | const playerContainer = 24 | isWatchPage() || isLivePage() ? document.querySelector("div#movie_player") 25 | : isShortsPage() ? document.querySelector("div#shorts-player") 26 | : null; 27 | // If player element is not available, return 28 | if (!playerContainer) return; 29 | // If setPlaybackQuality method is not available in the player, return 30 | if (!playerContainer.setPlaybackQuality) return; 31 | // Get the available quality levels 32 | const availableQualityLevels = (await playerContainer.getAvailableQualityLevels()) as YoutubePlayerQualityLevel[]; 33 | // Check if the specified player quality is available 34 | if (player_quality && player_quality !== "auto") { 35 | const closestQuality = chooseClosestQuality(player_quality, availableQualityLevels, player_quality_fallback_strategy); 36 | if (!closestQuality) return; 37 | // Log the message indicating the player quality being set 38 | browserColorLog(`Setting player quality to ${closestQuality}`, "FgMagenta"); 39 | // Set the playback quality and update the default quality in the dataset 40 | void playerContainer.setPlaybackQualityRange(closestQuality); 41 | playerContainer.dataset.defaultQuality = closestQuality; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/features/playlistLength/index.ts: -------------------------------------------------------------------------------- 1 | import type { Nullable } from "@/src/types"; 2 | 3 | import eventManager from "@/src/utils/EventManager"; 4 | import { YouTube_Enhancer_Public_Youtube_Data_API_V3_Key } from "@/src/utils/constants"; 5 | import { isWatchPage, waitForAllElements, waitForSpecificMessage } from "@/src/utils/utilities"; 6 | 7 | import { headerSelector, initializePlaylistLength, playlistItemsSelector } from "./utils"; 8 | let documentObserver: Nullable = null; 9 | export async function enablePlaylistLength() { 10 | const IsWatchPage = isWatchPage(); 11 | const { 12 | data: { 13 | options: { 14 | enable_playlist_length, 15 | playlist_length_get_method: playlistLengthGetMethod, 16 | playlist_watch_time_get_method: playlistWatchTimeGetMethod, 17 | youtube_data_api_v3_key 18 | } 19 | } 20 | } = await waitForSpecificMessage("options", "request_data", "content"); 21 | if (!enable_playlist_length) return; 22 | const urlContainsListParameter = window.location.href.includes("list="); 23 | if (!urlContainsListParameter) return; 24 | await waitForAllElements([headerSelector(), playlistItemsSelector()]); 25 | const apiKey = youtube_data_api_v3_key === "" ? YouTube_Enhancer_Public_Youtube_Data_API_V3_Key : youtube_data_api_v3_key; 26 | const pageType = IsWatchPage ? "watch" : "playlist"; 27 | try { 28 | documentObserver = await initializePlaylistLength({ 29 | apiKey, 30 | pageType, 31 | playlistLengthGetMethod, 32 | playlistWatchTimeGetMethod 33 | }); 34 | } catch (error) { 35 | documentObserver?.disconnect(); 36 | documentObserver = null; 37 | documentObserver = await initializePlaylistLength({ 38 | apiKey, 39 | pageType, 40 | playlistLengthGetMethod: "html", 41 | playlistWatchTimeGetMethod 42 | }); 43 | } 44 | } 45 | export function disablePlaylistLength() { 46 | eventManager.removeEventListeners("playlistLength"); 47 | if (documentObserver) documentObserver.disconnect(); 48 | document.querySelector("#yte-playlist-length-ui")?.remove(); 49 | } 50 | -------------------------------------------------------------------------------- /src/features/remainingTime/index.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv } from "@/src/types"; 2 | 3 | import eventManager from "@/src/utils/EventManager"; 4 | import { isShortsPage, isWatchPage, waitForSpecificMessage } from "@/src/utils/utilities"; 5 | 6 | import { calculateRemainingTime } from "./utils"; 7 | 8 | function playerTimeUpdateListener() { 9 | void (async () => { 10 | // Get the player element 11 | const playerContainer = 12 | isWatchPage() ? document.querySelector("div#movie_player") 13 | : isShortsPage() ? document.querySelector("div#shorts-player") 14 | : null; 15 | // If player element is not available, return 16 | if (!playerContainer) return; 17 | // Get the video element 18 | const videoElement = playerContainer.querySelector("video"); 19 | // If video element is not available, return 20 | if (!videoElement) return; 21 | // Get the remaining time element 22 | const remainingTimeElement = document.querySelector("span#ytp-time-remaining"); 23 | if (!remainingTimeElement) return; 24 | const remainingTime = await calculateRemainingTime({ playerContainer, videoElement }); 25 | remainingTimeElement.textContent = remainingTime; 26 | })(); 27 | } 28 | export async function setupRemainingTime() { 29 | // Wait for the "options" message from the content script 30 | const { 31 | data: { 32 | options: { enable_remaining_time } 33 | } 34 | } = await waitForSpecificMessage("options", "request_data", "content"); 35 | // If remaining time option is disabled, return 36 | if (!enable_remaining_time) return; 37 | const timeDisplay = document.querySelector(".ytp-time-display > span:nth-of-type(2)"); 38 | if (!timeDisplay) return; 39 | // Get the player element 40 | const playerContainer = 41 | isWatchPage() ? document.querySelector("div#movie_player") 42 | : isShortsPage() ? document.querySelector("div#shorts-player") 43 | : null; 44 | // If player element is not available, return 45 | if (!playerContainer) return; 46 | // Get the video element 47 | const videoElement = playerContainer.querySelector("video"); 48 | // If video element is not available, return 49 | if (!videoElement) return; 50 | const playerVideoData = await playerContainer.getVideoData(); 51 | const remainingTime = await calculateRemainingTime({ playerContainer, videoElement }); 52 | const remainingTimeElementExists = document.querySelector("span#ytp-time-remaining") !== null; 53 | if (playerVideoData.isLive && !remainingTimeElementExists) return; 54 | const remainingTimeElement = document.querySelector("span#ytp-time-remaining") ?? document.createElement("span"); 55 | // If the video is live return 56 | if (playerVideoData.isLive && remainingTimeElementExists) { 57 | remainingTimeElement.remove(); 58 | } 59 | if (!remainingTimeElementExists) { 60 | remainingTimeElement.id = "ytp-time-remaining"; 61 | remainingTimeElement.textContent = remainingTime; 62 | timeDisplay.insertAdjacentElement("beforeend", remainingTimeElement); 63 | } 64 | eventManager.addEventListener(videoElement, "timeupdate", playerTimeUpdateListener, "remainingTime"); 65 | } 66 | export function removeRemainingTimeDisplay() { 67 | const remainingTimeElement = document.querySelector("span#ytp-time-remaining"); 68 | if (!remainingTimeElement) return; 69 | remainingTimeElement.remove(); 70 | eventManager.removeEventListeners("remainingTime"); 71 | } 72 | -------------------------------------------------------------------------------- /src/features/remainingTime/utils.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv } from "@/src/types"; 2 | export function formatTime(timeInSeconds: number) { 3 | timeInSeconds = Math.round(timeInSeconds); 4 | const units: number[] = [ 5 | Math.floor(timeInSeconds / (3600 * 24)), 6 | Math.floor((timeInSeconds % (3600 * 24)) / 3600), 7 | Math.floor((timeInSeconds % 3600) / 60), 8 | Math.floor(timeInSeconds % 60) 9 | ]; 10 | const formattedUnits: string[] = units.reduce((acc: string[], unit) => { 11 | if (acc.length > 0) { 12 | acc.push(unit.toString().padStart(2, "0")); 13 | } else { 14 | if (unit > 0) { 15 | acc.push(unit.toString()); 16 | } 17 | } 18 | return acc; 19 | }, []); 20 | return `${formattedUnits.length > 0 ? formattedUnits.join(":") : "0"}`; 21 | } 22 | export async function calculateRemainingTime({ 23 | playerContainer, 24 | videoElement 25 | }: { 26 | playerContainer: YouTubePlayerDiv; 27 | videoElement: HTMLVideoElement; 28 | }) { 29 | // Get the player speed (playback rate) 30 | const { playbackRate } = videoElement; 31 | // Get the current time and duration of the video 32 | const currentTime = await playerContainer.getCurrentTime(); 33 | const duration = await playerContainer.getDuration(); 34 | // Calculate the remaining time in seconds 35 | const remainingTimeInSeconds = (duration - currentTime) / playbackRate; 36 | // Format the remaining time 37 | return ` (-${formatTime(remainingTimeInSeconds)})`; 38 | } 39 | -------------------------------------------------------------------------------- /src/features/rememberVolume/index.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv } from "@/src/types"; 2 | 3 | import { isLivePage, isShortsPage, isWatchPage, waitForSpecificMessage } from "@/src/utils/utilities"; 4 | 5 | import { setRememberedVolume, setupVolumeChangeListener } from "./utils"; 6 | 7 | /** 8 | * Sets the remembered volume based on the options received from a specific message. 9 | * It restores the last volume if the option is enabled. 10 | * 11 | * @returns {Promise} A promise that resolves once the remembered volume is set. 12 | */ 13 | export default async function enableRememberVolume(): Promise { 14 | // Wait for the "options" message from the content script 15 | const { 16 | data: { 17 | options: { enable_remember_last_volume: enableRememberVolume, remembered_volumes: rememberedVolumes } 18 | } 19 | } = await waitForSpecificMessage("options", "request_data", "content"); 20 | // If the volume is not being remembered, return 21 | if (!enableRememberVolume) return; 22 | const IsWatchPage = isWatchPage(); 23 | const IsLivePage = isLivePage(); 24 | const IsShortsPage = isShortsPage(); 25 | // Get the player container element 26 | const playerContainer = 27 | IsWatchPage || IsLivePage ? document.querySelector("div#movie_player") 28 | : IsShortsPage ? document.querySelector("div#shorts-player") 29 | : null; 30 | // If player container is not available, return 31 | if (!playerContainer) return; 32 | // If setVolume method is not available in the player container, return 33 | if (!playerContainer.setVolume) return; 34 | void setRememberedVolume({ 35 | enableRememberVolume, 36 | isShortsPage: IsShortsPage, 37 | isWatchPage: IsWatchPage || IsLivePage, 38 | playerContainer, 39 | rememberedVolumes 40 | }); 41 | void setupVolumeChangeListener(); 42 | } 43 | -------------------------------------------------------------------------------- /src/features/rememberVolume/utils.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv, configuration } from "@/src/types"; 2 | 3 | import eventManager from "@/src/utils/EventManager"; 4 | import { browserColorLog, isLivePage, isShortsPage, isWatchPage, sendContentOnlyMessage, waitForSpecificMessage } from "@/src/utils/utilities"; 5 | export async function setupVolumeChangeListener() { 6 | // Wait for the "options" message from the content script 7 | const { 8 | data: { 9 | options: { enable_remember_last_volume: enableRememberVolume } 10 | } 11 | } = await waitForSpecificMessage("options", "request_data", "content"); 12 | // If the volume is not being remembered, return 13 | if (!enableRememberVolume) return; 14 | const IsWatchPage = isWatchPage(); 15 | const IsLivePage = isLivePage(); 16 | const IsShortsPage = isShortsPage(); 17 | // Get the player container element 18 | const playerContainer = 19 | IsWatchPage || IsLivePage ? document.querySelector("div#movie_player") 20 | : IsShortsPage ? document.querySelector("div#shorts-player") 21 | : null; 22 | if (!playerContainer) return; 23 | const videoElement = playerContainer.querySelector("div > video"); 24 | if (!videoElement) return; 25 | eventManager.addEventListener( 26 | videoElement, 27 | "volumechange", 28 | ({ currentTarget }) => { 29 | void (async () => { 30 | if (!currentTarget) return; 31 | const newVolume = await playerContainer.getVolume(); 32 | if (IsWatchPage || IsLivePage) { 33 | // Send a "setVolume" message to the content script 34 | sendContentOnlyMessage("setRememberedVolume", { watchPageVolume: newVolume }); 35 | } else if (IsShortsPage) { 36 | // Send a "setVolume" message to the content script 37 | sendContentOnlyMessage("setRememberedVolume", { shortsPageVolume: newVolume }); 38 | } 39 | })(); 40 | }, 41 | "rememberVolume" 42 | ); 43 | } 44 | export async function setRememberedVolume({ 45 | enableRememberVolume, 46 | isShortsPage, 47 | isWatchPage, 48 | playerContainer, 49 | rememberedVolumes 50 | }: { 51 | enableRememberVolume: boolean; 52 | isShortsPage: boolean; 53 | isWatchPage: boolean; 54 | playerContainer: YouTubePlayerDiv; 55 | rememberedVolumes: configuration["remembered_volumes"]; 56 | }) { 57 | // If the remembered volume option is enabled, set the volume and draw the volume display 58 | if (rememberedVolumes && enableRememberVolume) { 59 | const { shortsPageVolume, watchPageVolume } = rememberedVolumes ?? {}; 60 | if (isWatchPage && watchPageVolume) { 61 | // Log the message indicating whether the last volume is being restored or not 62 | browserColorLog(`Restoring watch page volume to ${watchPageVolume}`, "FgMagenta"); 63 | await playerContainer.setVolume(watchPageVolume); 64 | } else if (isShortsPage && shortsPageVolume) { 65 | // Log the message indicating whether the last volume is being restored or not 66 | browserColorLog(`Restoring shorts page volume to ${shortsPageVolume}`, "FgMagenta"); 67 | await playerContainer.setVolume(shortsPageVolume); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/features/removeRedirect/index.ts: -------------------------------------------------------------------------------- 1 | import { type Nullable } from "@/src/types"; 2 | import { browserColorLog, waitForSpecificMessage } from "@/src/utils/utilities"; 3 | 4 | export default async function enableRemoveRedirect() { 5 | const { 6 | data: { 7 | options: { enable_redirect_remover: removeRedirectEnabled } 8 | } 9 | } = await waitForSpecificMessage("options", "request_data", "content"); 10 | if (!removeRedirectEnabled) return; 11 | browserColorLog(`Enabling removeRedirect`, "FgMagenta"); 12 | const regex = /https\:\/\/www\.youtube\.com\/redirect\?.+/gm; 13 | const links: NodeListOf = document.querySelectorAll( 14 | ".yt-core-attributed-string__link, .yt-simple-endpoint.style-scope.yt-formatted-string" 15 | ); 16 | links.forEach((link: HTMLElement) => { 17 | const href: null | string = link.getAttribute("href"); 18 | if (href && href.match(regex)) { 19 | const urlParams: URLSearchParams = new URLSearchParams(href); 20 | link.setAttribute("href", urlParams.get("q") || ""); 21 | } 22 | }); 23 | const callback: MutationCallback = (mutationsList: MutationRecord[]) => { 24 | for (const mutation of mutationsList) { 25 | if (mutation.type === "childList") { 26 | mutation.addedNodes.forEach((node: Nullable) => { 27 | if (node instanceof Element && node.hasAttribute("href")) { 28 | const href: null | string = node.getAttribute("href"); 29 | if (href !== null && href.match(regex)) { 30 | const urlParams: URLSearchParams = new URLSearchParams(href); 31 | node.setAttribute("href", urlParams.get("q") || ""); 32 | } 33 | } 34 | }); 35 | } 36 | } 37 | }; 38 | const observer: MutationObserver = new MutationObserver(callback); 39 | observer.observe(document.body, { attributes: false, childList: true, subtree: true }); 40 | } 41 | -------------------------------------------------------------------------------- /src/features/screenshotButton/index.ts: -------------------------------------------------------------------------------- 1 | import type { AddButtonFunction, RemoveButtonFunction } from "@/src/features"; 2 | 3 | import { addFeatureButton, removeFeatureButton } from "@/src/features/buttonPlacement"; 4 | import { getFeatureButton } from "@/src/features/buttonPlacement/utils"; 5 | import { getFeatureIcon } from "@/src/icons"; 6 | import { type Nullable } from "@/src/types"; 7 | import eventManager from "@/src/utils/EventManager"; 8 | import { createTooltip, waitForSpecificMessage } from "@/src/utils/utilities"; 9 | 10 | async function takeScreenshot(videoElement: HTMLVideoElement) { 11 | try { 12 | // Create a canvas element and get its context 13 | const canvas = document.createElement("canvas"); 14 | const context = canvas.getContext("2d"); 15 | // Set the dimensions of the canvas to the video's dimensions 16 | const { videoHeight, videoWidth } = videoElement; 17 | canvas.width = videoWidth; 18 | canvas.height = videoHeight; 19 | // Draw the video element onto the canvas 20 | if (!context) return; 21 | context.drawImage(videoElement, 0, 0, canvas.width, canvas.height); 22 | // Wait for the options message and get the format from it 23 | const { 24 | data: { 25 | options: { screenshot_format, screenshot_save_as } 26 | } 27 | } = await waitForSpecificMessage("options", "request_data", "content"); 28 | const blob = await new Promise>((resolve) => canvas.toBlob(resolve, "image/png")); 29 | if (!blob) return; 30 | switch (screenshot_save_as) { 31 | case "clipboard": { 32 | const screenshotButton = getFeatureButton("screenshotButton"); 33 | if (!screenshotButton) return; 34 | const { listener, remove } = createTooltip({ 35 | direction: "up", 36 | element: screenshotButton, 37 | featureName: "screenshotButton", 38 | id: "yte-feature-screenshotButton-tooltip", 39 | text: window.i18nextInstance.t("pages.content.features.screenshotButton.copiedToClipboard") 40 | }); 41 | listener(); 42 | const clipboardImage = new ClipboardItem({ "image/png": blob }); 43 | void navigator.clipboard.write([clipboardImage]); 44 | setTimeout(() => { 45 | remove(); 46 | }, 1200); 47 | break; 48 | } 49 | case "file": { 50 | const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); 51 | const a = document.createElement("a"); 52 | a.href = URL.createObjectURL(blob); 53 | a.download = `Screenshot-${location.href.match(/[\?|\&]v=([^&]+)/)?.[1]}-${timestamp}.${screenshot_format}`; 54 | a.click(); 55 | break; 56 | } 57 | } 58 | } catch (error) {} 59 | } 60 | 61 | export const addScreenshotButton: AddButtonFunction = async () => { 62 | // Wait for the "options" message from the content script 63 | const { 64 | data: { 65 | options: { 66 | button_placements: { screenshotButton: screenshotButtonPlacement }, 67 | enable_screenshot_button: enableScreenshotButton 68 | } 69 | } 70 | } = await waitForSpecificMessage("options", "request_data", "content"); 71 | // If the screenshot button option is disabled, return 72 | if (!enableScreenshotButton) return; 73 | // Add a click event listener to the screenshot button 74 | function screenshotButtonClickListener() { 75 | void (async () => { 76 | // Get the video element 77 | const videoElement = document.querySelector("video"); 78 | // If video element is not available, return 79 | if (!videoElement) return; 80 | try { 81 | // Take a screenshot 82 | await takeScreenshot(videoElement); 83 | } catch (error) { 84 | console.error(error); 85 | } 86 | })(); 87 | } 88 | await addFeatureButton( 89 | "screenshotButton", 90 | screenshotButtonPlacement, 91 | window.i18nextInstance.t("pages.content.features.screenshotButton.button.label"), 92 | getFeatureIcon("screenshotButton", screenshotButtonPlacement), 93 | screenshotButtonClickListener, 94 | false 95 | ); 96 | }; 97 | export const removeScreenshotButton: RemoveButtonFunction = async (placement) => { 98 | await removeFeatureButton("screenshotButton", placement); 99 | eventManager.removeEventListeners("screenshotButton"); 100 | }; 101 | -------------------------------------------------------------------------------- /src/features/scrollWheelSpeedControl/utils.ts: -------------------------------------------------------------------------------- 1 | import { setPlayerSpeed } from "@/src/features/playerSpeed"; 2 | import { youtubePlayerMinSpeed, type Selector } from "@/src/types"; 3 | 4 | import eventManager from "@/src/utils/EventManager"; 5 | import { browserColorLog, round } from "@/src/utils/utilities"; 6 | 7 | /** 8 | * Adjust the speed based on the scroll direction. 9 | * 10 | * @param playerContainer - The player container element. 11 | * @param scrollDelta - The scroll direction. 12 | * @param speedStep - The speed adjustment steps. 13 | * @returns Promise that resolves with the new speed. 14 | */ 15 | export function adjustSpeed(scrollDelta: number, speedStep: number): Promise<{ newSpeed: number; oldSpeed: number }> { 16 | return new Promise((resolve) => { 17 | void (async () => { 18 | const videoElement = document.querySelector("video"); 19 | if (!videoElement) return; 20 | const { playbackRate: currentPlaybackSpeed } = videoElement; 21 | const adjustmentAmount = speedStep * scrollDelta; 22 | if (currentPlaybackSpeed + adjustmentAmount > 16 || currentPlaybackSpeed + adjustmentAmount < youtubePlayerMinSpeed) return; 23 | const speed = round(currentPlaybackSpeed + adjustmentAmount, 2); 24 | setPlayerSpeed(speed); 25 | resolve({ newSpeed: speed, oldSpeed: currentPlaybackSpeed }); 26 | })(); 27 | }); 28 | } 29 | /** 30 | * Set up event listeners for the specified element. 31 | * 32 | * @param selector - The CSS selector for the element. 33 | * @param listener - The event listener function. 34 | */ 35 | export function setupScrollListeners(selector: Selector, handleWheel: (event: Event) => void) { 36 | const elements: NodeListOf = document.querySelectorAll(selector); 37 | if (!elements.length) return browserColorLog(`No elements found with selector ${selector}`, "FgRed"); 38 | for (const element of elements) { 39 | eventManager.addEventListener(element, "wheel", handleWheel, "scrollWheelSpeedControl", { passive: false }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/features/scrollWheelVolumeControl/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Selector, YouTubePlayerDiv } from "@/src/types"; 2 | 3 | import eventManager from "@/src/utils/EventManager"; 4 | import { browserColorLog, clamp, toDivisible } from "@/src/utils/utilities"; 5 | 6 | /** 7 | * Adjust the volume based on the scroll direction. 8 | * 9 | * @param playerContainer - The player container element. 10 | * @param scrollDelta - The scroll direction. 11 | * @param adjustmentSteps - The volume adjustment steps. 12 | * @returns Promise that resolves with the new volume. 13 | */ 14 | export function adjustVolume( 15 | playerContainer: YouTubePlayerDiv, 16 | scrollDelta: number, 17 | volumeStep: number 18 | ): Promise<{ newVolume: number; oldVolume: number }> { 19 | return new Promise((resolve) => { 20 | void (async () => { 21 | if (!playerContainer.getVolume) return; 22 | if (!playerContainer.setVolume) return; 23 | if (!playerContainer.isMuted) return; 24 | if (!playerContainer.unMute) return; 25 | const [volume, isMuted] = await Promise.all([playerContainer.getVolume(), playerContainer.isMuted()]); 26 | const newVolume = clamp(toDivisible(volume + scrollDelta * volumeStep, volumeStep), 0, 100); 27 | browserColorLog(`Adjusting volume by ${volumeStep} to ${newVolume}. Old volume was ${volume}`, "FgMagenta"); 28 | await playerContainer.setVolume(newVolume); 29 | if (isMuted) { 30 | if (typeof playerContainer.unMute === "function") await playerContainer.unMute(); 31 | } 32 | resolve({ newVolume, oldVolume: volume }); 33 | })(); 34 | }); 35 | } 36 | /** 37 | * Set up event listeners for the specified element. 38 | * 39 | * @param selector - The CSS selector for the element. 40 | * @param listener - The event listener function. 41 | */ 42 | export function setupScrollListeners(selector: Selector, handleWheel: (event: Event) => void) { 43 | const elements: NodeListOf = document.querySelectorAll(selector); 44 | if (!elements.length) return browserColorLog(`No elements found with selector ${selector}`, "FgRed"); 45 | for (const element of elements) { 46 | eventManager.addEventListener(element, "wheel", handleWheel, "scrollWheelVolumeControl", { passive: false }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/features/shareShortener/index.ts: -------------------------------------------------------------------------------- 1 | import { browserColorLog, waitForSpecificMessage } from "@/src/utils/utilities"; 2 | const regexp: RegExp = new RegExp("(\\?|&)(si|feature|pp)=[^&]*", "g"); 3 | let intervalId: NodeJS.Timeout | null = null; 4 | let input: HTMLInputElement | null; 5 | function cleanUrl(url: string): string { 6 | return url.replace(regexp, ""); 7 | } 8 | 9 | function cleanAndUpdateUrl() { 10 | if (intervalId) { 11 | clearInterval(intervalId); 12 | intervalId = null; 13 | input = null; 14 | } 15 | intervalId = setInterval(() => { 16 | if (!input) { 17 | input = document.querySelector("#share-url"); 18 | } 19 | if (input) { 20 | if (!input.value.match(regexp)) return; 21 | input.value = cleanUrl(input.value); 22 | } 23 | }, 50); 24 | } 25 | 26 | function cleanSearchPage(url: string) { 27 | if (!url.match(/https?:\/\/(?:www\.)?youtube\.com\/results\?search\_query\=.+/gm)) return; 28 | const allElements = Array.from(document.querySelectorAll("*")); 29 | allElements.forEach((e) => { 30 | const href: null | string = e.getAttribute("href"); 31 | if (href && href.match(/^\/watch\?v\=.+$/gm)) { 32 | e.setAttribute("href", href.replace(regexp, "")); 33 | } 34 | }); 35 | } 36 | 37 | function handleInput(event: MouseEvent) { 38 | const element = event.target as Element; 39 | if (!element.classList.contains("yt-spec-touch-feedback-shape__fill")) { 40 | return; 41 | } 42 | cleanAndUpdateUrl(); 43 | } 44 | 45 | export async function enableShareShortener() { 46 | const { 47 | data: { 48 | options: { enable_share_shortener } 49 | } 50 | } = await waitForSpecificMessage("options", "request_data", "content"); 51 | if (!enable_share_shortener) return; 52 | cleanSearchPage(window.location.href); 53 | document.addEventListener("click", handleInput); 54 | } 55 | 56 | export function disableShareShortener() { 57 | browserColorLog(`Disabling share shortener`, "FgMagenta"); 58 | document.removeEventListener("click", handleInput); 59 | if (intervalId) { 60 | clearInterval(intervalId); 61 | intervalId = null; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/features/shortsAutoScroll/index.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv } from "@/src/types"; 2 | 3 | import { setupAutoScroll } from "@/src/features/shortsAutoScroll/utils"; 4 | import eventManager from "@/src/utils/EventManager"; 5 | import { isShortsPage, waitForAllElements, waitForSpecificMessage } from "@/src/utils/utilities"; 6 | 7 | export async function enableShortsAutoScroll() { 8 | if (!isShortsPage()) return; 9 | // Wait for the "options" message from the content script 10 | const { 11 | data: { 12 | options: { enable_shorts_auto_scroll } 13 | } 14 | } = await waitForSpecificMessage("options", "request_data", "content"); 15 | // If the shorts auto scroll option is disabled, return 16 | if (!enable_shorts_auto_scroll) return; 17 | await waitForAllElements(["#shorts-player"]); 18 | // Get the shorts container 19 | const shortsContainer = document.querySelector("#shorts-player"); 20 | // If shorts container is not available, return 21 | if (!shortsContainer) return; 22 | // Get the video element 23 | const video = shortsContainer.querySelector("video"); 24 | // If video element is not available, return 25 | if (!video) return; 26 | // Setup auto scroll 27 | setupAutoScroll(shortsContainer, video); 28 | } 29 | export function disableShortsAutoScroll() { 30 | eventManager.removeEventListeners("shortsAutoScroll"); 31 | } 32 | -------------------------------------------------------------------------------- /src/features/shortsAutoScroll/utils.ts: -------------------------------------------------------------------------------- 1 | import type { YouTubePlayerDiv } from "@/src/types"; 2 | 3 | import eventManager from "@/src/utils/EventManager"; 4 | 5 | export const setupAutoScroll = (playerContainer: YouTubePlayerDiv, video: HTMLVideoElement) => { 6 | if (!("getProgressState" in playerContainer) || ("getProgressState" in playerContainer && typeof playerContainer.getProgressState !== "function")) 7 | return; 8 | const shortTimeUpdate = () => { 9 | const progressState = playerContainer.getProgressState(); 10 | const currentTime = Math.floor(progressState.current); 11 | const duration = Math.floor(progressState.duration); 12 | if (currentTime === duration) { 13 | eventManager.removeEventListener(video, "timeupdate", "shortsAutoScroll"); 14 | const nextButton = document.querySelector("#navigation-button-down > ytd-button-renderer > yt-button-shape > button"); 15 | if (!nextButton) return; 16 | // Click the next button 17 | nextButton.click(); 18 | } 19 | }; 20 | eventManager.addEventListener(video, "timeupdate", shortTimeUpdate, "shortsAutoScroll"); 21 | }; 22 | -------------------------------------------------------------------------------- /src/features/skipContinueWatching/index.ts: -------------------------------------------------------------------------------- 1 | import { browserColorLog, isNewYouTubeVideoLayout, waitForSpecificMessage } from "@/src/utils/utilities"; 2 | 3 | interface YtdWatchElement extends Element { 4 | youthereDataChanged_: () => void; 5 | } 6 | 7 | export async function enableSkipContinueWatching() { 8 | const { 9 | data: { 10 | options: { enable_skip_continue_watching } 11 | } 12 | } = await waitForSpecificMessage("options", "request_data", "content"); 13 | if (!enable_skip_continue_watching) return; 14 | browserColorLog("Enabling skipContinueWatching", "FgMagenta"); 15 | const ytdWatchElement = document.querySelector(isNewYouTubeVideoLayout() ? "ytd-watch-grid" : "ytd-watch-flexy"); 16 | if (ytdWatchElement) { 17 | (ytdWatchElement as YtdWatchElement).youthereDataChanged_ = function () {}; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/features/videoHistory/utils.ts: -------------------------------------------------------------------------------- 1 | import type { VideoHistoryStatus, VideoHistoryStorage } from "@/src/types"; 2 | export function getVideoHistory() { 3 | return JSON.parse(window.localStorage.getItem("videoHistory") ?? "{}") as VideoHistoryStorage; 4 | } 5 | export function setVideoHistory(id: string, timestamp: number, status: VideoHistoryStatus) { 6 | const history = getVideoHistory(); 7 | window.localStorage.setItem("videoHistory", JSON.stringify(Object.assign(history, { [id]: { id, status, timestamp } }))); 8 | } 9 | -------------------------------------------------------------------------------- /src/features/volumeBoost/index.ts: -------------------------------------------------------------------------------- 1 | import type { AddButtonFunction, RemoveButtonFunction } from "@/src/features"; 2 | 3 | import { addFeatureButton, removeFeatureButton } from "@/src/features/buttonPlacement"; 4 | import { updateFeatureButtonTitle } from "@/src/features/buttonPlacement/utils"; 5 | import { getFeatureIcon } from "@/src/icons"; 6 | import eventManager from "@/src/utils/EventManager"; 7 | import { browserColorLog, formatError, waitForSpecificMessage } from "@/src/utils/utilities"; 8 | 9 | export default async function volumeBoost() { 10 | const { 11 | data: { 12 | options: { enable_volume_boost, volume_boost_amount, volume_boost_mode } 13 | } 14 | } = await waitForSpecificMessage("options", "request_data", "content"); 15 | if (!enable_volume_boost) return; 16 | setupVolumeBoost(); 17 | if (volume_boost_mode === "per_video") { 18 | await addVolumeBoostButton(); 19 | } else if (volume_boost_mode === "global") { 20 | applyVolumeBoost(volume_boost_amount); 21 | } 22 | } 23 | export async function enableVolumeBoost() { 24 | setupVolumeBoost(); 25 | const { 26 | data: { 27 | options: { volume_boost_amount } 28 | } 29 | } = await waitForSpecificMessage("options", "request_data", "content"); 30 | applyVolumeBoost(volume_boost_amount); 31 | } 32 | function setupVolumeBoost() { 33 | if (!window.audioCtx || !window.gainNode) { 34 | browserColorLog(`Enabling volume boost`, "FgMagenta"); 35 | try { 36 | const player = document.querySelector("video"); 37 | if (!player) return; 38 | window.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 39 | const source = window.audioCtx.createMediaElementSource(player); 40 | const gainNode = window.audioCtx.createGain(); 41 | source.connect(gainNode); 42 | gainNode.connect(window.audioCtx.destination); 43 | window.gainNode = gainNode; 44 | } catch (error) { 45 | browserColorLog(`Failed to enable volume boost: ${formatError(error)}`, "FgRed"); 46 | } 47 | } 48 | } 49 | export function disableVolumeBoost() { 50 | if (window.gainNode) { 51 | browserColorLog(`Disabling volume boost`, "FgMagenta"); 52 | window.gainNode.gain.value = 1; // Set gain back to default 53 | } 54 | } 55 | export function applyVolumeBoost(volume_boost_amount: number) { 56 | browserColorLog(`Setting volume boost to ${Math.pow(10, volume_boost_amount / 20)}`, "FgMagenta"); 57 | if (!window.gainNode) setupVolumeBoost(); 58 | window.gainNode.gain.value = Math.pow(10, volume_boost_amount / 20); 59 | } 60 | export const addVolumeBoostButton: AddButtonFunction = async () => { 61 | const { 62 | data: { 63 | options: { 64 | button_placements: { volumeBoostButton: volumeBoostButtonPlacement } 65 | } 66 | } 67 | } = await waitForSpecificMessage("options", "request_data", "content"); 68 | await addFeatureButton( 69 | "volumeBoostButton", 70 | volumeBoostButtonPlacement, 71 | volumeBoostButtonPlacement === "feature_menu" ? 72 | window.i18nextInstance.t("pages.content.features.volumeBoostButton.button.label") 73 | : window.i18nextInstance.t(`pages.content.features.volumeBoostButton.button.toggle.off`), 74 | getFeatureIcon("volumeBoostButton", volumeBoostButtonPlacement), 75 | (checked) => { 76 | void (async () => { 77 | if (checked !== undefined) { 78 | updateFeatureButtonTitle( 79 | "volumeBoostButton", 80 | window.i18nextInstance.t(`pages.content.features.volumeBoostButton.button.toggle.${checked ? "on" : "off"}`) 81 | ); 82 | if (checked) { 83 | await enableVolumeBoost(); 84 | } else { 85 | disableVolumeBoost(); 86 | } 87 | } 88 | })(); 89 | }, 90 | true 91 | ); 92 | }; 93 | export const removeVolumeBoostButton: RemoveButtonFunction = async (placement) => { 94 | await removeFeatureButton("volumeBoostButton", placement); 95 | eventManager.removeEventListeners("volumeBoostButton"); 96 | }; 97 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import type { i18nInstanceType } from "./i18n"; 2 | 3 | declare module "*.svg" { 4 | import React = require("react"); 5 | export const ReactComponent: React.SFC>; 6 | const src: string; 7 | export default src; 8 | } 9 | 10 | declare module "*.json" { 11 | const content: string; 12 | export default content; 13 | } 14 | 15 | declare module "node_modules/@types/youtube-player/dist/types" { 16 | interface VideoData { 17 | allowLiveDvr: boolean; 18 | author: string; 19 | backgroundable: boolean; 20 | cpn: string; 21 | errorCode?: any; 22 | eventId: string; 23 | hasProgressBarBoundaries: boolean; 24 | isListed: boolean; 25 | isLive: boolean; 26 | isManifestless: boolean; 27 | isMultiChannelAudio: boolean; 28 | isPlayable: boolean; 29 | isPremiere: boolean; 30 | isWindowedLive: boolean; 31 | itct: string; 32 | paidContentOverlayDurationMs: number; 33 | progressBarEndPositionUtcTimeMillis?: any; 34 | progressBarStartPositionUtcTimeMillis?: any; 35 | title: string; 36 | video_id: string; 37 | video_quality: string; 38 | video_quality_features: string[]; 39 | } 40 | interface ProgressState { 41 | airingEnd: number; 42 | airingStart: number; 43 | allowSeeking: boolean; 44 | clipEnd: null | number; 45 | clipStart: number; 46 | current: number; 47 | displayedStart: number; 48 | duration: number; 49 | ingestionTime: null; 50 | isAtLiveHead: boolean; 51 | loaded: number; 52 | offset: number; 53 | seekableEnd: number; 54 | seekableStart: number; 55 | viewerLivestreamJoinMediaTime: number; 56 | } 57 | interface YouTubePlayer { 58 | unloadModule(moduleName: string): void; 59 | loadModule(moduleName: string): void; 60 | getProgressState(): ProgressState; 61 | getVideoBytesLoaded(): Promise; 62 | getVideoData(): Promise; 63 | setPlaybackQualityRange(suggestedQuality: string): Promise; 64 | } 65 | } 66 | declare global { 67 | interface ObjectConstructor { 68 | entries(o: { [K in keyof T]: T[K] }): [keyof T, T[keyof T]][]; 69 | keys(o: T): (keyof T)[]; 70 | } 71 | interface Window { 72 | audioCtx: AudioContext; 73 | cachedPlaylistDuration: { playlistId: string; totalTimeSeconds: number } | null; 74 | gainNode: GainNode; 75 | i18nextInstance: i18nInstanceType; 76 | webkitAudioContext: AudioContext; 77 | } 78 | } 79 | 80 | export {}; 81 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import useClickOutside from "./useClickOutside"; 2 | import useComponentVisible from "./useComponentVisible"; 3 | import useNotifications from "./useNotifications"; 4 | import useRunAfterUpdate from "./useRunAfterUpdate"; 5 | export { useClickOutside, useComponentVisible, useNotifications, useRunAfterUpdate }; 6 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import type { Nullable } from "@/src/types"; 2 | 3 | import { type RefObject, useEffect } from "react"; 4 | 5 | // Improved version of https://usehooks.com/useOnClickOutside/ 6 | 7 | const useClickOutside = ( 8 | ref: RefObject, 9 | handler: (event: FocusEvent | MouseEvent | TouchEvent) => void 10 | ) => { 11 | useEffect(() => { 12 | let startedInside: Nullable | boolean> = false; 13 | let startedWhenMounted: Nullable["current"] | boolean> = false; 14 | const listener = (event: FocusEvent | MouseEvent | TouchEvent) => { 15 | // Do nothing if `mousedown` or `touchstart` started inside ref element 16 | if (startedInside || !startedWhenMounted) return; 17 | // Do nothing if clicking ref's element or descendent elements 18 | if (!ref.current || ref.current.contains(event.target as Node)) return; 19 | handler(event); 20 | }; 21 | const validateEventStart = (event: FocusEvent | MouseEvent | TouchEvent) => { 22 | ({ current: startedWhenMounted } = ref); 23 | startedInside = event.target && ref.current && ref.current.contains(event.target as Node); 24 | }; 25 | document.addEventListener("mousedown", validateEventStart); 26 | document.addEventListener("touchstart", validateEventStart); 27 | document.addEventListener("click", listener); 28 | return () => { 29 | document.removeEventListener("mousedown", validateEventStart); 30 | document.removeEventListener("touchstart", validateEventStart); 31 | document.removeEventListener("click", listener); 32 | }; 33 | }, [ref, handler]); 34 | }; 35 | 36 | export default useClickOutside; 37 | -------------------------------------------------------------------------------- /src/hooks/useComponentVisible.ts: -------------------------------------------------------------------------------- 1 | import { type RefObject, useCallback, useEffect, useState } from "react"; 2 | 3 | export default function useComponentVisible( 4 | ref: RefObject, 5 | initialIsVisible: boolean 6 | ) { 7 | const [isComponentVisible, setIsComponentVisible] = useState(initialIsVisible); 8 | const handleClickOutside = useCallback( 9 | (event: MouseEvent) => { 10 | if (ref.current && !ref.current.contains(event.target as Node)) { 11 | setIsComponentVisible(false); 12 | } 13 | }, 14 | [ref] 15 | ); 16 | useEffect(() => { 17 | document.addEventListener("click", handleClickOutside, true); 18 | return () => { 19 | document.removeEventListener("click", handleClickOutside, true); 20 | }; 21 | }, [handleClickOutside]); 22 | return { isComponentVisible, setIsComponentVisible }; 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/useNotifications/context.ts: -------------------------------------------------------------------------------- 1 | import type { Notification, NotificationAction, NotificationType } from "@/src/types"; 2 | 3 | import { createContext } from "react"; 4 | 5 | export type AddNotification = (type: NotificationType, message: Notification["message"], action?: NotificationAction) => void; 6 | 7 | export type RemoveNotification = (notification: Notification) => void; 8 | export type CreateNotification = (type: NotificationType, message: Notification["message"], action?: NotificationAction) => Notification; 9 | 10 | export type ScheduleNotificationRemoval = (notification: Notification, removeAfterMs?: number) => void; 11 | export type NotificationsContextProps = { 12 | addNotification: AddNotification; 13 | notifications: Notification[]; 14 | removeNotification: RemoveNotification; 15 | }; 16 | export const NotificationsContext = createContext(undefined); 17 | -------------------------------------------------------------------------------- /src/hooks/useNotifications/index.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { NotificationsContext } from "./context"; 4 | const useNotifications = () => { 5 | const context = useContext(NotificationsContext); 6 | if (!context) { 7 | throw new Error("useNotifications must be used within a NotificationsProvider"); 8 | } 9 | return context; 10 | }; 11 | export default useNotifications; 12 | -------------------------------------------------------------------------------- /src/hooks/useNotifications/provider.tsx: -------------------------------------------------------------------------------- 1 | import type { Notification } from "@/src/types"; 2 | 3 | import { isNotStrictEqual } from "@/src/utils/utilities"; 4 | import { type ReactElement, useEffect, useState } from "react"; 5 | 6 | import { 7 | type AddNotification, 8 | type CreateNotification, 9 | NotificationsContext, 10 | type NotificationsContextProps, 11 | type RemoveNotification, 12 | type ScheduleNotificationRemoval 13 | } from "./context"; 14 | type NotificationProviderProps = { children: ReactElement | ReactElement[] }; 15 | export const NotificationsProvider = ({ children }: NotificationProviderProps) => { 16 | const [notifications, setNotifications] = useState([]); 17 | const notificationIsEqual = (a: Notification, b: Notification) => { 18 | return a.type === b.type && a.message === b.message && a.action === b.action; 19 | }; 20 | const createNotification: CreateNotification = (type, message, action) => { 21 | const removeNotificationAfterMs = action && action === "reset_settings" ? 15_000 : 5_000; 22 | const notification = { action, message, removeAfterMs: removeNotificationAfterMs, timestamp: +new Date(), type } satisfies Notification; 23 | return notification; 24 | }; 25 | const scheduleNotificationRemoval: ScheduleNotificationRemoval = (notification, removeAfterMs) => { 26 | if (removeAfterMs) { 27 | setTimeout(() => { 28 | removeNotification(notification); 29 | }, removeAfterMs); 30 | } 31 | }; 32 | const addNotification: AddNotification = (type, message, action) => { 33 | const notification = createNotification(type, message, action); 34 | const existingNotification = notifications.find((n) => notificationIsEqual(n, notification)); 35 | if (existingNotification) return; 36 | setNotifications((notifications) => [notification, ...notifications]); 37 | scheduleNotificationRemoval(notification, notification.removeAfterMs); 38 | }; 39 | const removeNotification: RemoveNotification = (notification) => { 40 | setNotifications((notifications) => notifications.filter(isNotStrictEqual(notification))); 41 | }; 42 | useEffect(() => { 43 | let animationFrameId: null | number = null; 44 | const updateNotifications = () => { 45 | const now = Date.now(); 46 | setNotifications((prevNotifications) => { 47 | if (prevNotifications.length === 0) return prevNotifications; 48 | return prevNotifications.reduce((acc: Notification[], notification) => { 49 | const elapsed = now - (notification.timestamp ?? now); 50 | const progress = Math.max(100 - (elapsed / (notification.removeAfterMs ?? 3000)) * 100, 0); 51 | if (progress > 0) { 52 | acc.push({ ...notification, progress }); 53 | } 54 | return acc; 55 | }, []); 56 | }); 57 | animationFrameId = requestAnimationFrame(updateNotifications); 58 | }; 59 | updateNotifications(); 60 | return () => { 61 | if (animationFrameId !== null) cancelAnimationFrame(animationFrameId); 62 | }; 63 | }, []); 64 | const contextValue = { addNotification, notifications, removeNotification } satisfies NotificationsContextProps; 65 | return {children}; 66 | }; 67 | -------------------------------------------------------------------------------- /src/hooks/useRunAfterUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef } from "react"; 2 | 3 | import type { AnyFunction } from "../types"; 4 | 5 | const useRunAfterUpdate = () => { 6 | const handlersRef = useRef([]); 7 | useLayoutEffect(() => { 8 | handlersRef.current.forEach((handler) => handler()); 9 | handlersRef.current = []; 10 | }); 11 | return (handler: AnyFunction) => { 12 | handlersRef.current.push(handler); 13 | }; 14 | }; 15 | export default useRunAfterUpdate; 16 | -------------------------------------------------------------------------------- /src/hooks/useSectionTitle/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export type SectionTitleContextProps = { 4 | title: string; 5 | }; 6 | export const SectionTitleContext = createContext(undefined); 7 | -------------------------------------------------------------------------------- /src/hooks/useSectionTitle/index.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { SectionTitleContext } from "./context"; 4 | 5 | const useSectionTitle = () => { 6 | const context = useContext(SectionTitleContext); 7 | if (context === undefined) { 8 | throw new Error("useSectionTitle must be used within a SectionTitleProvider"); 9 | } 10 | return context; 11 | }; 12 | export default useSectionTitle; 13 | -------------------------------------------------------------------------------- /src/hooks/useSectionTitle/provider.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/src/utils/utilities"; 2 | 3 | import { SectionTitleContext, type SectionTitleContextProps } from "./context"; 4 | export const SectionTitleProvider = ( 5 | context: { 6 | children: React.ReactNode; 7 | className: string; 8 | } & SectionTitleContextProps 9 | ) => { 10 | return ( 11 | 12 |
{context.children}
13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/hooks/useSettingsFilter/context.ts: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type SetStateAction, createContext } from "react"; 2 | 3 | export type SettingsFilterContextProps = { 4 | filter: string; 5 | setFilter: Dispatch>; 6 | }; 7 | 8 | export const SettingsFilterContext = createContext(undefined); 9 | -------------------------------------------------------------------------------- /src/hooks/useSettingsFilter/index.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | 3 | import { SettingsFilterContext } from "./context"; 4 | 5 | const useSettingsFilter = () => { 6 | const context = useContext(SettingsFilterContext); 7 | if (context === undefined) { 8 | throw new Error("useSettingsFilter must be used within a SettingsFilterProvider"); 9 | } 10 | return context; 11 | }; 12 | 13 | export default useSettingsFilter; 14 | -------------------------------------------------------------------------------- /src/hooks/useSettingsFilter/provider.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactElement, useState } from "react"; 2 | 3 | import { SettingsFilterContext, type SettingsFilterContextProps } from "./context"; 4 | 5 | export const SettingsFilterProvider = (context: { children: ReactElement | ReactElement[] }) => { 6 | const [filter, setFilter] = useState(""); 7 | 8 | return ( 9 | 10 | {context.children} 11 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/hooks/useStorage.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch, SetStateAction } from "react"; 2 | 3 | import { useCallback, useEffect, useRef, useState } from "react"; 4 | 5 | export type StorageArea = "local" | "sync"; 6 | 7 | // custom hook to set chrome local/sync storage 8 | // should also set a listener on this specific key 9 | 10 | type SetValue = Dispatch>; 11 | 12 | /** 13 | * Returns a stateful value from storage, and a function to update it. 14 | */ 15 | export function useStorage(key: string, initialValue: T, area: StorageArea = "local"): [T, SetValue] { 16 | const [storedValue, setStoredValue] = useState(initialValue); 17 | useEffect(() => { 18 | readStorage(key, area) 19 | .then((res) => { 20 | if (res) return setStoredValue(res); 21 | return; 22 | }) 23 | .catch((err) => console.error(err)); 24 | chrome.storage.onChanged.addListener((changes, namespace) => { 25 | if (namespace === area && Object.hasOwnProperty.call(changes, key)) { 26 | if (changes[key].newValue) setStoredValue(changes[key].newValue as unknown as T); 27 | } 28 | }); 29 | }, [area, key]); 30 | const setValueRef = useRef>(); 31 | setValueRef.current = (value) => { 32 | // Allow value to be a function, so we have the same API as useState 33 | const newValue = value instanceof Function ? value(storedValue) : value; 34 | // Save to storage 35 | setStoredValue((prevState) => { 36 | setStorage(key, newValue, area) 37 | .then((success) => { 38 | if (!success) setStoredValue(prevState); 39 | return; 40 | }) 41 | .catch((error) => console.error(error)); 42 | return newValue; 43 | }); 44 | }; 45 | // Return a wrapped version of useState's setter function that ... 46 | // ... persists the new value to storage. 47 | const setValue: SetValue = useCallback((value) => setValueRef.current?.(value), []); 48 | return [storedValue, setValue]; 49 | } 50 | 51 | /** 52 | * Retrieves value from chrome storage area 53 | * 54 | * @param key 55 | * @param area - defaults to local 56 | */ 57 | export async function readStorage(key: string, area: StorageArea = "local"): Promise { 58 | try { 59 | const result = await chrome.storage[area].get(key); 60 | return result?.[key] as unknown as T; 61 | } catch (error) { 62 | console.warn(`Error reading ${area} storage key "${key}":`, error); 63 | return undefined; 64 | } 65 | } 66 | 67 | /** 68 | * Sets object in chrome storage area 69 | * 70 | * @param key 71 | * @param value - value to be saved 72 | * @param area - defaults to local 73 | */ 74 | export async function setStorage(key: string, value: T, area: StorageArea = "local"): Promise { 75 | try { 76 | await chrome.storage[area].set({ [key]: value }); 77 | return true; 78 | } catch (error) { 79 | console.warn(`Error setting ${area} storage key "${key}":`, error); 80 | return false; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/i18n/constants.ts: -------------------------------------------------------------------------------- 1 | export const availableLocales = [ 2 | "ca-ES", 3 | "cs-CZ", 4 | "de-DE", 5 | "en-GB", 6 | "en-US", 7 | "es-ES", 8 | "fa-IR", 9 | "fr-FR", 10 | "he-IL", 11 | "hi-IN", 12 | "it-IT", 13 | "ja-JP", 14 | "ko-KR", 15 | "pl-PL", 16 | "pt-BR", 17 | "ru-RU", 18 | "sv-SE", 19 | "tr-TR", 20 | "uk-UA", 21 | "vi-VN", 22 | "zh-CN", 23 | "zh-TW" 24 | ] as const; 25 | export const localePercentages: Record = { 26 | "en-US": 100, 27 | "ca-ES": 0, 28 | "cs-CZ": 0, 29 | "de-DE": 27, 30 | "en-GB": 1, 31 | "es-ES": 48, 32 | "fa-IR": 0, 33 | "fr-FR": 50, 34 | "he-IL": 0, 35 | "hi-IN": 0, 36 | "it-IT": 89, 37 | "ja-JP": 89, 38 | "ko-KR": 89, 39 | "pl-PL": 0, 40 | "pt-BR": 55, 41 | "ru-RU": 89, 42 | "sv-SE": 89, 43 | "tr-TR": 62, 44 | "uk-UA": 18, 45 | "vi-VN": 0, 46 | "zh-CN": 89, 47 | "zh-TW": 86 48 | }; 49 | export const localeDirection: Record = { 50 | "ca-ES": "ltr", 51 | "cs-CZ": "ltr", 52 | "de-DE": "ltr", 53 | "en-GB": "ltr", 54 | "en-US": "ltr", 55 | "es-ES": "ltr", 56 | "fa-IR": "rtl", 57 | "fr-FR": "ltr", 58 | "he-IL": "rtl", 59 | "hi-IN": "ltr", 60 | "it-IT": "ltr", 61 | "ja-JP": "ltr", 62 | "ko-KR": "ltr", 63 | "pl-PL": "ltr", 64 | "pt-BR": "ltr", 65 | "ru-RU": "ltr", 66 | "sv-SE": "ltr", 67 | "tr-TR": "ltr", 68 | "uk-UA": "ltr", 69 | "vi-VN": "ltr", 70 | "zh-CN": "ltr", 71 | "zh-TW": "ltr" 72 | }; 73 | export type AvailableLocales = (typeof availableLocales)[number]; 74 | -------------------------------------------------------------------------------- /src/i18n/i18n.d.ts: -------------------------------------------------------------------------------- 1 | import "i18next"; 2 | declare module "i18next" { 3 | interface CustomTypeOptions { 4 | defaultNS: "en-US"; 5 | resources: { 6 | "en-US": typeof import("../../public/locales/en-US.json"); 7 | }; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { type AvailableLocales, availableLocales } from "@/src/i18n/constants"; 2 | import { type Resource, createInstance } from "i18next"; 3 | 4 | import { waitForSpecificMessage } from "../utils/utilities"; 5 | export type i18nInstanceType = ReturnType; 6 | 7 | export async function i18nService(locale: AvailableLocales) { 8 | let extensionURL; 9 | const isYouTube = window.location.hostname === "www.youtube.com"; 10 | if (isYouTube) { 11 | const extensionURLResponse = await waitForSpecificMessage("extensionURL", "request_data", "content"); 12 | if (!extensionURLResponse) throw new Error("Failed to get extension URL"); 13 | ({ 14 | data: { extensionURL } 15 | } = extensionURLResponse); 16 | } else { 17 | extensionURL = chrome.runtime.getURL(""); 18 | } 19 | if (!availableLocales.includes(locale)) throw new Error(`The locale '${locale}' is not available`); 20 | const response = await fetch(`${extensionURL}locales/${locale}.json`).catch((err) => { 21 | if (err instanceof Error) { 22 | throw err; 23 | } else { 24 | throw new Error("unknown error"); 25 | } 26 | }); 27 | const translations = (await response.json()) as typeof import("../../public/locales/en-US.json"); 28 | const i18nextInstance = await new Promise((resolve, reject) => { 29 | const resources: { 30 | [k in AvailableLocales]?: { 31 | translation: typeof import("../../public/locales/en-US.json"); 32 | }; 33 | } = { 34 | [locale]: { translation: translations } 35 | }; 36 | const instance = createInstance(); 37 | void instance.init( 38 | { 39 | debug: true, 40 | fallbackLng: "en-US", 41 | interpolation: { 42 | escapeValue: false 43 | }, 44 | lng: locale, 45 | resources: resources as unknown as { [key: string]: Resource }, 46 | returnObjects: true 47 | }, 48 | (err) => { 49 | if (err) reject(err); 50 | else resolve(instance); 51 | } 52 | ); 53 | }); 54 | return i18nextInstance; 55 | } 56 | -------------------------------------------------------------------------------- /src/manifest.ts: -------------------------------------------------------------------------------- 1 | import type { Manifest } from "webextension-polyfill"; 2 | 3 | import pkg from "../package.json"; 4 | import { availableLocales } from "./i18n/constants"; 5 | const permissions: Manifest.Permission[] = ["activeTab", "webRequest", "storage", "tabs", "scripting"]; 6 | const hostPermissions: Manifest.MatchPattern[] = ["https://www.youtube.com/*"]; 7 | const resources = [ 8 | "contentStyle.css", 9 | "/icons/icon_128.png", 10 | "/icons/icon_48.png", 11 | "/icons/icon_16.png", 12 | "src/pages/content/index.js", 13 | "src/pages/embedded/index.js", 14 | ...availableLocales.map((locale) => `/locales/${locale}.json`) 15 | ]; 16 | const icons = { 17 | "16": "/icons/icon_16.png", 18 | "19": "/icons/icon_19.png", 19 | "38": "/icons/icon_38.png", 20 | "48": "/icons/icon_48.png", 21 | "128": "/icons/icon_128.png" 22 | }; 23 | const action = { 24 | default_icon: "/icons/icon_48.png", 25 | default_popup: "src/pages/popup/index.html" 26 | }; 27 | const manifestV3: Manifest.WebExtensionManifest = { 28 | action, 29 | author: pkg.author.name, 30 | background: { 31 | service_worker: "src/pages/background/index.js", 32 | type: "module" 33 | }, 34 | content_scripts: [ 35 | { 36 | all_frames: true, 37 | css: ["contentStyle.css"], 38 | js: ["src/pages/content/index.js"], 39 | matches: ["https://www.youtube.com/*"], 40 | run_at: "document_start" 41 | } 42 | ], 43 | description: pkg.description, 44 | host_permissions: hostPermissions, 45 | icons, 46 | manifest_version: 3, 47 | name: pkg.displayName, 48 | options_ui: { 49 | page: "src/pages/options/index.html" 50 | }, 51 | permissions, 52 | version: pkg.version, 53 | web_accessible_resources: [ 54 | { 55 | matches: ["https://www.youtube.com/*"], 56 | resources 57 | } 58 | ] 59 | }; 60 | const manifestV2: Manifest.WebExtensionManifest = { 61 | author: pkg.author.name, 62 | background: { 63 | page: "src/pages/background/index.html" 64 | }, 65 | browser_action: action, 66 | browser_specific_settings: { 67 | gecko: { 68 | id: "{c49b13b1-5dee-4345-925e-0c793377e3fa}" 69 | } 70 | }, 71 | content_scripts: [ 72 | { 73 | css: ["contentStyle.css"], 74 | js: ["src/pages/content/index.js"], 75 | matches: ["https://www.youtube.com/*"], 76 | run_at: "document_start" 77 | } 78 | ], 79 | description: pkg.description, 80 | icons, 81 | manifest_version: 2, 82 | name: pkg.displayName, 83 | options_ui: { 84 | page: "src/pages/options/index.html" 85 | }, 86 | permissions: permissions.concat(hostPermissions), 87 | version: pkg.version, 88 | web_accessible_resources: resources 89 | }; 90 | 91 | export { manifestV2, manifestV3 }; 92 | -------------------------------------------------------------------------------- /src/pages/background/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | YouTube Enhancer Background 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/pages/background/index.ts: -------------------------------------------------------------------------------- 1 | import type { ContentToBackgroundSendOnlyMessageMappings } from "@/src/types"; 2 | 3 | import { updateStoredSettings } from "@/src/utils/updateStoredSettings"; 4 | 5 | import { version } from "../../../package.json"; 6 | 7 | chrome.runtime.onInstalled.addListener((details) => { 8 | const { previousVersion, reason } = details; 9 | if (!previousVersion) return; 10 | switch (reason) { 11 | case chrome.runtime.OnInstalledReason.INSTALL: { 12 | // Open the options page after install 13 | void chrome.tabs.create({ url: "/src/pages/options/index.html" }); 14 | break; 15 | } 16 | case chrome.runtime.OnInstalledReason.UPDATE: { 17 | if ( 18 | isNewMajorVersion(previousVersion as VersionString, version as VersionString) || 19 | isNewMinorVersion(previousVersion as VersionString, version as VersionString) 20 | ) { 21 | // Open options page if a new major or minor version is released 22 | void chrome.tabs.create({ url: "/src/pages/options/index.html" }); 23 | } 24 | void updateStoredSettings(); 25 | break; 26 | } 27 | } 28 | }); 29 | type VersionString = `${string}.${string}.${string}`; 30 | 31 | function isNewMinorVersion(oldVersion: VersionString, newVersion: VersionString) { 32 | const [, oldMinorVersion] = oldVersion.split("."); 33 | const [, newMinorVersion] = newVersion.split("."); 34 | return oldMinorVersion !== newMinorVersion; 35 | } 36 | function isNewMajorVersion(oldVersion: VersionString, newVersion: VersionString) { 37 | const [oldMajorVersion] = oldVersion.split("."); 38 | const [newMajorVersion] = newVersion.split("."); 39 | return oldMajorVersion !== newMajorVersion; 40 | } 41 | chrome.runtime.onMessage.addListener((message: ContentToBackgroundSendOnlyMessageMappings[keyof ContentToBackgroundSendOnlyMessageMappings]) => { 42 | switch (message.type) { 43 | case "pauseBackgroundPlayers": { 44 | // Get the active tab's ID 45 | chrome.tabs.query({ active: true, currentWindow: true }, (activeTabs) => { 46 | const [activeTab] = activeTabs; 47 | const { id: activeTabId } = activeTab; 48 | // Query all tabs except the active one 49 | chrome.tabs.query({ url: "https://www.youtube.com/*" }, (tabs) => { 50 | for (const tab of tabs) { 51 | // Skip the active tab 52 | if (tab.id === activeTabId) continue; 53 | if (tab.id !== undefined) { 54 | chrome.scripting.executeScript( 55 | { 56 | func: () => { 57 | const videos = document.querySelectorAll("video"); 58 | videos.forEach((video) => { 59 | if (!video.paused) { 60 | video.pause(); 61 | } 62 | }); 63 | const audios = document.querySelectorAll("audio"); 64 | audios.forEach((audio) => { 65 | if (!audio.paused) { 66 | audio.pause(); 67 | } 68 | }); 69 | }, 70 | target: { tabId: tab.id } 71 | }, 72 | (results) => { 73 | if (chrome.runtime.lastError) { 74 | console.error(chrome.runtime.lastError.message); 75 | } else { 76 | if (results[0].result) { 77 | console.log(results); 78 | } 79 | } 80 | } 81 | ); 82 | } 83 | } 84 | }); 85 | }); 86 | break; 87 | } 88 | } 89 | }); 90 | -------------------------------------------------------------------------------- /src/pages/embedded/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/pages/options/Options.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YouTube-Enhancer/extension/1e203d8737b597f120a42004aada0fe888e00405/src/pages/options/Options.css -------------------------------------------------------------------------------- /src/pages/options/Options.tsx: -------------------------------------------------------------------------------- 1 | import Settings from "@/src/components/Settings/Settings"; 2 | import { NotificationsProvider } from "@/src/hooks/useNotifications/provider"; 3 | import { SettingsFilterProvider } from "@/src/hooks/useSettingsFilter/provider"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | import React from "react"; 6 | 7 | export default function Options(): JSX.Element { 8 | const client = new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | refetchInterval: 75, 12 | refetchOnWindowFocus: true, 13 | staleTime: 250 14 | } 15 | } 16 | }); 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/options/index.css: -------------------------------------------------------------------------------- 1 | html > body { 2 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 3 | sans-serif; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | overflow-y: scroll; 7 | } 8 | body { 9 | width: 90%; 10 | min-width: 400px; 11 | max-width: 640px; 12 | margin: 32px auto !important; 13 | } 14 | html > body > div#__root > div { 15 | margin: auto; 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | YouTube Enhancer | Options 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/pages/options/index.tsx: -------------------------------------------------------------------------------- 1 | import Options from "@/pages/options/Options"; 2 | import "@/pages/options/index.css"; 3 | import React from "react"; 4 | import { createRoot } from "react-dom/client"; 5 | 6 | function init() { 7 | const rootContainer = document.querySelector("#__root"); 8 | if (!rootContainer) throw new Error("Can't find Options root element"); 9 | const root = createRoot(rootContainer); 10 | root.render(); 11 | } 12 | 13 | init(); 14 | -------------------------------------------------------------------------------- /src/pages/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import Settings from "@/src/components/Settings/Settings"; 2 | import { NotificationsProvider } from "@/src/hooks/useNotifications/provider"; 3 | import { SettingsFilterProvider } from "@/src/hooks/useSettingsFilter/provider"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | 6 | export default function Options(): JSX.Element { 7 | const client = new QueryClient({ 8 | defaultOptions: { 9 | queries: { 10 | refetchInterval: 75, 11 | refetchOnWindowFocus: true, 12 | staleTime: 250 13 | } 14 | } 15 | }); 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/popup/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: fit-content; 3 | margin: 12px; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | min-width: 400px; 9 | max-width: 640px; 10 | position: relative; 11 | overflow-y: scroll; 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | YouTube Enhancer | Options 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/pages/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import "@/assets/styles/tailwind.css"; 2 | import Popup from "@/pages/popup/Popup"; 3 | import "@/pages/popup/index.css"; 4 | import React from "react"; 5 | import { createRoot } from "react-dom/client"; 6 | 7 | function init() { 8 | const rootContainer = document.querySelector("#__root"); 9 | if (!rootContainer) throw new Error("Can't find Popup root element"); 10 | const root = createRoot(rootContainer); 11 | root.render(); 12 | } 13 | 14 | init(); 15 | -------------------------------------------------------------------------------- /src/reset.d.ts: -------------------------------------------------------------------------------- 1 | import "@total-typescript/ts-reset"; 2 | -------------------------------------------------------------------------------- /src/utils/checkLocalesForMissingKeys.ts: -------------------------------------------------------------------------------- 1 | import type EnUS from "public/locales/en-US.json"; 2 | 3 | import { availableLocales } from "../i18n/constants"; 4 | import { type LocaleFile, flattenLocaleValues, getLocaleFile } from "./plugins/utils"; 5 | function checkForMissingKeys(englishFile: LocaleFile, localeFile: LocaleFile) { 6 | const { keys: englishKeys } = flattenLocaleValues(englishFile); 7 | const { keys: localeKeys } = flattenLocaleValues(localeFile); 8 | if (englishKeys.length !== localeKeys.length) { 9 | const missingKeys = englishKeys.filter((key) => !localeKeys.includes(key)); 10 | const message = `${(localeFile as unknown as EnUS)["langCode"]} is missing ${missingKeys.length} keys\nMissing keys:\n${missingKeys.join(", ")}`; 11 | return message; 12 | } 13 | return false; 14 | } 15 | export default function checkLocalesForMissingKeys() { 16 | const englishFile = getLocaleFile("en-US"); 17 | const missingKeys = availableLocales 18 | .filter((availableLocales) => availableLocales !== "en-US") 19 | .map((locale) => { 20 | const localeFile = getLocaleFile(locale); 21 | return checkForMissingKeys(englishFile, localeFile); 22 | }) 23 | .filter(Boolean); 24 | if (missingKeys.length) { 25 | throw new Error(missingKeys.join("\n\n")); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/log.ts: -------------------------------------------------------------------------------- 1 | import { getFormattedTimestamp } from "./utilities"; 2 | 3 | type ColorType = "error" | "info" | "success" | "warning" | keyof typeof TerminalColors; 4 | function getColor(type: ColorType) { 5 | switch (type) { 6 | case "error": 7 | return TerminalColors.FgRed; 8 | case "info": 9 | return TerminalColors.FgBlue; 10 | case "success": 11 | return TerminalColors.FgGreen; 12 | case "warning": 13 | return TerminalColors.FgYellow; 14 | default: 15 | return TerminalColors[type]; 16 | } 17 | } 18 | export function colorizeTerminalLog(message: string, type: ColorType = "FgBlack") { 19 | const color = getColor(type); 20 | return `${color}${message}${TerminalColors.Reset}`; 21 | } 22 | export default function terminalColorLog(message: string, type?: ColorType) { 23 | console.log(colorizeTerminalLog(`[${getFormattedTimestamp()}] [YouTube Enhancer]`, "FgCyan"), colorizeTerminalLog(message, type)); 24 | } 25 | 26 | const TerminalColors = { 27 | BgBlack: "\x1b[40m", 28 | BgBlue: "\x1b[44m", 29 | BgCyan: "\x1b[46m", 30 | BgGreen: "\x1b[42m", 31 | BgMagenta: "\x1b[45m", 32 | BgRed: "\x1b[41m", 33 | BgWhite: "\x1b[47m", 34 | BgYellow: "\x1b[43m", 35 | Blink: "\x1b[5m", 36 | Bright: "\x1b[1m", 37 | Dim: "\x1b[2m", 38 | FgBlack: "\x1b[30m", 39 | FgBlue: "\x1b[34m", 40 | FgCyan: "\x1b[36m", 41 | FgGreen: "\x1b[32m", 42 | FgMagenta: "\x1b[35m", 43 | FgRed: "\x1b[31m", 44 | FgWhite: "\x1b[37m", 45 | FgYellow: "\x1b[33m", 46 | Hidden: "\x1b[8m", 47 | Reset: "\x1b[0m", 48 | Reverse: "\x1b[7m", 49 | Underscore: "\x1b[4m" 50 | } as const; 51 | -------------------------------------------------------------------------------- /src/utils/monaco.ts: -------------------------------------------------------------------------------- 1 | import type { editor } from "monaco-editor/esm/vs/editor/editor.api"; 2 | 3 | import "monaco-editor/esm/vs/basic-languages/css/css"; 4 | import "monaco-editor/esm/vs/basic-languages/css/css.contribution"; 5 | import "monaco-editor/esm/vs/editor/edcore.main"; 6 | import "monaco-editor/esm/vs/editor/editor.all.js"; 7 | import { MarkerSeverity } from "monaco-editor/esm/vs/editor/editor.api"; 8 | import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; 9 | import "monaco-editor/esm/vs/language/css/cssMode"; 10 | import "monaco-editor/esm/vs/language/css/monaco.contribution"; 11 | export { MarkerSeverity, type editor, monaco }; 12 | -------------------------------------------------------------------------------- /src/utils/plugins/build-content-script.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | 3 | import { resolve } from "path"; 4 | import { build } from "vite"; 5 | import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; 6 | 7 | import { ENABLE_SOURCE_MAP, outputFolderName } from "../constants"; 8 | const packages: { [entryAlias: string]: string }[] = [ 9 | { 10 | content: resolve(__dirname, "../../../", "src/pages/content/index.ts") 11 | }, 12 | { 13 | embedded: resolve(__dirname, "../../../", "src/pages/embedded/index.ts") 14 | } 15 | ]; 16 | const root = resolve("src"); 17 | const pagesDir = resolve(root, "pages"); 18 | const assetsDir = resolve(root, "assets"); 19 | const componentsDir = resolve(root, "components"); 20 | const utilsDir = resolve(root, "utils"); 21 | const hooksDir = resolve(root, "hooks"); 22 | 23 | const outDir = resolve(__dirname, "../../../", outputFolderName); 24 | export default function buildContentScript(): PluginOption { 25 | return { 26 | async buildEnd() { 27 | for (const _package of packages) { 28 | await build({ 29 | build: { 30 | emptyOutDir: false, 31 | outDir: resolve(outDir, "temp"), 32 | rollupOptions: { 33 | input: _package, 34 | output: { 35 | entryFileNames: (chunk) => { 36 | return `src/pages/${chunk.name}/index.js`; 37 | } 38 | } 39 | }, 40 | sourcemap: ENABLE_SOURCE_MAP 41 | }, 42 | configFile: false, 43 | plugins: [cssInjectedByJsPlugin()], 44 | publicDir: false, 45 | resolve: { 46 | alias: { 47 | "@/assets": assetsDir, 48 | "@/components": componentsDir, 49 | "@/hooks": hooksDir, 50 | "@/pages": pagesDir, 51 | "@/src": root, 52 | "@/utils": utilsDir 53 | } 54 | } 55 | }); 56 | } 57 | }, 58 | name: "build-content" 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/utils/plugins/copy-build.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | 3 | import { rmSync } from "fs"; 4 | import { resolve } from "path"; 5 | 6 | import { outputFolderName } from "../constants"; 7 | import terminalColorLog from "../log"; 8 | import { browsers, copyDirectorySync } from "./utils"; 9 | 10 | const outDir = resolve(__dirname, "..", "..", "..", outputFolderName); 11 | export default function copyBuild(): PluginOption { 12 | return { 13 | closeBundle() { 14 | for (const browser of browsers) { 15 | copyDirectorySync(resolve(outDir, "temp"), resolve(outDir, browser.name)); 16 | terminalColorLog(`Build copy complete: ${resolve(outDir, browser.name)}`, "success"); 17 | } 18 | rmSync(resolve(outDir, "temp"), { recursive: true }); 19 | }, 20 | name: "copy-build" 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/plugins/copy-public.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | 3 | import { existsSync, mkdirSync } from "fs"; 4 | import { resolve } from "path"; 5 | 6 | import { outputFolderName } from "../constants"; 7 | import terminalColorLog from "../log"; 8 | import { browsers, copyDirectorySync } from "./utils"; 9 | 10 | const outDir = resolve(__dirname, "..", "..", "..", outputFolderName); 11 | 12 | const publicDir = resolve(__dirname, "..", "..", "..", "public"); 13 | export default function copyPublic(): PluginOption { 14 | return { 15 | closeBundle() { 16 | for (const browser of browsers) { 17 | if (!existsSync(resolve(outDir, browser.name))) { 18 | mkdirSync(resolve(outDir, browser.name), { recursive: true }); 19 | } 20 | copyDirectorySync(publicDir, resolve(outDir, browser.name)); 21 | terminalColorLog(`Public directory copy complete: ${resolve(outDir, browser.name)}`, "success"); 22 | } 23 | }, 24 | name: "copy-public" 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/plugins/make-manifest.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | 3 | import { existsSync, mkdirSync, writeFileSync } from "fs"; 4 | import { resolve } from "path"; 5 | 6 | import { manifestV2, manifestV3 } from "../../manifest"; 7 | import { outputFolderName } from "../constants"; 8 | import terminalColorLog from "../log"; 9 | import { browsers } from "./utils"; 10 | 11 | const outDir = resolve(__dirname, "..", "..", "..", outputFolderName); 12 | function writeManifest(version: 2 | 3, browserName: string) { 13 | const manifestPath = resolve(outDir, browserName, `manifest.json`); 14 | 15 | writeFileSync(manifestPath, JSON.stringify(version === 2 ? manifestV2 : manifestV3, null, 2)); 16 | 17 | terminalColorLog(`Manifest file copy complete: ${manifestPath}`, "success"); 18 | } 19 | export default function makeManifest(): PluginOption { 20 | return { 21 | closeBundle() { 22 | for (const browser of browsers) { 23 | if (!existsSync(resolve(outDir, browser.name))) { 24 | mkdirSync(resolve(outDir, browser.name), { recursive: true }); 25 | } 26 | writeManifest(browser.type === "chrome" ? 3 : 2, browser.name); 27 | } 28 | }, 29 | name: "make-manifest" 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/plugins/make-release-zips.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | 3 | import archiver from "archiver"; 4 | import { createWriteStream, existsSync, mkdirSync } from "fs"; 5 | import { resolve } from "path"; 6 | 7 | import pkg from "../../../package.json"; 8 | import { outputFolderName } from "../constants"; 9 | import terminalColorLog from "../log"; 10 | import { browsers } from "./utils"; 11 | 12 | const outDir = resolve(__dirname, "..", "..", "..", outputFolderName); 13 | const releaseDir = resolve(__dirname, "..", "..", "..", "releases"); 14 | 15 | export default function makeReleaseZips(): PluginOption { 16 | return { 17 | closeBundle() { 18 | for (const browser of browsers) { 19 | if (!existsSync(resolve(releaseDir, browser.name))) { 20 | mkdirSync(resolve(releaseDir, browser.name), { recursive: true }); 21 | } 22 | const releaseZipPath = resolve(releaseDir, browser.name, `${pkg.name}-v${pkg.version}-${browser.name}.zip`); 23 | const releaseZipStream = createWriteStream(releaseZipPath); 24 | const releaseZip = archiver("zip", { 25 | zlib: { 26 | level: 9 27 | } 28 | }); 29 | releaseZip.pipe(releaseZipStream); 30 | releaseZip.directory(resolve(outDir, browser.name), false); 31 | 32 | releaseZipStream.on("close", () => { 33 | terminalColorLog(`Release zip file created: ${releaseZipPath}`, "success"); 34 | }); 35 | void releaseZip.finalize(); 36 | } 37 | }, 38 | name: "make-release-zips" 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/plugins/replace-dev-mode-const.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from "vite"; 2 | 3 | import terminalColorLog from "../log"; 4 | export default function replaceDevModeConst(): PluginOption { 5 | return { 6 | name: "replace-dev-mode-const", 7 | transform(code, id) { 8 | if (id.includes("constants.ts")) { 9 | terminalColorLog(`Replacing DEV_MODE constant`); 10 | const replacedConstantCode = code.replace( 11 | /export const DEV_MODE = process.env.__DEV__ === "true";/g, 12 | `export const DEV_MODE = ${process.env.__DEV__ === "true"};` 13 | ); 14 | return { 15 | code: replacedConstantCode, 16 | map: null 17 | }; 18 | } 19 | } 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/plugins/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AvailableLocales } from "@/src/i18n/constants"; 2 | 3 | import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "fs"; 4 | import { GetInstalledBrowsers } from "get-installed-browsers"; 5 | import { join, resolve } from "path"; 6 | 7 | import { outputFolderName } from "../../../src/utils/constants"; 8 | export type LocaleValue = { [key: string]: LocaleValue } | string; 9 | export type LocaleFile = { 10 | [key: string]: LocaleValue; 11 | }; 12 | export const rootDir = resolve(__dirname, "../../../"); 13 | export const srcDir = resolve(rootDir, "src"); 14 | export const outDir = resolve(rootDir, outputFolderName); 15 | export const publicDir = resolve(rootDir, "public"); 16 | export const pagesDir = resolve(srcDir, "pages"); 17 | export const assetsDir = resolve(srcDir, "assets"); 18 | export const componentsDir = resolve(srcDir, "components"); 19 | export const utilsDir = resolve(srcDir, "utils"); 20 | export const hooksDir = resolve(srcDir, "hooks"); 21 | export const i18nDir = resolve(srcDir, "i18n"); 22 | 23 | export const browsers = GetInstalledBrowsers(); 24 | export function copyDirectorySync(sourceDir: string, targetDir: string) { 25 | // Create the target directory if it doesn't exist 26 | if (!existsSync(targetDir)) { 27 | mkdirSync(targetDir, { recursive: true }); 28 | } 29 | 30 | // Get a list of all files and subdirectories in the source directory 31 | const items = readdirSync(sourceDir); 32 | 33 | for (const item of items) { 34 | const sourcePath = join(sourceDir, item); 35 | const targetPath = join(targetDir, item); 36 | 37 | // Check if the current item is a directory 38 | if (statSync(sourcePath).isDirectory()) { 39 | // Recursively copy the subdirectory 40 | copyDirectorySync(sourcePath, targetPath); 41 | } else { 42 | // Copy the file 43 | copyFileSync(sourcePath, targetPath); 44 | } 45 | } 46 | } 47 | 48 | export const emptyOutputFolder = () => { 49 | if (!existsSync(outDir)) return; 50 | const files = readdirSync(outDir); 51 | for (const file of files) { 52 | if (file.endsWith(".zip")) continue; 53 | const filePath = resolve(outDir, file); 54 | const fileStat = statSync(filePath); 55 | if (fileStat.isDirectory()) { 56 | rmSync(filePath, { force: true, recursive: true }); 57 | } else { 58 | rmSync(filePath, { force: true }); 59 | } 60 | } 61 | }; 62 | export function flattenLocaleValues(localeFile: LocaleFile, parentKey = ""): { keys: string[]; values: string[] } { 63 | let values: string[] = []; 64 | let keys: string[] = []; 65 | for (const key in localeFile) { 66 | if (["langCode", "langName"].includes(key)) continue; 67 | const { [key]: value } = localeFile; 68 | 69 | const currentKey = parentKey ? `${parentKey}.${key}` : key; 70 | 71 | if (typeof value === "object") { 72 | const { keys: nestedKeys, values: nestedValues } = flattenLocaleValues(value, currentKey); 73 | values = values.concat(nestedValues); 74 | keys = keys.concat(nestedKeys); 75 | } else { 76 | values.push(value); 77 | keys.push(currentKey); 78 | } 79 | } 80 | 81 | return { keys, values }; 82 | } 83 | export function getLocaleFile(locale: AvailableLocales): LocaleFile { 84 | const localeFile = readFileSync(`${publicDir}/locales/${locale}.json`, "utf-8"); 85 | return JSON.parse(localeFile) as LocaleFile; 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/updateAvailableLocales.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, readdirSync, writeFileSync } from "fs"; 2 | 3 | import { i18nDir, publicDir } from "./plugins/utils"; 4 | function updateAvailableLocalesArray(code: string, updatedArray: string[]) { 5 | const match = code.match(/export\s+const\s+availableLocales\s*=\s*\[([^\]]*)\]\s*as\s*const\s*;/); 6 | if (match) { 7 | const [, oldArrayPart] = match; 8 | const newArrayPart = JSON.stringify(updatedArray, null, 2).replace(/^\[|\]$/g, ""); 9 | return code.replace(oldArrayPart, newArrayPart); 10 | } else { 11 | return null; 12 | } 13 | } 14 | 15 | export default function updateAvailableLocales() { 16 | const availableLocales = readdirSync(`${publicDir}/locales`) 17 | .filter((locale) => locale.endsWith(".json")) 18 | .map((locale) => locale.replace(".json", "")); 19 | const availableLocalesFile = readFileSync(`${i18nDir}/constants.ts`, "utf-8"); 20 | const updatedAvailableLocalesFile = updateAvailableLocalesArray(availableLocalesFile, availableLocales); 21 | if (updatedAvailableLocalesFile && updatedAvailableLocalesFile !== availableLocalesFile) { 22 | writeFileSync(`${i18nDir}/constants.ts`, updatedAvailableLocalesFile); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/updateLocalePercentages.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | import { z } from "zod"; 3 | import { generateErrorMessage } from "zod-error"; 4 | 5 | import type { CrowdinLanguageProgressResponse, TypeToZodSchema } from "../types"; 6 | 7 | import { type AvailableLocales } from "../i18n/constants"; 8 | import { i18nDir } from "./plugins/utils"; 9 | import { formatError } from "./utilities"; 10 | 11 | const crowdinLanguageProgressResponseSchema: TypeToZodSchema = z.object({ 12 | data: z.array( 13 | z.object({ 14 | data: z.object({ 15 | approvalProgress: z.number(), 16 | language: z.object({ 17 | androidCode: z.string(), 18 | dialectOf: z.string().nullable(), 19 | editorCode: z.string(), 20 | id: z.string(), 21 | locale: z.string(), 22 | name: z.string(), 23 | osxCode: z.string(), 24 | osxLocale: z.string(), 25 | pluralCategoryNames: z.array(z.string()), 26 | pluralExamples: z.array(z.string()), 27 | pluralRules: z.string(), 28 | textDirection: z.string(), 29 | threeLettersCode: z.string(), 30 | twoLettersCode: z.string() 31 | }), 32 | languageId: z.string(), 33 | phrases: z.object({ 34 | approved: z.number(), 35 | preTranslateAppliedTo: z.number(), 36 | total: z.number(), 37 | translated: z.number() 38 | }), 39 | translationProgress: z.number(), 40 | words: z.object({ 41 | approved: z.number(), 42 | preTranslateAppliedTo: z.number(), 43 | total: z.number(), 44 | translated: z.number() 45 | }) 46 | }) 47 | }) 48 | ), 49 | pagination: z.object({ 50 | limit: z.number(), 51 | offset: z.number() 52 | }) 53 | }); 54 | 55 | function updateLocalePercentageObject(code: string, updatedObject: Record) { 56 | const match = code.match(/export\s+const\s+localePercentages\s*:\s*Record\s*=\s*({[^}]+});/); 57 | if (match) { 58 | const [, oldObjectPart] = match; 59 | const newObjectPart = JSON.stringify(updatedObject, null, 2); 60 | return code.replace(oldObjectPart, newObjectPart); 61 | } else { 62 | return null; 63 | } 64 | } 65 | export default async function updateLocalePercentages() { 66 | const localePercentages = await getLocalePercentagesFromCrowdin(); 67 | if (!localePercentages) return; 68 | const localePercentagesFile = readFileSync(`${i18nDir}/constants.ts`, "utf-8"); 69 | const updatedLocalePercentagesFile = updateLocalePercentageObject(localePercentagesFile, Object.fromEntries(localePercentages)); 70 | if (updatedLocalePercentagesFile && updatedLocalePercentagesFile !== localePercentagesFile) { 71 | writeFileSync(`${i18nDir}/constants.ts`, updatedLocalePercentagesFile); 72 | } 73 | } 74 | async function getLocalePercentagesFromCrowdin() { 75 | if (!process.env.CROWDIN_TOKEN) return; 76 | try { 77 | const response = await fetch("https://crowdin.com/api/v2/projects/627048/languages/progress", { 78 | headers: { Authorization: "Bearer " + process.env.CROWDIN_TOKEN }, 79 | method: "get" 80 | }); 81 | const data = await response.text(); 82 | const json = JSON.parse(data); 83 | const crowdinLanguageProgressResponseParsed = crowdinLanguageProgressResponseSchema.safeParse(json); 84 | if (crowdinLanguageProgressResponseParsed.success) { 85 | const { data } = json as CrowdinLanguageProgressResponse; 86 | const localePercentages = new Map([ 87 | ["en-US", 100], 88 | ...data.map( 89 | ({ 90 | data: { 91 | approvalProgress, 92 | language: { locale } 93 | } 94 | }) => [locale as AvailableLocales, approvalProgress] as [AvailableLocales, number] 95 | ) 96 | ]); 97 | return localePercentages; 98 | } else if (!crowdinLanguageProgressResponseParsed.success) { 99 | const { error } = crowdinLanguageProgressResponseParsed; 100 | throw new Error(`Failed to get locale percentages from Crowdin\n\n${generateErrorMessage(error.errors)}`); 101 | } 102 | } catch (error) { 103 | throw new Error(formatError(error)); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/utils/updateStoredSettings.ts: -------------------------------------------------------------------------------- 1 | import type { configuration, configurationKeys } from "@/src/types"; 2 | 3 | import { defaultConfiguration } from "@/src/utils/constants"; 4 | import { parseStoredValue } from "@/src/utils/utilities"; 5 | 6 | const changedKeys = Object.keys({ 7 | osd_display_type: "" 8 | } satisfies Partial>); 9 | 10 | export async function updateStoredSettings() { 11 | try { 12 | const settings = await getStoredSettings(); 13 | const removedKeys = Object.keys(settings).filter((key) => !Object.keys(defaultConfiguration).includes(key)); 14 | for (const changedKey of changedKeys) { 15 | switch (changedKey) { 16 | case "osd_display_type": { 17 | if ((settings.osd_display_type as unknown as string) === "round") { 18 | settings.osd_display_type = "circle"; 19 | } 20 | break; 21 | } 22 | } 23 | } 24 | for (const key of removedKeys) { 25 | delete settings[key]; 26 | } 27 | await setModifiedSettings(settings); 28 | } catch (error) { 29 | console.error("Failed to update stored settings:", error); 30 | } 31 | } 32 | 33 | async function setModifiedSettings(settings: Partial) { 34 | const updates: Record = {}; 35 | for (const [key, value] of Object.entries(settings)) { 36 | updates[key] = typeof value !== "string" ? JSON.stringify(value) : value; 37 | } 38 | await chrome.storage.local.set(updates); 39 | } 40 | 41 | async function getStoredSettings(): Promise { 42 | return new Promise((resolve, reject) => { 43 | chrome.storage.local.get((settings) => { 44 | try { 45 | const storedSettings: Partial = ( 46 | Object.keys(settings) 47 | .filter((key) => typeof key === "string") 48 | .filter((key) => Object.keys(defaultConfiguration).includes(key as unknown as string)) as configurationKeys[] 49 | ).reduce((acc, key) => Object.assign(acc, { [key]: parseStoredValue(settings[key] as string) }), {}); 50 | const castedSettings = storedSettings as configuration; 51 | resolve(castedSettings); 52 | } catch (error) { 53 | reject(error); 54 | } 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config } from "tailwindcss"; 2 | import multi from "tailwindcss-multi"; 3 | export default { 4 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 5 | plugins: [multi.handler], 6 | prefix: "", 7 | theme: { 8 | extend: { 9 | animation: { 10 | "spin-slow": "spin 20s linear infinite" 11 | } 12 | } 13 | } 14 | } satisfies Config; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "types": ["vite/client", "node", "chrome"], 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "moduleDetection": "force", 7 | "verbatimModuleSyntax": true, 8 | "allowJs": false, 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/src/*": ["src/*"], 23 | "@/assets/*": ["src/assets/*"], 24 | "@/pages/*": ["src/pages/*"], 25 | "@/components/*": ["src/components/*"], 26 | "@/utils": ["src/utils/index"], 27 | "@/utils/*": ["src/utils/*"], 28 | "@/hooks": ["src/hooks/index"], 29 | "@/hooks/*": ["src/hooks/*"] 30 | } 31 | }, 32 | "include": [ 33 | "src", 34 | "vite.config.ts", 35 | "public/locales", 36 | "playwright.config.ts", 37 | "tailwind.config.ts", 38 | "postcss.config.cjs", 39 | "prettier.config.cjs", 40 | "src/features/__tests__", 41 | "eslint.config.mjs" 42 | ], 43 | "exclude": ["node_modules", "dist", "releases"] 44 | } 45 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react-swc"; 2 | import { config } from "dotenv"; 3 | import { resolve } from "path"; 4 | import { defineConfig } from "vite"; 5 | 6 | import checkLocalesForMissingKeys from "./src/utils/checkLocalesForMissingKeys"; 7 | import { ENABLE_SOURCE_MAP } from "./src/utils/constants"; 8 | import buildContentScript from "./src/utils/plugins/build-content-script"; 9 | import copyBuild from "./src/utils/plugins/copy-build"; 10 | import copyPublic from "./src/utils/plugins/copy-public"; 11 | import makeManifest from "./src/utils/plugins/make-manifest"; 12 | import makeReleaseZips from "./src/utils/plugins/make-release-zips"; 13 | import replaceDevModeConst from "./src/utils/plugins/replace-dev-mode-const"; 14 | import { assetsDir, componentsDir, emptyOutputFolder, hooksDir, outDir, pagesDir, srcDir, utilsDir } from "./src/utils/plugins/utils"; 15 | import updateAvailableLocales from "./src/utils/updateAvailableLocales"; 16 | import updateLocalePercentages from "./src/utils/updateLocalePercentages"; 17 | config(); 18 | 19 | export default function build() { 20 | emptyOutputFolder(); 21 | void updateAvailableLocales(); 22 | if (process.env.__DEV__ !== "true") { 23 | void checkLocalesForMissingKeys(); 24 | void updateLocalePercentages(); 25 | } 26 | return defineConfig({ 27 | build: { 28 | emptyOutDir: false, 29 | modulePreload: false, 30 | outDir: resolve(outDir, "temp"), 31 | rollupOptions: { 32 | input: { 33 | background: resolve(pagesDir, "background", "index.html"), 34 | options: resolve(pagesDir, "options", "index.html"), 35 | popup: resolve(pagesDir, "popup", "index.html") 36 | }, 37 | output: { 38 | entryFileNames: (chunk) => { 39 | return `src/pages/${chunk.name}/index.js`; 40 | } 41 | } 42 | }, 43 | sourcemap: ENABLE_SOURCE_MAP 44 | }, 45 | plugins: [replaceDevModeConst(), react(), makeManifest(), buildContentScript(), copyPublic(), copyBuild(), makeReleaseZips()], 46 | resolve: { 47 | alias: { 48 | "@/assets": assetsDir, 49 | "@/components": componentsDir, 50 | "@/hooks": hooksDir, 51 | "@/pages": pagesDir, 52 | "@/src": srcDir, 53 | "@/utils": utilsDir 54 | } 55 | } 56 | }); 57 | } 58 | --------------------------------------------------------------------------------