├── .editorconfig ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── release.yml │ ├── take-action.yml │ ├── tests.yml │ ├── update-oss-attribution.yml │ └── updateInvidous.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── LICENSE-APPSTORE.txt ├── LICENSE-HISTORY.txt ├── README.md ├── ci ├── invidiousCI.ts ├── invidiouslist.json └── prettify.ts ├── config.json.example ├── jest.config.js ├── manifest ├── beta-manifest-extra.json ├── chrome-manifest-extra.json ├── firefox-beta-manifest-extra.json ├── firefox-manifest-extra.json ├── manifest-v2-extra.json ├── manifest.json └── safari-manifest-extra.json ├── oss-attribution └── licenseInfos.json ├── package-lock.json ├── package.json ├── public ├── casual.css ├── content.css ├── help.css ├── help.html ├── icons │ ├── add.svg │ ├── close.png │ ├── heart.svg │ ├── help.svg │ ├── logo-1024.png │ ├── logo-128.png │ ├── logo-16.png │ ├── logo-256.png │ ├── logo-2r.svg │ ├── logo-32.png │ ├── logo-512.png │ ├── logo-64.png │ ├── logo-casual.svg │ ├── logo.svg │ ├── newprofilepic.jpg │ ├── not_visible.svg │ ├── pause.svg │ ├── refresh.svg │ ├── remove.svg │ ├── settings.svg │ ├── thumbs_down_locked.svg │ └── visible.svg ├── options │ ├── options.css │ └── options.html ├── oss-attribution │ ├── .gitkeep │ └── attribution.txt ├── payment.html ├── popup.css ├── popup.html └── shared.css ├── src ├── background.ts ├── config │ ├── channelOverrides.ts │ ├── config.ts │ └── stats.ts ├── content.ts ├── dataFetching.ts ├── document.ts ├── documentScriptInjector.ts ├── help │ ├── HelpComponent.tsx │ ├── PaymentComponent.tsx │ ├── help.tsx │ └── payment.tsx ├── license │ ├── LicenseComponent.tsx │ └── license.ts ├── options.ts ├── options │ ├── CasualChoice.tsx │ ├── CasualChoiceComponent.tsx │ ├── ChannelOverrides.tsx │ ├── ChannelOverridesComponent.tsx │ ├── KeybindComponent.tsx │ └── KeybindDialogComponent.tsx ├── popup │ ├── FormattedTextComponent.tsx │ ├── FormattingOptionsComponent.tsx │ ├── PopupComponent.tsx │ ├── SelectOptionComponent.tsx │ ├── ToggleOptionComponent.tsx │ ├── YourWorkComponent.tsx │ └── popup.tsx ├── submission │ ├── BrandingPreviewComponent.tsx │ ├── CasualVoteComponent.tsx │ ├── CasualVoteOnboardingComponent.tsx │ ├── SubmissionChecklist.tsx │ ├── SubmissionComponent.tsx │ ├── ThumbnailComponent.tsx │ ├── ThumbnailDrawerComponent.tsx │ ├── ThumbnailSelectionComponent.tsx │ ├── TitleComponent.tsx │ ├── TitleDrawerComponent.tsx │ ├── autoWarning.ts │ ├── casualVote.const.ts │ ├── casualVoteButton.tsx │ ├── submitButton.tsx │ └── titleButton.tsx ├── svgIcons │ ├── addIcon.tsx │ ├── checkIcon.tsx │ ├── clipboardIcon.tsx │ ├── cursorIcon.tsx │ ├── downvoteIcon.tsx │ ├── exclamationIcon.tsx │ ├── fontIcon.tsx │ ├── pencilIcon.tsx │ ├── personIcon.tsx │ ├── questionIcon.tsx │ ├── resetIcon.tsx │ └── upvoteIcon.tsx ├── thumbnails │ ├── thumbnailData.ts │ ├── thumbnailDataCache.ts │ └── thumbnailRenderer.ts ├── titles │ ├── pageTitleHandler.ts │ ├── titleAntiTranslateData.ts │ ├── titleData.ts │ ├── titleFormatter.ts │ ├── titleFormatterData.ts │ └── titleRenderer.ts ├── types │ └── messaging.ts ├── unactivatedWarning.ts ├── utils.ts ├── utils │ ├── configUtils.ts │ ├── cssInjector.ts │ ├── extensionCompatibility.ts │ ├── keybinds.ts │ ├── logger.ts │ ├── pageCleaner.ts │ ├── requests.ts │ ├── titleBar.ts │ └── tooltip.tsx ├── video.ts └── videoBranding │ ├── mediaSessionHandler.ts │ ├── notificationHandler.ts │ ├── onboarding.tsx │ ├── videoBranding.ts │ └── watchPageBrandingHandler.ts ├── test ├── selenium.test.ts └── titleFormatter.test.ts ├── tsconfig-production.json ├── tsconfig.json └── webpack ├── webpack.common.js ├── webpack.dev.js ├── webpack.manifest.js └── webpack.prod.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | [*.{js,json,ts,tsx}] 12 | charset = utf-8 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [package.json] 17 | indent_style = space 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:react/recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true 17 | }, 18 | "ecmaVersion": 12, 19 | "sourceType": "module" 20 | }, 21 | "plugins": ["react", "@typescript-eslint"], 22 | "rules": { 23 | "@typescript-eslint/no-unused-vars": "error", 24 | "no-self-assign": "off", 25 | "@typescript-eslint/no-empty-interface": "off", 26 | "react/prop-types": [2, { "ignore": ["children"] }], 27 | "@typescript-eslint/member-delimiter-style": "warn", 28 | "require-await": "warn", 29 | "@typescript-eslint/no-non-null-assertion": "off", 30 | "@typescript-eslint/no-this-alias": "off", 31 | "@typescript-eslint/ban-ts-comment": "off" 32 | }, 33 | "settings": { 34 | "react": { 35 | "version": "detect" 36 | } 37 | }, 38 | "overrides": [ 39 | { 40 | "files": ["src/**/*.ts"], 41 | 42 | "parserOptions": { 43 | "project": ["./tsconfig.json"] 44 | }, 45 | 46 | "rules": { 47 | "@typescript-eslint/no-misused-promises": "warn", 48 | "@typescript-eslint/no-floating-promises" : "warn" 49 | } 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ajayyy-org 2 | patreon: ajayyy 3 | custom: [dearrow.ajay.app/donate, theajayyy.itch.io/dearrow] 4 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | - [ ] I agree to license my contribution under GPL-3.0 and agree to allow distribution on app stores as outlined in [LICENSE-APPSTORE](https://github.com/ajayyy/DeArrow/blob/master/LICENSE-APPSTORE.txt) 2 | 3 | To test this pull request, follow the [instructions in the wiki](https://github.com/ajayyy/SponsorBlock/wiki/Testing-a-Pull-Request). 4 | 5 | *** 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | build: 8 | name: Create artifacts 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | # Initialization 13 | - uses: actions/checkout@v4 14 | with: 15 | submodules: recursive 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: '18' 19 | - run: npm ci 20 | - name: Copy configuration 21 | run: cp config.json.example config.json 22 | 23 | # Run linter 24 | - name: Lint 25 | run: npm run lint 26 | 27 | # Create Chrome artifacts 28 | - name: Create Chrome artifacts 29 | run: npm run build:chrome 30 | - uses: actions/upload-artifact@v4 31 | with: 32 | name: ChromeExtension 33 | path: dist 34 | - run: mkdir ./builds 35 | - uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28 36 | with: 37 | args: zip -qq -r ./builds/ChromeExtension.zip ./dist 38 | 39 | # Create Firefox artifacts 40 | - name: Create Firefox artifacts 41 | run: npm run build:firefox 42 | - uses: actions/upload-artifact@v4 43 | with: 44 | name: FirefoxExtension 45 | path: dist 46 | - uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28 47 | with: 48 | args: zip -qq -r ./builds/FirefoxExtension.zip ./dist 49 | 50 | # Create Beta artifacts (Builds with the name changed to beta) 51 | - name: Create Chrome Beta artifacts 52 | run: npm run build:chrome -- --env stream=beta 53 | - uses: actions/upload-artifact@v4 54 | with: 55 | name: ChromeExtensionBeta 56 | path: dist 57 | - uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28 58 | with: 59 | args: zip -qq -r ./builds/ChromeExtensionBeta.zip ./dist 60 | 61 | - name: Create Firefox Beta artifacts 62 | run: npm run build:firefox -- --env stream=beta 63 | - uses: actions/upload-artifact@v4 64 | with: 65 | name: FirefoxExtensionBeta 66 | path: dist 67 | - uses: montudor/action-zip@0852c26906e00f8a315c704958823928d8018b28 68 | with: 69 | args: zip -qq -r ./builds/FirefoxExtensionBeta.zip ./dist 70 | 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Upload Release Build 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | 9 | build: 10 | name: Upload Release 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | # Initialization 15 | - uses: actions/checkout@v4 16 | with: 17 | submodules: recursive 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: '18' 21 | - name: Copy configuration 22 | run: cp config.json.example config.json 23 | 24 | # Create source artifact with submodule 25 | - name: Create directory 26 | run: cd ..; mkdir ./builds 27 | - name: Zip Source code 28 | run: zip -r ../builds/SourceCodeUseThisOne.zip * 29 | - name: Upload Source to release 30 | uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467 31 | with: 32 | args: ../builds/SourceCodeUseThisOne.zip 33 | name: SourceCodeUseThisOne.zip 34 | path: ../builds/SourceCodeUseThisOne.zip 35 | repo-token: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - run: npm ci 38 | 39 | # Create Chrome artifacts 40 | - name: Create Chrome artifacts 41 | run: npm run build:chrome 42 | - run: mkdir ./builds 43 | - name: Zip Artifacts 44 | run: cd ./dist ; zip -r ../builds/ChromeExtension.zip * 45 | - name: Upload ChromeExtension to release 46 | uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467 47 | with: 48 | args: builds/ChromeExtension.zip 49 | name: ChromeExtension.zip 50 | path: ./builds/ChromeExtension.zip 51 | repo-token: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | # Create Firefox artifacts 54 | - name: Create Firefox artifacts 55 | run: npm run build:firefox 56 | - name: Zip Artifacts 57 | run: cd ./dist ; zip -r ../builds/FirefoxExtension.zip * 58 | - name: Upload FirefoxExtension to release 59 | uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467 60 | with: 61 | args: builds/FirefoxExtension.zip 62 | name: FirefoxExtension.zip 63 | path: ./builds/FirefoxExtension.zip 64 | repo-token: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | # Create Beta artifacts (Builds with the name changed to beta) 67 | - name: Create Chrome Beta artifacts 68 | run: npm run build:chrome -- --env stream=beta 69 | - name: Zip Artifacts 70 | run: cd ./dist ; zip -r ../builds/ChromeExtensionBeta.zip * 71 | - name: Upload ChromeExtensionBeta to release 72 | uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467 73 | with: 74 | args: builds/ChromeExtensionBeta.zip 75 | name: ChromeExtensionBeta.zip 76 | path: ./builds/ChromeExtensionBeta.zip 77 | repo-token: ${{ secrets.GITHUB_TOKEN }} 78 | 79 | 80 | # Create Safari artifacts 81 | - name: Create Safari artifacts 82 | run: npm run build:safari 83 | - name: Zip Artifacts 84 | run: cd ./dist ; zip -r ../builds/SafariExtension.zip * 85 | - name: Upload SafariExtension to release 86 | uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467 87 | with: 88 | args: builds/SafariExtension.zip 89 | name: SafariExtension.zip 90 | path: ./builds/SafariExtension.zip 91 | repo-token: ${{ secrets.GITHUB_TOKEN }} 92 | 93 | # Create Edge artifacts 94 | - name: Clear dist for Edge 95 | run: rm -rf ./dist 96 | - name: Create Edge artifacts 97 | run: npm run build:edge 98 | - name: Zip Artifacts 99 | run: cd ./dist ; zip -r ../builds/EdgeExtension.zip * 100 | - name: Upload EdgeExtension to release 101 | uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467 102 | with: 103 | args: builds/EdgeExtension.zip 104 | name: EdgeExtension.zip 105 | path: ./builds/EdgeExtension.zip 106 | repo-token: ${{ secrets.GITHUB_TOKEN }} 107 | 108 | # Firefox Beta 109 | - name: Create Firefox Beta artifacts 110 | run: npm run build:firefox -- --env stream=beta 111 | - uses: actions/upload-artifact@v4 112 | with: 113 | name: FirefoxExtensionBeta 114 | path: dist 115 | - name: Zip Artifacts 116 | run: cd ./dist ; zip -r ../builds/FirefoxExtensionBeta.zip * 117 | 118 | # Create Firefox Signed Beta version 119 | - name: Create Firefox Signed Beta artifacts 120 | run: npm run web-sign 121 | env: 122 | WEB_EXT_API_KEY: ${{ secrets.WEB_EXT_API_KEY }} 123 | WEB_EXT_API_SECRET: ${{ secrets.WEB_EXT_API_SECRET }} 124 | - name: Install rename 125 | run: sudo apt-get install rename 126 | - name: Rename signed file 127 | run: cd ./web-ext-artifacts ; rename 's/.*/FirefoxSignedInstaller.xpi/' * 128 | - uses: actions/upload-artifact@v4 129 | with: 130 | name: FirefoxExtensionSigned.xpi 131 | path: ./web-ext-artifacts/FirefoxSignedInstaller.xpi 132 | 133 | - name: Upload FirefoxSignedInstaller.xpi to release 134 | uses: Shopify/upload-to-release@07611424e04f1475ddf550e1c0dd650b867d5467 135 | with: 136 | args: web-ext-artifacts/FirefoxSignedInstaller.xpi 137 | name: FirefoxSignedInstaller.xpi 138 | path: ./web-ext-artifacts/FirefoxSignedInstaller.xpi 139 | repo-token: ${{ secrets.GITHUB_TOKEN }} 140 | 141 | -------------------------------------------------------------------------------- /.github/workflows/take-action.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/take.yml 2 | name: Assign issue to contributor 3 | on: 4 | issue_comment: 5 | 6 | jobs: 7 | assign: 8 | name: Take an issue 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: take the issue 12 | uses: bdougie/take-action@28b86cd8d25593f037406ecbf96082db2836e928 13 | env: 14 | GITHUB_TOKEN: ${{ github.token }} 15 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Run tests 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | # Initialization 12 | - uses: actions/checkout@v4 13 | with: 14 | submodules: recursive 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: '18' 18 | - run: npm ci 19 | 20 | - uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce 21 | with: 22 | chrome-version: 135 23 | install-dependencies: true 24 | install-chromedriver: true 25 | 26 | - name: Copy configuration 27 | run: "cp config.json.example config.json; sed -i 's/^}/,\"freeAccess\": true}/' config.json" 28 | 29 | - name: Set up WireGuard Connection 30 | uses: niklaskeerl/easy-wireguard-action@50341d5f4b8245ff3a90e278aca67b2d283c78d0 31 | with: 32 | WG_CONFIG_FILE: ${{ secrets.WG_CONFIG_FILE }} 33 | 34 | - name: Run tests 35 | run: npm run test 36 | 37 | - name: Upload results on fail 38 | if: ${{ failure() }} 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: Test Results 42 | path: ./test-results -------------------------------------------------------------------------------- /.github/workflows/update-oss-attribution.yml: -------------------------------------------------------------------------------- 1 | name: update oss attributions 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - 'package.json' 8 | - 'package-lock.json' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update-oss: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | submodules: recursive 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: '18' 21 | - name: Install and generate attribution 22 | run: | 23 | npm ci 24 | npm i -g oss-attribution-generator 25 | generate-attribution 26 | mv ./oss-attribution/attribution.txt ./public/oss-attribution/attribution.txt 27 | - name: Prettify attributions 28 | run: | 29 | cd ci && npx ts-node prettify.ts 30 | 31 | - name: Create pull request to update list 32 | uses: peter-evans/create-pull-request@v7 33 | # v4.2.3 34 | with: 35 | commit-message: Update OSS Attribution 36 | author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 37 | branch: ci/oss_attribution 38 | title: Update OSS Attribution 39 | body: Automated OSS Attribution update 40 | -------------------------------------------------------------------------------- /.github/workflows/updateInvidous.yml: -------------------------------------------------------------------------------- 1 | name: update invidious 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 0 1 * *' # check every month 6 | 7 | jobs: 8 | check-list: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | submodules: recursive 14 | - name: Download instance list 15 | run: | 16 | wget https://api.invidious.io/instances.json -O ci/data.json 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: "Run CI" 20 | run: npm run ci:invidious 21 | 22 | - name: Create pull request to update list 23 | uses: peter-evans/create-pull-request@v7 24 | # v4.2.3 25 | with: 26 | commit-message: Update Invidious List 27 | author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> 28 | branch: ci/update_invidious_list 29 | title: Update Invidious List 30 | body: Automated Invidious list update -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | ignored 3 | .idea/ 4 | node_modules 5 | web-ext-artifacts 6 | .vscode/ 7 | dist/ 8 | tmp/ 9 | .DS_Store 10 | ci/data.json 11 | test-results -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "public/_locales"] 2 | path = public/_locales 3 | url = https://github.com/ajayyy/ExtensionTranslations 4 | [submodule "maze-utils"] 5 | path = maze-utils 6 | url = https://github.com/ajayyy/maze-utils 7 | -------------------------------------------------------------------------------- /LICENSE-APPSTORE.txt: -------------------------------------------------------------------------------- 1 | The developers are aware that the terms of service that 2 | apply to apps distributed via Apple's App Store services and similar app stores may conflict 3 | with rights granted under the DeArrow license, the GNU General 4 | Public License, version 3. The copyright holders of the DeArrow project 5 | do not wish this conflict to prevent the otherwise-compliant distribution 6 | of derived apps via the App Store and similar app stores. 7 | Therefore, we have committed not to pursue any license 8 | violation that results solely from the conflict between the GNU GPLv3 9 | and the Apple App Store terms of service or similar app stores. In 10 | other words, as long as you comply with the GPL in all other respects, 11 | including its requirements to provide users with source code and the 12 | text of the license, we will not object to your distribution of the 13 | DeArrow project through the App Store. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Logo 3 |

4 | 5 |

DeArrow

6 | 7 |

8 | Download: 9 | Chrome/Chromium | 10 | Firefox | 11 | Safari for MacOS and iOS | 12 | Android | 13 | Buy | 14 | Website | 15 | Stats 16 |

17 | 18 | DeArrow is a browser extension for crowdsourcing better titles and thumbnails on YouTube. 19 | 20 | The goal of DeArrow is to make titles accurate and reduce sensationalism. 21 | 22 | Titles can be any arbitrary text. Thumbnails are screenshots from specific timestamps in the video. These are user submitted and voted on. 23 | 24 | By default, if there are no submissions, it will format the original title to the user-specified format, and set a screenshot from a random timestamp as the thumbnail. This can be configured in the options to disable formatting, or show the original thumbnail by default. 25 | 26 | If the original thumbnail is actually good, you can still vote for it in the submission menu, and then it will act like a submission. 27 | 28 | The extension is currently in beta, and there are some issues to work out, but it should be fully usable. 29 | 30 | ![](https://cdn.fosstodon.org/media_attachments/files/110/520/916/244/905/970/original/9908f444b4e78a31.png) 31 | ![](https://cdn.fosstodon.org/media_attachments/files/110/520/917/557/536/945/original/b65eadd7ea18e073.png) 32 | 33 | ### How it works 34 | 35 | The browser extension first fetches data from the [backend](https://github.com/ajayyy/SponsorBlockServer) about submitted titles and thumbnails. If one is found, it replaces the branding locally. 36 | 37 | All thumbnails are just timestamps in a video, so they need to be generated. There are two options to generate them. One is to use the [thumbnail generation service](https://github.com/ajayyy/DeArrowThumbnailCache), and another is to generate it locally. It tries both and uses the fastest one. The thumbnail generation service will cache thumbnails for future requests, making it return instantly for the next user. Local thumbnail generation is done by taking a screenshot of an HTML video element using and drawing that to a canvas. 38 | 39 | If no thumbnails or titles are submitted, it switches to the configurable fallback options. Titles will be formatted according to user preference (title or sentence cases). Thumbnails, by default, are generated at a random timestamp that is not in a [SponsorBlock](https://github.com/ajayyy/SponsorBlock) segment. 40 | 41 | Lastly, it adds a "show original" button if anything was changed, allowing you to peek at the original title and thumbnail when you want. 42 | 43 | ### Related Repositories 44 | 45 | | Name | URL | 46 | | --- | --- | 47 | | Extension | https://github.com/ajayyy/DeArrow | 48 | | Shared Library With SponsorBlock | https://github.com/ajayyy/maze-utils | 49 | | Translations | https://github.com/ajayyy/ExtensionTranslations | 50 | | Safari | https://github.com/ajayyy/DeArrowSafari | 51 | | Backend | https://github.com/ajayyy/SponsorBlockServer| 52 | | Backend Kubernetes Manifests | https://github.com/ajayyy/SponsorBlockKubernetes | 53 | | Thumbnail Cache Backend | https://github.com/ajayyy/DeArrowThumbnailCache | 54 | | Thumbnail Cache Kubernetes Manifests | https://github.com/ajayyy/k8s-thumbnail-cache | 55 | 56 | 57 | ### Group Policy Options 58 | 59 | See the [Firefox Managed Storage](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/managed), [Chrome Admin Settings](https://www.chromium.org/administrators/configuring-policy-for-extensions/) and [Edge ExtensionSettings](https://learn.microsoft.com/en-us/deployedge/microsoft-edge-manage-extensions-ref-guide) pages for more info. This [uBlock Origin wiki page](https://github.com/gorhill/uBlock/wiki/Deploying-uBlock-Origin) might also help. 60 | 61 | It is possible to inject a license key using group policy/managed storage to be able to have the extension auto-activated even when you reset the settings on each install. 62 | 63 | ```json 64 | { 65 | "licenseKey": "your license key here" 66 | } 67 | ``` 68 | 69 | ### Building 70 | 71 | You must have [Node.js 16](https://nodejs.org/) and npm installed. 72 | 73 | 1. Clone with submodules 74 | 75 | ```bash 76 | git clone https://github.com/ajayyy/DeArrow --recurse-submodules=yes 77 | ``` 78 | 79 | Or if you already cloned it, pull submodules with 80 | 81 | ```bash 82 | git submodule update --init --recursive 83 | ``` 84 | 85 | 2. Copy the file `config.json.example` to `config.json` and adjust configuration as desired. 86 | 87 | - You will need to repeat this step in the future if you get build errors related to `CompileConfig`. 88 | 89 | 3. Run `npm ci` in the repository to install dependencies. 90 | 91 | 4. Run `npm run build:dev` (for Chrome) or `npm run build:dev:firefox` (for Firefox) to generate a development version of the extension with source maps. 92 | 93 | - You can also run `npm run build` (for Chrome) or `npm run build:firefox` (for Firefox) to generate a production build. 94 | 95 | 5. The built extension is now in `dist/`. You can load this folder directly in Chrome as an [unpacked extension](https://developer.chrome.com/docs/extensions/mv3/getstarted/#manifest), or convert it to a zip file to load it as a [temporary extension](https://developer.mozilla.org/en-US/docs/Tools/about:debugging#loading_a_temporary_extension) in Firefox. You may need to edit package.json and add the parameters directly there. 96 | 97 | ### Credit 98 | 99 | Built on the base of [SponsorBlock](https://github.com/ajayyy/SponsorBlock) licensed under GPL 3.0. 100 | 101 | Logo based on Twemoji licensed under CC-BY 4.0. 102 | -------------------------------------------------------------------------------- /ci/invidiousCI.ts: -------------------------------------------------------------------------------- 1 | /* 2 | This file is only ran by GitHub Actions in order to populate the Invidious instances list 3 | 4 | This file should not be shipped with the extension 5 | */ 6 | 7 | import { writeFile, existsSync } from 'fs'; 8 | import { join } from 'path'; 9 | 10 | // import file from https://api.invidious.io/instances.json 11 | if (!existsSync(join(__dirname, "data.json"))) { 12 | process.exit(1); 13 | } 14 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 15 | // @ts-ignore 16 | import * as data from "../ci/data.json"; 17 | 18 | type instanceMap = { 19 | name: string; 20 | url: string; 21 | dailyRatios: {ratio: string; label: string }[]; 22 | thirtyDayUptime: string; 23 | }[] 24 | 25 | // only https servers 26 | const mapped: instanceMap = data 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | .filter((i: any) => i[1]?.type === 'https') 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 30 | .map((instance: any) => { 31 | return { 32 | name: instance[0], 33 | url: instance[1].uri, 34 | dailyRatios: instance[1].monitor.dailyRatios, 35 | thirtyDayUptime: instance[1]?.monitor['30dRatio'].ratio, 36 | } 37 | }) 38 | 39 | // reliability and sanity checks 40 | const reliableCheck = mapped 41 | .filter((instance) => { 42 | // 30d uptime >= 90% 43 | const thirtyDayUptime = Number(instance.thirtyDayUptime) >= 90 44 | // available for at least 80/90 days 45 | const dailyRatioCheck = instance.dailyRatios.filter(status => status.label !== "black") 46 | return (thirtyDayUptime && dailyRatioCheck.length >= 80) 47 | }) 48 | // url includes name 49 | .filter(instance => instance.url.includes(instance.name)) 50 | 51 | // finally map to array 52 | const result: string[] = reliableCheck.map(instance => instance.name).sort() 53 | writeFile(join(__dirname, "./invidiouslist.json"), JSON.stringify(result), (err) => { 54 | if (err) return console.log(err); 55 | }) -------------------------------------------------------------------------------- /ci/invidiouslist.json: -------------------------------------------------------------------------------- 1 | ["inv.bp.projectsegfau.lt","inv.odyssey346.dev","inv.riverside.rocks","inv.vern.cc","invidious.baczek.me","invidious.epicsite.xyz","invidious.esmailelbob.xyz","invidious.flokinet.to","invidious.lidarshield.cloud","invidious.nerdvpn.de","invidious.privacydev.net","invidious.snopyta.org","invidious.tiekoetter.com","invidious.weblibre.org","iv.melmac.space","vid.puffyan.us","watch.thekitty.zone","y.com.sb","yewtu.be","yt.artemislena.eu","yt.funami.tech","yt.oelrichsgarcia.de"] -------------------------------------------------------------------------------- /ci/prettify.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from 'fs'; 2 | 3 | import * as license from "../oss-attribution/licenseInfos.json"; 4 | 5 | const result = JSON.stringify(license, null, 2); 6 | writeFile("../oss-attribution/licenseInfos.json", result, err => { if (err) return console.log(err) } ); -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "serverAddress": "https://sponsor.ajay.app", 3 | "thumbnailServerAddress": "https://dearrow-thumb.ajay.app", 4 | "testingServerAddress": "https://sponsor.ajay.app/test", 5 | "debug": false, 6 | "extensionImportList": { 7 | "chromium": [ 8 | "mnjggcdmjocbbbhaepdhchncahnbgone", 9 | "mbmgnelfcpoecdepckhlhegpcehmpmji" 10 | ], 11 | "firefox": [ 12 | "sponsorBlocker@ajay.app", 13 | "sponsorBlockerBETA@ajay.app" 14 | ], 15 | "safari": [ 16 | "app.ajay.sponsor.macos.extension" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "test" 4 | ], 5 | "transform": { 6 | "^.+\\.ts$": "ts-jest" 7 | }, 8 | "reporters": ["default", "github-actions"], 9 | "globals": { 10 | "LOAD_CLD": false 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /manifest/beta-manifest-extra.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BETA - DeArrow" 3 | } 4 | -------------------------------------------------------------------------------- /manifest/chrome-manifest-extra.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": [ 3 | "scripting" 4 | ], 5 | "content_scripts": [ 6 | { 7 | "world": "MAIN", 8 | "js": [ 9 | "./js/document.js" 10 | ], 11 | "matches": [ 12 | "https://*.youtube.com/*", 13 | "https://www.youtube-nocookie.com/embed/*" 14 | ], 15 | "all_frames": true, 16 | "run_at": "document_start" 17 | } 18 | ], 19 | "web_accessible_resources": [{ 20 | "resources": [ 21 | "icons/refresh.svg", 22 | "icons/logo.svg", 23 | "js/document.js", 24 | "js/options.js", 25 | "js/popup.js", 26 | "popup.css", 27 | "shared.css", 28 | "help.html", 29 | "help.css", 30 | "icons/logo-16.png", 31 | "icons/logo-32.png", 32 | "icons/logo-64.png", 33 | "icons/logo-128.png", 34 | "icons/logo-256.png", 35 | "icons/logo-2r.svg", 36 | "icons/logo-casual.svg", 37 | "icons/close.png", 38 | "icons/add.svg", 39 | "icons/remove.svg" 40 | ], 41 | "matches": [""] 42 | }], 43 | "host_permissions": [ 44 | "https://sponsor.ajay.app/*", 45 | "https://dearrow-thumb.ajay.app/*", 46 | "https://dearrow.ajay.app/*", 47 | "https://*.youtube.com/*", 48 | "https://www.youtube-nocookie.com/embed/*" 49 | ], 50 | "action": { 51 | "default_title": "DeArrow", 52 | "default_popup": "popup.html", 53 | "default_icon": { 54 | "16": "icons/logo-16.png", 55 | "32": "icons/logo-32.png", 56 | "64": "icons/logo-64.png", 57 | "128": "icons/logo-128.png" 58 | }, 59 | "theme_icons": [ 60 | { 61 | "light": "icons/logo-16.png", 62 | "dark": "icons/logo-16.png", 63 | "size": 16 64 | }, 65 | { 66 | "light": "icons/logo-32.png", 67 | "dark": "icons/logo-32.png", 68 | "size": 32 69 | }, 70 | { 71 | "light": "icons/logo-64.png", 72 | "dark": "icons/logo-64.png", 73 | "size": 64 74 | }, 75 | { 76 | "light": "icons/logo-128.png", 77 | "dark": "icons/logo-128.png", 78 | "size": 128 79 | }, 80 | { 81 | "light": "icons/logo-256.png", 82 | "dark": "icons/logo-256.png", 83 | "size": 256 84 | }, 85 | { 86 | "light": "icons/logo-512.png", 87 | "dark": "icons/logo-512.png", 88 | "size": 512 89 | }, 90 | { 91 | "light": "icons/logo-1024.png", 92 | "dark": "icons/logo-1024.png", 93 | "size": 1024 94 | } 95 | ] 96 | }, 97 | "background": { 98 | "service_worker": "./js/background.js" 99 | }, 100 | "manifest_version": 3 101 | } -------------------------------------------------------------------------------- /manifest/firefox-beta-manifest-extra.json: -------------------------------------------------------------------------------- 1 | { 2 | "browser_specific_settings": { 3 | "gecko": { 4 | "id": "deArrowBETA@ajay.app" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /manifest/firefox-manifest-extra.json: -------------------------------------------------------------------------------- 1 | { 2 | "browser_specific_settings": { 3 | "gecko": { 4 | "id": "deArrow@ajay.app", 5 | "strict_min_version": "102.0" 6 | }, 7 | "gecko_android": { 8 | "strict_min_version": "113.0" 9 | } 10 | }, 11 | "permissions": [ 12 | "scripting" 13 | ], 14 | "content_scripts": [{ 15 | "run_at": "document_start", 16 | "matches": [ 17 | "https://*.youtube.com/*", 18 | "https://www.youtube-nocookie.com/embed/*" 19 | ], 20 | "all_frames": true, 21 | "js": [ 22 | "./js/documentScriptInjector.js" 23 | ], 24 | "css": [ 25 | "content.css", 26 | "shared.css" 27 | ] 28 | }], 29 | "background": { 30 | "persistent": false 31 | }, 32 | "browser_action": { 33 | "default_area": "navbar" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /manifest/manifest-v2-extra.json: -------------------------------------------------------------------------------- 1 | { 2 | "web_accessible_resources": [ 3 | "icons/refresh.svg", 4 | "icons/logo.svg", 5 | "js/document.js", 6 | "js/options.js", 7 | "js/popup.js", 8 | "popup.css", 9 | "shared.css", 10 | "help.html", 11 | "help.css", 12 | "icons/logo-16.png", 13 | "icons/logo-32.png", 14 | "icons/logo-64.png", 15 | "icons/logo-128.png", 16 | "icons/logo-256.png", 17 | "icons/logo-2r.svg", 18 | "icons/logo-casual.svg", 19 | "icons/close.png", 20 | "icons/add.svg", 21 | "icons/remove.svg" 22 | ], 23 | "permissions": [ 24 | "https://sponsor.ajay.app/*", 25 | "https://dearrow-thumb.ajay.app/*", 26 | "https://*.googlevideo.com/*", 27 | "https://*.youtube.com/*", 28 | "https://www.youtube-nocookie.com/embed/*" 29 | ], 30 | "optional_permissions": [ 31 | "*://*/*" 32 | ], 33 | "browser_action": { 34 | "default_title": "DeArrow", 35 | "default_popup": "popup.html", 36 | "default_icon": { 37 | "16": "icons/logo-16.png", 38 | "32": "icons/logo-32.png", 39 | "64": "icons/logo-64.png", 40 | "128": "icons/logo-128.png" 41 | }, 42 | "theme_icons": [ 43 | { 44 | "light": "icons/logo-16.png", 45 | "dark": "icons/logo-16.png", 46 | "size": 16 47 | }, 48 | { 49 | "light": "icons/logo-32.png", 50 | "dark": "icons/logo-32.png", 51 | "size": 32 52 | }, 53 | { 54 | "light": "icons/logo-64.png", 55 | "dark": "icons/logo-64.png", 56 | "size": 64 57 | }, 58 | { 59 | "light": "icons/logo-128.png", 60 | "dark": "icons/logo-128.png", 61 | "size": 128 62 | }, 63 | { 64 | "light": "icons/logo-256.png", 65 | "dark": "icons/logo-256.png", 66 | "size": 256 67 | }, 68 | { 69 | "light": "icons/logo-512.png", 70 | "dark": "icons/logo-512.png", 71 | "size": 512 72 | }, 73 | { 74 | "light": "icons/logo-1024.png", 75 | "dark": "icons/logo-1024.png", 76 | "size": 1024 77 | } 78 | ] 79 | }, 80 | "background": { 81 | "scripts":[ 82 | "./js/background.js" 83 | ] 84 | }, 85 | "manifest_version": 2 86 | } -------------------------------------------------------------------------------- /manifest/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "__MSG_deArrowFullName__", 3 | "short_name": "DeArrow", 4 | "version": "2.1", 5 | "default_locale": "en", 6 | "description": "__MSG_deArrowDescription__", 7 | "homepage_url": "https://dearrow.ajay.app", 8 | "permissions": [ 9 | "storage", 10 | "unlimitedStorage", 11 | "alarms" 12 | ], 13 | "icons": { 14 | "16": "icons/logo-16.png", 15 | "32": "icons/logo-32.png", 16 | "64": "icons/logo-64.png", 17 | "128": "icons/logo-128.png", 18 | "256": "icons/logo-256.png", 19 | "512": "icons/logo-512.png", 20 | "1024": "icons/logo-1024.png" 21 | }, 22 | "options_ui": { 23 | "page": "options/options.html", 24 | "open_in_tab": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /manifest/safari-manifest-extra.json: -------------------------------------------------------------------------------- 1 | { 2 | "background": { 3 | "persistent": false 4 | }, 5 | "content_scripts": [{ 6 | "run_at": "document_start", 7 | "matches": [ 8 | "https://*.youtube.com/*", 9 | "https://www.youtube-nocookie.com/embed/*" 10 | ], 11 | "all_frames": true, 12 | "js": [ 13 | "./js/content.js" 14 | ], 15 | "css": [ 16 | "content.css", 17 | "shared.css" 18 | ] 19 | }] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dearrow", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "background.js", 6 | "dependencies": { 7 | "cld3-asm": "^3.1.1", 8 | "react": "^18.2.0", 9 | "react-dom": "^18.2.0", 10 | "seedrandom": "^3.0.5" 11 | }, 12 | "devDependencies": { 13 | "@types/chrome": "^0.0.243", 14 | "@types/firefox-webext-browser": "^111.0.1", 15 | "@types/jest": "^29.1.2", 16 | "@types/react": "^18.0.21", 17 | "@types/react-dom": "^18.0.6", 18 | "@types/seedrandom": "^3.0.5", 19 | "@types/selenium-webdriver": "^4.1.5", 20 | "@types/wicg-mediasession": "^1.1.4", 21 | "@typescript-eslint/eslint-plugin": "^5.39.0", 22 | "@typescript-eslint/parser": "^5.39.0", 23 | "chromedriver": "^135.0.1", 24 | "concurrently": "^7.4.0", 25 | "copy-webpack-plugin": "^11.0.0", 26 | "eslint": "^8.24.0", 27 | "eslint-plugin-react": "^7.31.8", 28 | "fork-ts-checker-webpack-plugin": "^7.2.13", 29 | "jest": "^29.1.2", 30 | "jest-environment-jsdom": "^29.1.2", 31 | "rimraf": "^3.0.2", 32 | "schema-utils": "^4.0.0", 33 | "selenium-webdriver": "^4.5.0", 34 | "speed-measure-webpack-plugin": "^1.5.0", 35 | "ts-jest": "^29.0.3", 36 | "ts-loader": "^9.4.1", 37 | "ts-node": "^10.9.1", 38 | "typescript": "4.8", 39 | "web-ext": "^8.2.0", 40 | "webpack": "^5.94.0", 41 | "webpack-cli": "^4.10.0", 42 | "webpack-merge": "^5.8.0" 43 | }, 44 | "scripts": { 45 | "web-run": "npm run web-run:chrome", 46 | "web-sign": "web-ext sign --channel unlisted -s dist", 47 | "web-run:firefox": "cd dist && web-ext run --start-url https://addons.mozilla.org/firefox/addon/ublock-origin/", 48 | "web-run:firefox-android": "cd dist && web-ext run -t firefox-android --firefox-apk org.mozilla.fenix", 49 | "web-run:chrome": "cd dist && web-ext run --start-url https://chrome.google.com/webstore/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm -t chromium", 50 | "build": "npm run build:chrome", 51 | "build:chrome": "webpack --env browser=chrome --config webpack/webpack.prod.js", 52 | "build:firefox": "webpack --env browser=firefox --config webpack/webpack.prod.js", 53 | "build:safari": "webpack --env browser=safari --config webpack/webpack.prod.js", 54 | "build:edge": "webpack --env browser=edge --config webpack/webpack.prod.js", 55 | "build:dev": "npm run build:dev:chrome", 56 | "build:dev:chrome": "webpack --env browser=chrome --config webpack/webpack.dev.js", 57 | "build:dev:firefox": "webpack --env browser=firefox --config webpack/webpack.dev.js", 58 | "build:watch": "npm run build:watch:chrome", 59 | "build:watch:chrome": "webpack --env browser=chrome --config webpack/webpack.dev.js --watch", 60 | "build:watch:firefox": "webpack --env browser=firefox --config webpack/webpack.dev.js --watch", 61 | "ci:invidious": "ts-node ci/invidiousCI.ts", 62 | "dev": "npm run build:dev && concurrently \"npm run web-run\" \"npm run build:watch\"", 63 | "dev:firefox": "npm run build:dev:firefox && concurrently \"npm run web-run:firefox\" \"npm run build:watch:firefox\"", 64 | "dev:firefox-android": "npm run build:dev:firefox && concurrently \"npm run web-run:firefox-android\" \"npm run build:watch:firefox\"", 65 | "clean": "rimraf dist", 66 | "test": "npm run build:chrome && npm run test:no-build", 67 | "test-without-building": "npm run test:no-build", 68 | "test:no-build": "npx jest --passWithNoTests", 69 | "lint": "eslint src", 70 | "lint:fix": "eslint src --fix" 71 | }, 72 | "engines": { 73 | "node": ">=16" 74 | }, 75 | "funding": [ 76 | { 77 | "type": "individual", 78 | "url": "https://sponsor.ajay.app/donate" 79 | }, 80 | { 81 | "type": "github", 82 | "url": "https://github.com/sponsors/ajayyy-org" 83 | }, 84 | { 85 | "type": "patreon", 86 | "url": "https://www.patreon.com/ajayyy" 87 | }, 88 | { 89 | "type": "individual", 90 | "url": "https://paypal.me/ajayyy" 91 | } 92 | ], 93 | "repository": { 94 | "type": "git", 95 | "url": "git+https://github.com/ajayyy/DeArrow.git" 96 | }, 97 | "author": "Ajay Ramachandran", 98 | "license": "GPL-3.0", 99 | "private": true 100 | } 101 | -------------------------------------------------------------------------------- /public/casual.css: -------------------------------------------------------------------------------- 1 | .embed [data-type="react-CasualChoiceComponent"] { 2 | display: none; 3 | } 4 | 5 | .casualChoiceContainer { 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | padding: 20px; 10 | font-size: 14px; 11 | --container-gap: 20px; 12 | gap: var(--container-gap); 13 | color: #dfdfdf; 14 | 15 | height: auto; 16 | 17 | border-radius: 10px; 18 | border-width: 10px; 19 | border: solid; 20 | 21 | width: 300px; 22 | cursor: pointer; 23 | 24 | transition: border-color ease-in-out 0.2s, background-color ease-in-out 0.2s; 25 | } 26 | 27 | .casualChoiceContainer.casualMode { 28 | border-color: rgba(121, 215, 152, 0.5); 29 | } 30 | .casualChoiceContainer.casualMode.selected { 31 | border-color: rgba(121, 215, 152, 1); 32 | background-color: rgba(0, 118, 31, 0.2); 33 | } 34 | 35 | .casualChoiceContainer.classicMode { 36 | border-color: rgba(136, 201, 249, 0.5); 37 | } 38 | .casualChoiceContainer.classicMode.selected { 39 | border-color: rgba(136, 201, 249, 1); 40 | background-color: rgba(18, 19, 189, 0.2); 41 | } 42 | 43 | .casualChoiceTitle { 44 | font-size: 24px; 45 | font-weight: bold; 46 | } 47 | 48 | .casualChoiceLogo { 49 | padding-left: 35%; 50 | padding-right: 35%; 51 | 52 | box-sizing: border-box; 53 | } 54 | 55 | .casualChoiceDescription { 56 | font-size: 14px; 57 | } 58 | 59 | .casualChoiceDescription div { 60 | margin-top: 10px; 61 | margin-bottom: 10px; 62 | line-height: 1.3; 63 | } 64 | .casualChoiceDescription div:first-child { 65 | margin-top: 0px; 66 | } 67 | .casualChoiceDescription div:last-child { 68 | margin-bottom: 0px; 69 | } 70 | 71 | .casualChoicesContainer { 72 | display: flex; 73 | justify-content: center; 74 | gap: 10%; 75 | max-width: 800px; 76 | } 77 | 78 | .casualChoiceCategories { 79 | display: flex; 80 | flex-direction: column; 81 | justify-content: center; 82 | align-items: center; 83 | --container-gap: 20px; 84 | gap: var(--container-gap); 85 | width: 100%; 86 | } 87 | 88 | .casualChoiceContainer:not(.selected) .casualChoiceCategories { 89 | display: none; 90 | } 91 | 92 | .casualCategoryPill { 93 | display: flex; 94 | flex-direction: row; 95 | justify-content: space-between; 96 | align-items: center; 97 | 98 | cursor: default; 99 | width: 80%; 100 | border-radius: 25px; 101 | background-color: rgba(121, 215, 152, 0.5); 102 | 103 | position: relative; 104 | } 105 | 106 | .casualCategoryPillContent { 107 | display: flex; 108 | flex-direction: column; 109 | justify-content: center; 110 | align-items: center; 111 | gap: 5px; 112 | 113 | width: 100%; 114 | 115 | padding: 5px; 116 | font-size: 16px; 117 | } 118 | 119 | .addButton { 120 | width: 25px; 121 | height: 25px; 122 | font-size: 20px; 123 | background-color: rgba(121, 215, 152, 0.5); 124 | cursor: pointer; 125 | } 126 | 127 | .minimumVotes { 128 | display: flex; 129 | flex-direction: row; 130 | justify-content: center; 131 | align-items: center; 132 | gap: 5px; 133 | } 134 | 135 | .minimumVotesText { 136 | font-size: 11px; 137 | opacity: 0.8; 138 | } 139 | 140 | .minimumVotesButton { 141 | border: solid 1px; 142 | cursor: pointer; 143 | 144 | --font-size: 14px; 145 | 146 | font-size: var(--font-size); 147 | width: var(--font-size); 148 | height: var(--font-size); 149 | 150 | display: flex; 151 | justify-content: center; 152 | align-items: center; 153 | line-height: 1; 154 | 155 | border-radius: 100%; 156 | } 157 | 158 | .casualCategoryPill .closeButton { 159 | cursor: pointer; 160 | position: absolute; 161 | right: 5px; 162 | 163 | padding: 5px; 164 | } 165 | 166 | .casualCategorySelectionAnchor { 167 | position: relative; 168 | width: 100%; 169 | 170 | /* Undo gap */ 171 | margin-top: calc(var(--container-gap) * -1); 172 | } 173 | 174 | .casualCategorySelectionParent { 175 | position: absolute; 176 | background-color: rgba(28, 28, 28, 0.9); 177 | padding: 5px; 178 | border-radius: 25px; 179 | 180 | top: 5px; 181 | left: 0; 182 | right: 0; 183 | width: fit-content; 184 | margin: auto; 185 | } 186 | 187 | .casualCategorySelection { 188 | font-size: 14px; 189 | padding: 5px; 190 | margin: 10px; 191 | 192 | border-radius: 25px; 193 | text-align: center; 194 | } 195 | 196 | [data-theme="light"] .casualCategorySelection { 197 | color: white; 198 | } 199 | 200 | .casualCategorySelection:hover { 201 | background-color: rgba(235, 235, 235, 0.2); 202 | } 203 | 204 | .casualChoicesContainer input:checked + .sb-slider { 205 | background-color: rgba(121, 215, 152, 0.5); 206 | } 207 | 208 | .casualChoicesContainer .sb-switch-container { 209 | width: 100%; 210 | display: flex; 211 | 212 | --sb-slider-color: #495c49; 213 | } 214 | 215 | .casualChoicesContainer .sb-switch-container .sb-switch-label { 216 | text-align: center; 217 | } -------------------------------------------------------------------------------- /public/help.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-scheme: dark; 3 | --background: #333333; 4 | --header-color: #212121; 5 | --dialog-background: #181818; 6 | --dialog-border: white; 7 | --text: #c4c4c4; 8 | --title: #dad8d8; 9 | --disabled: #080052; 10 | --black: black; 11 | --white: white; 12 | } 13 | 14 | [data-theme="light"] { 15 | --color-scheme: light; 16 | --background: #f9f9f9; 17 | --header-color: white; 18 | --dialog-background: #f9f9f9; 19 | --dialog-border: #282828; 20 | --text: #262626; 21 | --title: #707070; 22 | --disabled: #ffcaca; 23 | --black: white; 24 | --white: black; 25 | } 26 | 27 | html { 28 | color-scheme: var(--color-scheme); 29 | } 30 | 31 | .bigText { 32 | font-size: 50px; 33 | } 34 | 35 | .bigText > * { 36 | font-size: 50px; 37 | } 38 | 39 | body { 40 | background-color: var(--background); 41 | font-family: sans-serif; 42 | } 43 | 44 | .center { 45 | text-align: center; 46 | } 47 | 48 | .inline { 49 | display: inline-block; 50 | } 51 | 52 | .container { 53 | max-width: 900px; 54 | margin: auto; 55 | } 56 | 57 | .projectPreview { 58 | position: relative; 59 | } 60 | 61 | .projectPreviewImage { 62 | position: absolute; 63 | left: -90px; 64 | width: 80px; 65 | top: 50%; 66 | transform: translateY(-50%); 67 | } 68 | 69 | .projectPreviewImageLarge { 70 | position: absolute; 71 | left: -210px; 72 | width: 200px; 73 | top: 50%; 74 | transform: translateY(-20%); 75 | } 76 | 77 | .createdBy { 78 | font-size: 14px; 79 | text-align: center; 80 | padding-top: 0px; 81 | padding-bottom: 0px; 82 | } 83 | 84 | #title { 85 | background-color: #636363; 86 | 87 | text-align: center; 88 | vertical-align: middle; 89 | 90 | font-size: 50px; 91 | color: var(--header-color); 92 | 93 | padding: 20px; 94 | 95 | text-decoration: none; 96 | 97 | border-radius: 15px; 98 | 99 | transition: font-size 1s; 100 | } 101 | 102 | #titleText { 103 | padding-left: 5px; 104 | } 105 | 106 | .subtitle { 107 | font-size: 40px; 108 | color: #dad8d8; 109 | 110 | padding-top: 10px; 111 | 112 | transition: font-size 0.4s; 113 | } 114 | 115 | .subtitle:hover { 116 | font-size: 45px; 117 | 118 | transition: font-size 0.4s; 119 | } 120 | 121 | .profilepic { 122 | background-color: #636363 !important; 123 | vertical-align: middle; 124 | } 125 | 126 | .profilepiccircle { 127 | vertical-align: middle; 128 | overflow: hidden; 129 | border-radius: 50%; 130 | } 131 | 132 | a { 133 | text-decoration: underline; 134 | color: inherit; 135 | } 136 | 137 | .link { 138 | padding: 20px; 139 | 140 | height: 80px; 141 | 142 | transition: height 0.2s; 143 | } 144 | 145 | .link:hover { 146 | height: 95px; 147 | 148 | transition: height 0.2s; 149 | } 150 | 151 | #contact, 152 | .smalllink { 153 | font-size: 25px; 154 | color: #e8e8e8; 155 | 156 | text-align: center; 157 | 158 | padding: 10px; 159 | } 160 | 161 | #contact { 162 | text-decoration: none; 163 | } 164 | 165 | p, 166 | li { 167 | font-size: 16px; 168 | } 169 | 170 | p, 171 | li, 172 | a, 173 | span, 174 | div:not(.casualChoicesContainer *) { 175 | color: var(--text); 176 | } 177 | 178 | p, 179 | li, 180 | code, 181 | a { 182 | text-align: left; 183 | overflow-wrap: break-word; 184 | } 185 | 186 | .optionsFrame { 187 | width: 100%; 188 | height: 500px; 189 | } 190 | 191 | .previewImage { 192 | max-height: 200px; 193 | } 194 | 195 | img { 196 | max-width: 100%; 197 | 198 | text-align: center; 199 | } 200 | 201 | #recentPostTitle { 202 | font-size: 30px; 203 | color: #dad8d8; 204 | } 205 | 206 | #recentPostDate { 207 | font-size: 15px; 208 | color: #dad8d8; 209 | } 210 | 211 | h1, 212 | h2, 213 | h3, 214 | h4, 215 | h5, 216 | h6 { 217 | color: var(--title); 218 | text-align: center; 219 | } 220 | 221 | svg { 222 | text-decoration: none; 223 | } 224 | 225 | #sbDonate { 226 | font-size: 10px; 227 | } 228 | 229 | @media screen and (orientation:portrait) { 230 | .projectPreviewImage { 231 | position: unset; 232 | width: 50%; 233 | display: block; 234 | margin: auto; 235 | transform: none; 236 | } 237 | 238 | .projectPreviewImageLarge { 239 | position: unset; 240 | left: 0; 241 | width: 50%; 242 | display: block; 243 | margin: auto; 244 | transform: unset; 245 | } 246 | 247 | .container { 248 | max-width: 100%; 249 | margin: 5px; 250 | text-align: center; 251 | } 252 | 253 | p, 254 | li, 255 | code, 256 | a { 257 | text-align: center; 258 | } 259 | } 260 | 261 | /* keybind dialog */ 262 | .key { 263 | border-width: 1px; 264 | border-style: solid; 265 | border-radius: 5px; 266 | display: inline-block; 267 | min-width: 33px; 268 | text-align: center; 269 | font-weight: bold; 270 | border-color: var(--white); 271 | box-sizing: border-box; 272 | } 273 | 274 | .unbound, 275 | .key { 276 | padding: 8px; 277 | } 278 | 279 | #keybind-dialog .dialog { 280 | position: fixed; 281 | border-width: 3px; 282 | border-style: solid; 283 | border-radius: 15px; 284 | max-height: 100vh; 285 | width: 400px; 286 | overflow-x: auto; 287 | z-index: 100; 288 | padding: 15px; 289 | left: 50%; 290 | top: 50%; 291 | transform: translate(-50%, -50%); 292 | font-size: 14px; 293 | background-color: var(--dialog-background); 294 | border-color: var(--dialog-border); 295 | } 296 | 297 | #change-keybind-buttons { 298 | float: right; 299 | } 300 | 301 | #change-keybind-buttons>.option-button { 302 | margin: 0 2px; 303 | } 304 | 305 | #change-keybind-settings { 306 | margin: 15px 15px 30px; 307 | } 308 | 309 | #change-keybind-settings .key { 310 | vertical-align: top; 311 | margin: 15px 0 0 40px; 312 | height: 34px; 313 | } 314 | 315 | #change-keybind-error { 316 | margin-bottom: 15px; 317 | color: red; 318 | font-weight: bold; 319 | } 320 | 321 | .blocker { 322 | position: fixed; 323 | left: 0; 324 | right: 0; 325 | top: 0; 326 | bottom: 0; 327 | z-index: 90; 328 | background-color: #00000080; 329 | } 330 | 331 | .option-button { 332 | cursor: pointer; 333 | 334 | background-color: #0e79ca; 335 | padding: 10px; 336 | color: white; 337 | border-radius: 5px; 338 | font-size: 14px; 339 | 340 | width: max-content; 341 | } 342 | 343 | .option-button:hover:not(.disabled) { 344 | background-color: #0e79ca; 345 | } 346 | 347 | .option-button.disabled { 348 | cursor: default; 349 | background-color: var(--disabled); 350 | color: grey; 351 | } 352 | 353 | .selfpromo-text { 354 | color: #8a8a8a; 355 | cursor: pointer; 356 | } 357 | 358 | .hidden { 359 | display: none; 360 | } 361 | 362 | .payment-announcement-container { 363 | padding: 15px; 364 | border-radius: 20px; 365 | background-color: #171717; 366 | } 367 | 368 | .payment-announcement-container * { 369 | color: white; 370 | } 371 | 372 | .payment-announcement > * { 373 | font-size: 25px; 374 | margin-top: 0; 375 | text-align: center; 376 | } 377 | 378 | .center-button { 379 | margin-left: auto; 380 | margin-right: auto; 381 | } 382 | 383 | .option-text-box { 384 | width: 300px; 385 | font-size: 13px; 386 | 387 | margin: 10px 388 | } 389 | 390 | .option-link { 391 | text-decoration: none; 392 | } 393 | 394 | .option-link.side-by-side { 395 | margin: 50px; 396 | } 397 | 398 | .redeem-box { 399 | margin-bottom: 10px; 400 | } 401 | 402 | .casualChoicesContainer { 403 | margin-top: 10px; 404 | margin-bottom: 10px; 405 | } -------------------------------------------------------------------------------- /public/help.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/icons/add.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/close.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/icons/close.png -------------------------------------------------------------------------------- /public/icons/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 35 | 39 | 43 | 44 | -------------------------------------------------------------------------------- /public/icons/help.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 58 | 59 | -------------------------------------------------------------------------------- /public/icons/logo-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/icons/logo-1024.png -------------------------------------------------------------------------------- /public/icons/logo-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/icons/logo-128.png -------------------------------------------------------------------------------- /public/icons/logo-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/icons/logo-16.png -------------------------------------------------------------------------------- /public/icons/logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/icons/logo-256.png -------------------------------------------------------------------------------- /public/icons/logo-2r.svg: -------------------------------------------------------------------------------- 1 | 2 | 31 | -------------------------------------------------------------------------------- /public/icons/logo-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/icons/logo-32.png -------------------------------------------------------------------------------- /public/icons/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/icons/logo-512.png -------------------------------------------------------------------------------- /public/icons/logo-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/icons/logo-64.png -------------------------------------------------------------------------------- /public/icons/logo-casual.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 55 | -------------------------------------------------------------------------------- /public/icons/newprofilepic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/icons/newprofilepic.jpg -------------------------------------------------------------------------------- /public/icons/not_visible.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 35 | 39 | 40 | -------------------------------------------------------------------------------- /public/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 21 | 23 | image/svg+xml 24 | 26 | 27 | 28 | 29 | 31 | 51 | 55 | 59 | 60 | -------------------------------------------------------------------------------- /public/icons/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 36 | 40 | 44 | 45 | -------------------------------------------------------------------------------- /public/icons/remove.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icons/thumbs_down_locked.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 20 | 22 | image/svg+xml 23 | 25 | 26 | 27 | 28 | 30 | 50 | 54 | 58 | 59 | -------------------------------------------------------------------------------- /public/icons/visible.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/oss-attribution/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/public/oss-attribution/.gitkeep -------------------------------------------------------------------------------- /public/payment.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Payment 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/popup.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --sb-main-font-family: "Source Sans Pro", sans-serif; 3 | --sb-main-bg-color: #222; 4 | --sb-main-fg-color: #fff; 5 | --sb-grey-bg-color: #333; 6 | --sb-grey-fg-color: #999; 7 | --sb-red-bg-color: #cc1717; 8 | --cb-switch-color: #0e79ca; 9 | 10 | /* Options */ 11 | --color-scheme: dark; 12 | --background: #333333; 13 | --menu-background: #181818; 14 | --menu-foreground: white; 15 | --dialog-background: #181818; 16 | --dialog-border: white; 17 | --tab-color: #242424; 18 | --tab-button-hover: #07375d; 19 | --tab-hover: white; 20 | --description: #dfdfdf; 21 | --disabled: #080052; 22 | --title: #dad8d8; 23 | --border-color: #484848; 24 | --black: black; 25 | --white: white; 26 | } 27 | 28 | /* 29 | * Main containers 30 | */ 31 | #sponsorBlockPopupHTML { 32 | color-scheme: dark; 33 | max-height: 600px; 34 | overflow-y: auto; 35 | } 36 | 37 | #sponsorBlockPopupBody { 38 | margin: 0; 39 | width: 374px; 40 | max-width: 100%; 41 | /* NOTE: Ensures content doesn't exceed restricted popup widths in Firefox */ 42 | font-size: 14px; 43 | font-family: var(--sb-main-font-family); 44 | background-color: var(--sb-main-bg-color); 45 | color: var(--sb-main-fg-color); 46 | color-scheme: dark; 47 | text-align: center; 48 | } 49 | 50 | #sponsorBlockPopupBody a, 51 | #sponsorBlockPopupBody button { 52 | cursor: pointer; 53 | } 54 | 55 | /* 56 | * Header logo 57 | */ 58 | .sbPopupLogo { 59 | display: flex; 60 | align-items: center; 61 | justify-content: center; 62 | font-weight: bold; 63 | user-select: none; 64 | padding: 10px 0px 0px; 65 | font-size: 32px; 66 | } 67 | 68 | .sbPopupLogo img { 69 | margin: 8px; 70 | } 71 | 72 | /* 73 | * Main controls menu 74 | */ 75 | .sbControlsMenu { 76 | margin: 16px; 77 | margin-top: 6px; 78 | border-radius: 8px; 79 | background-color: var(--sb-grey-bg-color); 80 | justify-content: space-evenly; 81 | overflow: hidden; 82 | display: flex; 83 | } 84 | 85 | .sbControlsMenu-item { 86 | display: flex; 87 | align-items: center; 88 | flex-direction: column; 89 | justify-content: center; 90 | background: transparent; 91 | user-select: none; 92 | cursor: pointer; 93 | border: none; 94 | flex: 1; 95 | padding: 10px 15px; 96 | transition: background-color 0.2s ease-in-out; 97 | } 98 | 99 | .sbControlsMenu-item:hover { 100 | background-color: #444; 101 | } 102 | 103 | .sbControlsMenu-itemIcon { 104 | margin-bottom: 6px; 105 | } 106 | 107 | /* 108 | * "Extension is enabled" toggle 109 | */ 110 | .toggleSwitchContainer { 111 | display: flex; 112 | align-items: center; 113 | flex-direction: column; 114 | } 115 | 116 | .toggleSwitchContainer-switch { 117 | display: flex; 118 | margin-bottom: 6px; 119 | } 120 | 121 | .switchBg { 122 | width: 50px; 123 | height: 23px; 124 | display: block; 125 | border-radius: 18.5px; 126 | } 127 | 128 | .switchBg.shadow { 129 | box-shadow: 0.75px 0.75px 10px 0px rgba(50, 50, 50, 0.5); 130 | opacity: 1; 131 | } 132 | 133 | .switchBg.white { 134 | opacity: 1; 135 | position: absolute; 136 | background-color: #ccc; 137 | } 138 | 139 | .switchBg.blue { 140 | opacity: 0; 141 | position: absolute; 142 | background-color: var(--cb-switch-color); 143 | transition: opacity 0.2s ease-out; 144 | } 145 | 146 | .switchDot { 147 | width: 15px; 148 | margin: 4px; 149 | height: 15px; 150 | border-radius: 50%; 151 | position: absolute; 152 | transition: transform 0.2s ease-out; 153 | background-color: var(--sb-main-fg-color); 154 | box-shadow: 0.75px 0.75px 3.8px 0px rgba(50, 50, 50, 0.45); 155 | } 156 | 157 | #toggleSwitch:checked~.switchDot { 158 | transform: translateX(27px); 159 | } 160 | 161 | #toggleSwitch:checked~.switchBg.blue { 162 | opacity: 1; 163 | } 164 | 165 | #toggleSwitch:checked~.switchBg.white { 166 | transition: opacity 0.2s step-end; 167 | opacity: 0; 168 | } 169 | 170 | 171 | 172 | /* 173 | * Footer 174 | */ 175 | #sbFooter { 176 | padding: 8px 0; 177 | } 178 | 179 | #sbFooter a { 180 | transition: background 0.3s ease !important; 181 | color: var(--sb-main-fg-color); 182 | display: inline-block; 183 | text-decoration: none; 184 | border-radius: 4px; 185 | background-color: #333; 186 | padding: 4px 8px; 187 | font-weight: 500; 188 | margin: 2px 1px; 189 | } 190 | 191 | #sbFooter a:hover { 192 | background-color: #444; 193 | } 194 | 195 | #sponsorTimesDonateContainer a { 196 | color: var(--sb-main-fg-color); 197 | text-decoration: none; 198 | } 199 | 200 | .activation-needed { 201 | background-color: #171717; 202 | border-radius: 15px; 203 | 204 | padding: 20px; 205 | margin: 20px; 206 | 207 | font-size: 20px; 208 | } 209 | 210 | .option-button { 211 | cursor: pointer; 212 | 213 | background-color: #0e79ca; 214 | padding: 10px; 215 | color: white; 216 | border-radius: 5px; 217 | font-size: 14px; 218 | 219 | width: max-content; 220 | 221 | margin: auto; 222 | text-align: center; 223 | } 224 | 225 | .option-button:hover:not(.disabled) { 226 | background-color: #0e79ca; 227 | } -------------------------------------------------------------------------------- /public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/shared.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --sb-slider-color: #707070; 3 | } 4 | 5 | /* 6 | * Generic utilities 7 | */ 8 | .cb-grey-text { 9 | color: var(--sb-grey-fg-color); 10 | } 11 | 12 | .cb-white-text { 13 | color: var(--sb-main-fg-color); 14 | } 15 | 16 | .sbHeader { 17 | font-size: 20px; 18 | font-weight: bold; 19 | text-align: left; 20 | margin: 0; 21 | } 22 | 23 | #sponsorBlockPopupBody .u-mZ, .sbYourWorkBox .u-mZ { 24 | margin: 0 !important; 25 | position: relative; 26 | } 27 | 28 | #sponsorBlockPopupBody .hidden, .sbYourWorkBox .hidden { 29 | display: none !important; 30 | } 31 | 32 | 33 | /* 34 | * Your Work box 35 | */ 36 | .sbYourWorkBox { 37 | margin: 16px; 38 | margin-bottom: 8px; 39 | border-radius: 8px; 40 | border: 2px solid var(--sb-grey-bg-color); 41 | } 42 | 43 | .sbYourWorkBox a, 44 | .sbYourWorkBox button { 45 | cursor: pointer; 46 | } 47 | 48 | .sbYourWorkCols { 49 | display: flex; 50 | border-top: 2px solid var(--sb-grey-bg-color); 51 | } 52 | 53 | .sbStatsSentence { 54 | padding: 6px 14px; 55 | border-top: 2px solid var(--sb-grey-bg-color); 56 | } 57 | 58 | /* 59 | * Increase font size of username input and display 60 | */ 61 | #usernameValue, 62 | #usernameInput { 63 | font-size: 16px; 64 | flex: 1 0; 65 | } 66 | 67 | #sponsorTimesContributionsDisplay { 68 | font-size: 16px; 69 | } 70 | 71 | /* 72 | * 69 | 70 | 71 | ); 72 | }; 73 | 74 | interface YesOrNoProps { 75 | voteType: CasualVoteType | null; 76 | setVoteType: (v: CasualVoteType | null) => void; 77 | } 78 | 79 | function YesOrNo(props: YesOrNoProps): React.ReactElement { 80 | return ( 81 | <> 82 |
83 | { 87 | if (checked) { 88 | props.setVoteType(CasualVoteType.Like); 89 | } else { 90 | props.setVoteType(null); 91 | } 92 | }} 93 | showCheckbox={false} 94 | /> 95 | 96 | { 100 | if (checked) { 101 | props.setVoteType(CasualVoteType.Dislike); 102 | } else { 103 | props.setVoteType(null); 104 | } 105 | }} 106 | showCheckbox={false} 107 | /> 108 |
109 | 110 | ); 111 | } 112 | 113 | interface CheckboxesProps { 114 | voteInfo: Set; 115 | show: boolean; 116 | existingVotes: CasualVoteInfo[]; 117 | setVoteInfoReady: (v: boolean) => void; 118 | } 119 | 120 | function Checkboxes(props: CheckboxesProps): React.ReactElement { 121 | const result: React.ReactElement[] = []; 122 | 123 | for (const category of casualVoteCategories) { 124 | const existingVote = props.existingVotes.find((v) => v.id === category.id); 125 | 126 | result.push( 127 | { 132 | if (checked) { 133 | props.voteInfo.add(category.id); 134 | } else { 135 | props.voteInfo.delete(category.id); 136 | } 137 | 138 | props.setVoteInfoReady(props.voteInfo.size > 0); 139 | }} 140 | showCheckbox={true} 141 | /> 142 | ); 143 | } 144 | 145 | return ( 146 |
147 |
148 | {result} 149 |
150 |
151 | ); 152 | } 153 | 154 | function getVotesText(count: number): string { 155 | const format = count === 1 ? chrome.i18n.getMessage("vote") : chrome.i18n.getMessage("votes"); 156 | return format.replace("{0}", count.toString()); 157 | } 158 | 159 | interface CheckboxProps { 160 | langKey: string; 161 | checked?: boolean; 162 | onChange: (value: boolean) => void; 163 | subtitle?: string; 164 | showCheckbox: boolean; 165 | } 166 | 167 | function Checkbox(props: CheckboxProps): React.ReactElement { 168 | const [checked, setChecked] = React.useState(props.checked ?? false); 169 | 170 | if (props.checked != null && checked !== props.checked) { 171 | setChecked(props.checked); 172 | } 173 | 174 | return ( 175 |
{ 177 | setChecked(!checked); 178 | props.onChange(!checked); 179 | }} 180 | key={props.langKey}> 181 |
182 | 188 | 189 | 190 | { 191 | props.showCheckbox && 192 | 193 | 194 | 195 | } 196 |
197 | 198 |
199 |
200 | 203 |
204 | 205 | {props.subtitle && ( 206 |
207 | {props.subtitle} 208 |
209 | )} 210 |
211 |
212 | ); 213 | } -------------------------------------------------------------------------------- /src/submission/CasualVoteOnboardingComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { FormattedText } from "../popup/FormattedTextComponent"; 3 | import { casualWikiLink } from "./casualVote.const"; 4 | import { TitleFormatting } from "../config/config"; 5 | 6 | export interface CasualVoteComponentProps { 7 | close: () => void; 8 | } 9 | 10 | export const CasualVoteOnboardingComponent = (props: CasualVoteComponentProps) => { 11 | 12 | return ( 13 |
e.stopPropagation()} 15 | onMouseDown={(e) => e.stopPropagation()}> 16 | 17 |
18 | 21 |
22 | 23 |
24 | 28 |
29 | 30 |
31 | {chrome.i18n.getMessage("CasualModeDescription").split("\n").map((line, index) => ( 32 |
{line}
33 | ))} 34 | 35 |
36 | 40 | 44 | 45 |
46 |
47 | 48 |
49 | 58 |
59 | 60 |
61 | 69 |
70 |
71 | ); 72 | }; -------------------------------------------------------------------------------- /src/submission/ThumbnailDrawerComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ThumbnailType } from "./ThumbnailComponent"; 3 | import { VideoID } from "../../maze-utils/src/video"; 4 | import { ThumbnailSubmission } from "../thumbnails/thumbnailData"; 5 | import { ThumbnailSelectionComponent } from "./ThumbnailSelectionComponent"; 6 | 7 | export interface ThumbnailDrawerComponentProps { 8 | video: HTMLVideoElement; 9 | videoId: VideoID; 10 | existingSubmissions: RenderedThumbnailSubmission[]; 11 | selectedThumbnailIndex: number; 12 | upvotedThumbnailIndex: number; 13 | onSelect: (submission: ThumbnailSubmission, index: number) => void; 14 | onUpvote: (index: number) => void; 15 | actAsVip: boolean; 16 | } 17 | 18 | interface NoTimeRenderedThumbnailSubmission { 19 | type: ThumbnailType.CurrentTime | ThumbnailType.Original; 20 | } 21 | 22 | interface TimeRenderedThumbnailSubmission { 23 | timestamp: number; 24 | type: ThumbnailType.SpecifiedTime; 25 | } 26 | 27 | export type RenderedThumbnailSubmission = (NoTimeRenderedThumbnailSubmission | TimeRenderedThumbnailSubmission) & { 28 | votable: boolean; 29 | locked: boolean; 30 | }; 31 | 32 | export const ThumbnailDrawerComponent = (props: ThumbnailDrawerComponentProps) => { 33 | return ( 34 | <> 35 | {getThumbnails(props, props.selectedThumbnailIndex)} 36 | 37 | ); 38 | }; 39 | 40 | function getThumbnails(props: ThumbnailDrawerComponentProps, 41 | selectedThumbnail: number): JSX.Element[] { 42 | const thumbnails: JSX.Element[] = []; 43 | const renderCount = props.existingSubmissions.length; 44 | for (let i = 0; i < renderCount; i++) { 45 | const time = props.existingSubmissions[i].type === ThumbnailType.SpecifiedTime ? 46 | (props.existingSubmissions[i] as TimeRenderedThumbnailSubmission).timestamp : undefined; 47 | 48 | thumbnails.push( 49 | { 54 | props.onSelect(submission, i); 55 | }} 56 | onUpvote={() => { 57 | props.onUpvote(i); 58 | }} 59 | type={props.existingSubmissions[i].type} 60 | videoID={props.videoId} 61 | time={time} 62 | votable={props.existingSubmissions[i].votable} 63 | locked={props.existingSubmissions[i].locked} 64 | actAsVip={props.actAsVip} 65 | key={time ? `T${time}` : `I${i}`} 66 | > 67 | ); 68 | } 69 | 70 | return thumbnails; 71 | } -------------------------------------------------------------------------------- /src/submission/TitleDrawerComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { TitleComponent } from "./TitleComponent"; 3 | import { VideoID } from "../../maze-utils/src/video"; 4 | 5 | export interface TitleDrawerComponentProps { 6 | existingSubmissions: RenderedTitleSubmission[]; 7 | onSelectOrUpdate: (title: RenderedTitleSubmission, oldTitle: string, index: number) => void; 8 | onDeselect: (index: number) => void; 9 | onUpvote: (index: number) => void; 10 | selectedTitleIndex: number; 11 | upvotedTitleIndex: number; 12 | actAsVip: boolean; 13 | videoID: VideoID; 14 | } 15 | 16 | export interface RenderedTitleSubmission { 17 | title: string; 18 | votable: boolean; 19 | original: boolean; 20 | locked: boolean; 21 | } 22 | 23 | export const TitleDrawerComponent = (props: TitleDrawerComponentProps) => { 24 | return ( 25 | <> 26 | {getTitles(props, props.selectedTitleIndex)} 27 | 28 | ); 29 | }; 30 | 31 | function getTitles(props: TitleDrawerComponentProps, 32 | selectedTitle: number): JSX.Element[] { 33 | const titles: JSX.Element[] = []; 34 | for (let i = 0; i < props.existingSubmissions.length; i++) { 35 | titles.push( 36 | { 40 | props.onSelectOrUpdate({ 41 | ...props.existingSubmissions[i], 42 | title 43 | }, oldTitle, i); 44 | }} 45 | onDeselect={() => { 46 | props.onDeselect(i); 47 | }} 48 | onUpvote={() => { 49 | props.onUpvote(i); 50 | }} 51 | actAsVip={props.actAsVip} 52 | key={i} 53 | submission={props.existingSubmissions[i]} 54 | videoID={props.videoID} 55 | > 56 | ); 57 | } 58 | 59 | return titles; 60 | } -------------------------------------------------------------------------------- /src/submission/autoWarning.ts: -------------------------------------------------------------------------------- 1 | import { objectToURI } from "../../maze-utils/src"; 2 | import { getHash } from "../../maze-utils/src/hash"; 3 | import Config from "../config/config"; 4 | import { getCurrentPageTitle } from "../titles/titleData"; 5 | import { cleanEmojis, cleanFancyText, cleanPunctuation, isWordCustomCapitalization } from "../titles/titleFormatter"; 6 | import { sendRequestToServer } from "../utils/requests"; 7 | import { Tooltip } from "../utils/tooltip"; 8 | import { ChatDisplayName, getChatDisplayName } from "./SubmissionComponent"; 9 | 10 | interface AutoWarningCheck { 11 | check: (title: string, originalTitle: string) => { 12 | found: boolean; 13 | match?: string | null; 14 | }; 15 | error: string; 16 | id: string; 17 | } 18 | 19 | let activeTooltip: Tooltip | null = null; 20 | let currentWarningId: string | null = null; 21 | let timeout: NodeJS.Timeout | null = null; 22 | 23 | const shownWarnings: string[] = []; 24 | const autoWarningChecks: AutoWarningCheck[] = [ 25 | { 26 | error: chrome.i18n.getMessage("DeArrowStartLowerCaseWarning"), 27 | check: (title) => { 28 | return { 29 | found: !!title.match(/^\p{Ll}\S+ \S+ \S+/u) && !isWordCustomCapitalization(title.split(" ")[0]) 30 | }; 31 | }, 32 | id: "startLowerCase" 33 | }, 34 | { 35 | error: chrome.i18n.getMessage("DeArrowDiscussingWarning"), 36 | check: (title) => { 37 | const match = title.match(/^(discussing|explaining|talking about|summarizing) .\S+ .\S+/i)?.[1]; 38 | return { 39 | found: !!match, 40 | match, 41 | }; 42 | }, 43 | id: "discussing" 44 | }, { 45 | error: chrome.i18n.getMessage("DeArrowEndWithPeriodWarning"), 46 | check: (title) => { 47 | return { 48 | found: !!title.match(/\.$/u) 49 | }; 50 | }, 51 | id: "endWithPeriod" 52 | }, { 53 | error: chrome.i18n.getMessage("DeArrowClickbaitWarning"), 54 | check: (title, originalTitle) => { 55 | const regex = /clickbait|fake news|fake video|boring|yapping|yap|worth your time/i; 56 | const match = title.match(regex)?.[0]; 57 | const found = !!title.match(regex) && !originalTitle.match(regex); 58 | 59 | return { 60 | found, 61 | match: found ? match : null, 62 | }; 63 | }, 64 | id: "clickbait" 65 | }, { 66 | error: chrome.i18n.getMessage("DeArrowAddingAnswerWarning"), 67 | check: (title, originalTitle) => { 68 | // Only if ends with ? or ... and then optionally more symbols 69 | const cleaned = cleanPunctuation(cleanFancyText(cleanEmojis(originalTitle.toLowerCase()))); 70 | return { 71 | found: title.toLowerCase().startsWith(cleaned) 72 | && !!originalTitle.match(/(\?|\.\.\.)[^\p{L}]*$/u) 73 | && title.trim().length !== cleaned.trim().length 74 | }; 75 | }, 76 | id: "addingAnswer" 77 | }, { 78 | error: chrome.i18n.getMessage("DeArrowKeepingBadOriginalWarning"), 79 | check: (title, originalTitle) => { 80 | const regex = /massive problem|you need|insane|crazy|you won't believe this/i; 81 | const match = title.match(regex)?.[0]; 82 | const found = !!title.match(regex) && !!originalTitle.match(regex); 83 | 84 | return { 85 | found, 86 | match: found ? match : null, 87 | }; 88 | }, 89 | id: "keepingBadOriginal" 90 | }, { 91 | error: chrome.i18n.getMessage("DeArrowEmojiWarning"), 92 | check: (title) => { 93 | return { 94 | found: cleanEmojis(title.trim()) !== title.trim() 95 | }; 96 | }, 97 | id: "emoji" 98 | } 99 | ]; 100 | 101 | export function getAutoWarning(title: string, originalTitle: string, ignoreShown = false): { id: string; text: string } | null { 102 | for (const check of autoWarningChecks) { 103 | const { found, match } = check.check(title, originalTitle); 104 | if (found && (ignoreShown || !shownWarnings.includes(check.id))) { 105 | return { 106 | id: check.id, 107 | text: check.error + (match ? `\n\n${chrome.i18n.getMessage("DetectedWord")}${match}` : "") 108 | }; 109 | } 110 | } 111 | 112 | return null; 113 | } 114 | 115 | export function showAutoWarningIfRequired(title: string, element: HTMLElement): void { 116 | // Wait until some time after typing stops 117 | if (timeout) { 118 | clearTimeout(timeout); 119 | } 120 | 121 | timeout = setTimeout(() => { 122 | showAutoWarningIfRequiredInternal(title, element); 123 | }, 500) 124 | } 125 | 126 | function showAutoWarningIfRequiredInternal(title: string, element: HTMLElement): void { 127 | timeout = null; 128 | 129 | const originalTitle = getCurrentPageTitle() || ""; 130 | const warning = getAutoWarning(title, originalTitle); 131 | if (warning && warning.id !== currentWarningId) { 132 | activeTooltip?.close(); 133 | 134 | currentWarningId = warning.id; 135 | activeTooltip = new Tooltip({ 136 | textBoxes: warning.text.split("\n"), 137 | referenceNode: element.parentElement!, 138 | prependElement: element, 139 | positionRealtive: false, 140 | containerAbsolute: true, 141 | bottomOffset: "35px", 142 | rightOffset: "0", 143 | leftOffset: "0", 144 | displayTriangle: true, 145 | extraClass: "centeredSBTriangle", 146 | center: true, 147 | showGotIt: false, 148 | buttonsAtBottom: true, 149 | textBoxMaxHeight: "350px", 150 | opacity: 1, 151 | buttons: [{ 152 | name: chrome.i18n.getMessage("GotIt"), 153 | listener: () => { 154 | shownWarnings.push(warning.id); 155 | activeTooltip?.close(); 156 | activeTooltip = null; 157 | currentWarningId = null; 158 | } 159 | }, { 160 | name: chrome.i18n.getMessage("questionButton"), 161 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 162 | listener: async () => { 163 | const publicUserID = await getHash(Config.config!.userID!); 164 | 165 | const values = ["userName"]; 166 | const result = await sendRequestToServer("GET", "/api/userInfo", { 167 | publicUserID: publicUserID, 168 | values 169 | }); 170 | 171 | let name: ChatDisplayName | null = null; 172 | 173 | if (result.ok) { 174 | const userInfo = JSON.parse(result.responseText); 175 | name = { 176 | publicUserID, 177 | username: userInfo.userName 178 | }; 179 | } 180 | 181 | window.open(`https://chat.sponsor.ajay.app/#${objectToURI("", { 182 | displayName: getChatDisplayName(name), 183 | customDescription: `${chrome.i18n.getMessage("chatboxDescription")}\n\nhttps://discord.gg/SponsorBlock\nhttps://matrix.to/#/#sponsor:ajay.app?via=matrix.org`, 184 | bigDescription: true 185 | }, false)}`); 186 | } 187 | }], 188 | }); 189 | } else { 190 | activeTooltip?.close(); 191 | activeTooltip = null; 192 | currentWarningId = null; 193 | } 194 | } 195 | 196 | export function resetShownWarnings(): void { 197 | shownWarnings.length = 0; 198 | activeTooltip?.close(); 199 | activeTooltip = null; 200 | currentWarningId = null; 201 | } 202 | 203 | export function isAutoWarningShown(): boolean { 204 | return !!activeTooltip; 205 | } -------------------------------------------------------------------------------- /src/submission/casualVote.const.ts: -------------------------------------------------------------------------------- 1 | export interface CasualVoteCategory { 2 | id: string; 3 | key: string; 4 | } 5 | 6 | export const casualVoteCategories: CasualVoteCategory[] = [{ 7 | id: "funny", 8 | key: "dearrow_category_funny" 9 | }, { 10 | id: "creative", 11 | key: "dearrow_category_creative" 12 | }, { 13 | id: "clever", 14 | key: "dearrow_category_clever" 15 | }, { 16 | id: "descriptive", 17 | key: "dearrow_category_descriptive" 18 | }, { 19 | id: "other", 20 | key: "dearrow_category_other" 21 | }]; 22 | 23 | export const casualWikiLink = "https://wiki.sponsor.ajay.app/w/DeArrow/Casual_mode"; -------------------------------------------------------------------------------- /src/submission/casualVoteButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CasualVoteInfo, replaceCurrentVideoBranding } from "../videoBranding/videoBranding"; 3 | import { getVideoID, getYouTubeVideoID } from "../../maze-utils/src/video"; 4 | import { logError } from "../utils/logger"; 5 | import { submitVideoCasualVote } from "../dataFetching"; 6 | import Config from "../config/config"; 7 | import { closeGuidelineChecklist } from "./SubmissionChecklist"; 8 | import { TitleButton } from "./titleButton"; 9 | import { CasualVoteComponent } from "./CasualVoteComponent"; 10 | import { CasualVoteOnboardingComponent } from "./CasualVoteOnboardingComponent"; 11 | import { shouldStoreVotes } from "../utils/configUtils"; 12 | 13 | const casualVoteButtonIcon = ` 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | `; 25 | 26 | export class CasualVoteButton extends TitleButton { 27 | existingVotes: CasualVoteInfo[]; 28 | 29 | constructor() { 30 | super(casualVoteButtonIcon, chrome.i18n.getMessage("OpenCasualVoteMenu"), "cbCasualVoteButton", true); 31 | this.existingVotes = []; 32 | } 33 | 34 | close(): void { 35 | closeGuidelineChecklist(); 36 | 37 | super.close(); 38 | this.updateIcon(); 39 | } 40 | 41 | clearExistingVotes(): void { 42 | this.existingVotes = []; 43 | } 44 | 45 | setExistingVotes(existingVotes: CasualVoteInfo[]): void { 46 | this.existingVotes = existingVotes; 47 | this.render(); 48 | } 49 | 50 | render(): void { 51 | if (this.root) { 52 | if (shouldShowCasualOnboarding()) { 53 | this.root?.render( this.close()} />); 54 | 55 | Config.config!.showInfoAboutCasualMode = false; 56 | } else { 57 | this.root?.render( this.submitPressed(categories, downvote)} 61 | />); 62 | } 63 | } 64 | } 65 | 66 | private async submitPressed(categories: string[], downvote: boolean): Promise { 67 | if (getVideoID() !== getYouTubeVideoID()) { 68 | alert(chrome.i18n.getMessage("videoIDWrongWhenSubmittingError")); 69 | return false; 70 | } 71 | 72 | const result = await submitVideoCasualVote(getVideoID()!, categories, downvote); 73 | 74 | if (result && result.ok) { 75 | this.close(); 76 | 77 | if (shouldStoreVotes()) { 78 | const unsubmitted = Config.local!.unsubmitted[getVideoID()!] ??= { 79 | thumbnails: [], 80 | titles: [] 81 | }; 82 | 83 | unsubmitted.casual = !downvote; 84 | Config.forceLocalUpdate("unsubmitted"); 85 | } 86 | 87 | setTimeout(() => replaceCurrentVideoBranding().catch(logError), 1100); 88 | 89 | return true; 90 | } else { 91 | const text = result.responseText; 92 | 93 | if (text.includes("")) { 94 | alert(chrome.i18n.getMessage("502")); 95 | } else { 96 | alert(text); 97 | } 98 | 99 | return false; 100 | } 101 | } 102 | 103 | updateIcon(): void { 104 | if (this.button) { 105 | if (Config.config!.extensionEnabled && 106 | (Config.config!.casualMode || shouldShowCasualOnboarding())) { 107 | this.button.style.removeProperty("display"); 108 | 109 | super.updateIcon(); 110 | } else { 111 | this.button.style.display = "none"; 112 | } 113 | } 114 | } 115 | } 116 | 117 | function shouldShowCasualOnboarding(): boolean { 118 | // Check if userID not blank to ensure sync config is working 119 | return !Config.config!.casualMode && Config.config!.showInfoAboutCasualMode && !!Config.config!.userID; 120 | } -------------------------------------------------------------------------------- /src/submission/submitButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { BrandingResult, replaceCurrentVideoBranding } from "../videoBranding/videoBranding"; 3 | import { SubmissionComponent } from "./SubmissionComponent"; 4 | import { getVideo, getVideoID, getYouTubeVideoID, isCurrentTimeWrong } from "../../maze-utils/src/video"; 5 | import { logError } from "../utils/logger"; 6 | import { TitleSubmission } from "../titles/titleData"; 7 | import { ThumbnailSubmission } from "../thumbnails/thumbnailData"; 8 | import { queueThumbnailCacheRequest, submitVideoBranding } from "../dataFetching"; 9 | import Config from "../config/config"; 10 | import { shouldStoreVotes } from "../utils/configUtils"; 11 | import { closeGuidelineChecklist, confirmGuidelines } from "./SubmissionChecklist"; 12 | import { TitleButton } from "./titleButton"; 13 | 14 | const submitButtonIcon = ` 15 | 16 | 17 | `; 18 | 19 | export class SubmitButton extends TitleButton { 20 | submissions: BrandingResult; 21 | 22 | constructor() { 23 | super(submitButtonIcon, chrome.i18n.getMessage("OpenSubmissionMenu"), "cbSubmitButton"); 24 | this.submissions = { 25 | thumbnails: [], 26 | titles: [], 27 | randomTime: null, 28 | videoDuration: null, 29 | casualVotes: [] 30 | }; 31 | } 32 | 33 | close(): void { 34 | closeGuidelineChecklist(); 35 | 36 | super.close(); 37 | } 38 | 39 | clearSubmissions(): void { 40 | this.setSubmissions({ 41 | thumbnails: [], 42 | titles: [], 43 | randomTime: null, 44 | videoDuration: null, 45 | casualVotes: [] 46 | }); 47 | } 48 | 49 | setSubmissions(submissions: BrandingResult): void { 50 | this.submissions = submissions; 51 | this.render(); 52 | } 53 | 54 | render(): void { 55 | if (this.root) { 56 | this.root?.render( this.submitPressed(title, thumbnail, actAsVip)} 61 | />); 62 | } 63 | } 64 | 65 | private async submitPressed(title: TitleSubmission | null, thumbnail: ThumbnailSubmission | null, actAsVip: boolean): Promise { 66 | if (title) { 67 | title.title = title.title.trim(); 68 | 69 | if (title.title.length === 0) { 70 | title = null; 71 | } 72 | } 73 | 74 | if (getVideoID() !== getYouTubeVideoID()) { 75 | alert(chrome.i18n.getMessage("videoIDWrongWhenSubmittingError")); 76 | return false; 77 | } 78 | 79 | if (isCurrentTimeWrong()) { 80 | alert(chrome.i18n.getMessage("submissionFailedServerSideAds")); 81 | return false; 82 | } 83 | 84 | if (!await confirmGuidelines(title)) { 85 | return false; 86 | } 87 | 88 | const result = await submitVideoBranding(getVideoID()!, title, thumbnail, false, actAsVip); 89 | 90 | if (result && result.ok) { 91 | this.close(); 92 | 93 | // Try to get this generated by the server 94 | if (thumbnail && !thumbnail.original) { 95 | queueThumbnailCacheRequest(getVideoID()!, thumbnail.timestamp, undefined, false, true); 96 | } 97 | 98 | // Set the unsubmitted as selected 99 | if (shouldStoreVotes()) { 100 | const unsubmitted = Config.local!.unsubmitted[getVideoID()!] ??= { 101 | titles: [], 102 | thumbnails: [] 103 | }; 104 | 105 | unsubmitted.titles.forEach((t) => t.selected = false); 106 | unsubmitted.thumbnails.forEach((t) => t.selected = false); 107 | 108 | if (title) { 109 | const unsubmittedTitle = unsubmitted.titles.find((t) => t.title.trim() === title!.title); 110 | if (unsubmittedTitle) { 111 | unsubmittedTitle.selected = true; 112 | } else { 113 | unsubmitted.titles.push({ 114 | title: title.title, 115 | selected: true 116 | }); 117 | } 118 | 119 | unsubmitted.titles = unsubmitted.titles.filter((t) => t.selected); 120 | } 121 | 122 | if (thumbnail) { 123 | if (thumbnail.original && !unsubmitted.thumbnails.find((t) => t.original)) { 124 | unsubmitted.thumbnails.push({ 125 | original: true, 126 | selected: true 127 | }); 128 | } else { 129 | const unsubmittedThumbnail = unsubmitted.thumbnails.find((t) => (t.original && thumbnail.original) 130 | || (!t.original && !thumbnail.original && t.timestamp === thumbnail.timestamp)) 131 | if (unsubmittedThumbnail) { 132 | unsubmittedThumbnail.selected = true; 133 | } else { 134 | if (thumbnail.original) { 135 | unsubmitted.thumbnails.push({ 136 | original: true, 137 | selected: true 138 | }); 139 | } else { 140 | unsubmitted.thumbnails.push({ 141 | original: false, 142 | timestamp: thumbnail.timestamp, 143 | selected: true 144 | }); 145 | } 146 | } 147 | } 148 | 149 | unsubmitted.thumbnails = unsubmitted.thumbnails.filter((t) => t.selected); 150 | } 151 | } else { 152 | delete Config.local!.unsubmitted[getVideoID()!]; 153 | } 154 | 155 | Config.forceLocalUpdate("unsubmitted"); 156 | 157 | setTimeout(() => replaceCurrentVideoBranding().catch(logError), 1100); 158 | 159 | return true; 160 | } else { 161 | const text = result.responseText; 162 | 163 | if (text.includes("")) { 164 | alert(chrome.i18n.getMessage("502")); 165 | } else { 166 | alert(text); 167 | } 168 | 169 | return false; 170 | } 171 | } 172 | } -------------------------------------------------------------------------------- /src/svgIcons/addIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface AddIconProps { 4 | fill?: string; 5 | className?: string; 6 | width?: string; 7 | height?: string; 8 | onClick?: () => void; 9 | } 10 | 11 | const AddIcon = ({ 12 | fill = "#ffffff", 13 | className = "", 14 | width = "20", 15 | height = "20", 16 | onClick 17 | }: AddIconProps): JSX.Element => ( 18 | 26 | 28 | 29 | ); 30 | 31 | export default AddIcon; -------------------------------------------------------------------------------- /src/svgIcons/checkIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface CheckIconProps { 4 | id?: string; 5 | style?: React.CSSProperties; 6 | className?: string; 7 | onClick?: () => void; 8 | } 9 | 10 | const CheckIcon = ({ 11 | id = "", 12 | className = "", 13 | style = {}, 14 | onClick 15 | }: CheckIconProps): JSX.Element => ( 16 | 23 | 24 | 25 | ); 26 | 27 | export default CheckIcon; -------------------------------------------------------------------------------- /src/svgIcons/clipboardIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface ClipboardIconProps { 4 | id?: string; 5 | style?: React.CSSProperties; 6 | className?: string; 7 | onClick?: () => void; 8 | } 9 | 10 | const ClipboardIcon = ({ 11 | id = "", 12 | className = "", 13 | style = {}, 14 | onClick 15 | }: ClipboardIconProps): JSX.Element => ( 16 | 23 | 24 | 25 | 26 | ); 27 | 28 | export default ClipboardIcon; -------------------------------------------------------------------------------- /src/svgIcons/cursorIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface CursorIconProps { 4 | id?: string; 5 | style?: React.CSSProperties; 6 | className?: string; 7 | onClick?: () => void; 8 | } 9 | 10 | const CursorIcon = ({ 11 | id = "", 12 | className = "", 13 | style = {}, 14 | onClick 15 | }: CursorIconProps): JSX.Element => ( 16 | 23 | 24 | 25 | ); 26 | 27 | export default CursorIcon; -------------------------------------------------------------------------------- /src/svgIcons/downvoteIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface DownvoteIconProps { 4 | locked?: boolean; 5 | selected?: boolean; 6 | className?: string; 7 | width?: string; 8 | height?: string; 9 | style?: React.CSSProperties; 10 | onClick?: () => void; 11 | } 12 | 13 | const DownvoteIcon = ({ 14 | locked = false, 15 | selected = false, 16 | className = "", 17 | width = "16", 18 | height = "16", 19 | style = {}, 20 | onClick 21 | }: DownvoteIconProps): JSX.Element => ( 22 | 30 | 34 | 38 | 39 | ); 40 | 41 | export default DownvoteIcon; -------------------------------------------------------------------------------- /src/svgIcons/exclamationIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface ExclamationIconProps { 4 | id?: string; 5 | style?: React.CSSProperties; 6 | className?: string; 7 | onClick?: () => void; 8 | } 9 | 10 | const ExclamationIcon = ({ 11 | id = "", 12 | className = "", 13 | style = {}, 14 | onClick 15 | }: ExclamationIconProps): JSX.Element => ( 16 | 23 | 24 | 25 | ); 26 | 27 | export default ExclamationIcon; -------------------------------------------------------------------------------- /src/svgIcons/fontIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface FontIconProps { 4 | id?: string; 5 | style?: React.CSSProperties; 6 | className?: string; 7 | onClick?: () => void; 8 | } 9 | 10 | const FontIcon = ({ 11 | id = "", 12 | className = "", 13 | style = {}, 14 | onClick 15 | }: FontIconProps): JSX.Element => ( 16 | 23 | 24 | 25 | ); 26 | 27 | export default FontIcon; -------------------------------------------------------------------------------- /src/svgIcons/pencilIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface PencilIconProps { 4 | id?: string; 5 | style?: React.CSSProperties; 6 | className?: string; 7 | onClick?: () => void; 8 | } 9 | 10 | const PencilIcon = ({ 11 | id = "", 12 | className = "", 13 | style = {}, 14 | onClick 15 | }: PencilIconProps): JSX.Element => ( 16 | 23 | 24 | 25 | ); 26 | 27 | export default PencilIcon; -------------------------------------------------------------------------------- /src/svgIcons/personIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface PersonIconProps { 4 | id?: string; 5 | style?: React.CSSProperties; 6 | className?: string; 7 | onClick?: () => void; 8 | } 9 | 10 | const PersonIcon = ({ 11 | id = "", 12 | className = "", 13 | style = {}, 14 | onClick 15 | }: PersonIconProps): JSX.Element => ( 16 | 23 | 24 | 25 | ); 26 | 27 | export default PersonIcon; -------------------------------------------------------------------------------- /src/svgIcons/questionIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface QuestionIconProps { 4 | id?: string; 5 | style?: React.CSSProperties; 6 | className?: string; 7 | onClick?: () => void; 8 | } 9 | 10 | const QuestionIcon = ({ 11 | id = "", 12 | className = "", 13 | style = {}, 14 | onClick 15 | }: QuestionIconProps): JSX.Element => ( 16 | 23 | 24 | 25 | ); 26 | 27 | export default QuestionIcon; -------------------------------------------------------------------------------- /src/svgIcons/resetIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface AddIconProps { 4 | style?: React.CSSProperties; 5 | className?: string; 6 | onClick?: () => void; 7 | } 8 | 9 | const ResetIcon = ({ 10 | className = "", 11 | style = {}, 12 | onClick 13 | }: AddIconProps): JSX.Element => ( 14 | 20 | 25 | 29 | 30 | ); 31 | 32 | export default ResetIcon; -------------------------------------------------------------------------------- /src/svgIcons/upvoteIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export interface UpvoteIconProps { 4 | selected?: boolean; 5 | fill?: string; 6 | className?: string; 7 | width?: string; 8 | height?: string; 9 | style?: React.CSSProperties; 10 | onClick?: () => void; 11 | } 12 | 13 | const UpvoteIcon = ({ 14 | selected = false, 15 | className = "", 16 | width = "16", 17 | height = "16", 18 | style = {}, 19 | onClick 20 | }: UpvoteIconProps): JSX.Element => ( 21 | 29 | 33 | 37 | 38 | ); 39 | 40 | export default UpvoteIcon; -------------------------------------------------------------------------------- /src/thumbnails/thumbnailDataCache.ts: -------------------------------------------------------------------------------- 1 | import { ChannelID } from "../../maze-utils/src/video"; 2 | import { VideoID } from "../../maze-utils/src/video"; 3 | import { DataCache } from "../../maze-utils/src/cache"; 4 | 5 | export interface PlaybackUrl { 6 | url: string; 7 | width: number; 8 | height: number; 9 | } 10 | 11 | interface ThumbnailVideoBase { 12 | video: HTMLVideoElement | null; 13 | width: number; 14 | height: number; 15 | onReady: Array<(video: RenderedThumbnailVideo | null) => void>; 16 | timestamp: number; 17 | } 18 | 19 | export type RenderedThumbnailVideo = ThumbnailVideoBase & { 20 | blob: Blob; 21 | rendered: true; 22 | fromThumbnailCache: boolean; 23 | } 24 | 25 | export type ThumbnailVideo = RenderedThumbnailVideo | ThumbnailVideoBase & { 26 | rendered: false; 27 | }; 28 | 29 | export interface FailInfo { 30 | timestamp: number; 31 | onReady: Array<(video: RenderedThumbnailVideo | null) => void>; 32 | } 33 | 34 | interface VideoMetadata { 35 | playbackUrls: PlaybackUrl[]; 36 | duration: number | null; 37 | channelID: ChannelID | null; 38 | author: string | null; 39 | isLive: boolean | null; 40 | isUpcoming: boolean | null; 41 | } 42 | 43 | export interface ThumbnailData { 44 | video: ThumbnailVideo[]; 45 | metadata: VideoMetadata; 46 | failures: FailInfo[]; 47 | thumbnailCachesFailed: Set; 48 | } 49 | 50 | export const thumbnailDataCache = new DataCache(() => ({ 51 | video: [], 52 | metadata: { 53 | playbackUrls: [], 54 | duration: null, 55 | channelID: null, 56 | author: null, 57 | isLive: false, 58 | isUpcoming: false 59 | }, 60 | failures: [], 61 | thumbnailCachesFailed: new Set() 62 | })); 63 | 64 | export interface ChannelData { 65 | avatarUrl: string | null; 66 | } 67 | 68 | export const channelInfoCache = new DataCache(() => ({ 69 | avatarUrl: null 70 | })); -------------------------------------------------------------------------------- /src/titles/pageTitleHandler.ts: -------------------------------------------------------------------------------- 1 | import { addCleanupListener } from "../../maze-utils/src/cleanup"; 2 | import { onMobile } from "../../maze-utils/src/pageInfo"; 3 | import { setMediaSessionTitle } from "../videoBranding/mediaSessionHandler"; 4 | 5 | let targetTitle: string | null = null; 6 | let targetFullTitle = ""; 7 | 8 | 9 | export function setCurrentVideoTitle(title: string) { 10 | setMediaSessionTitle(title); 11 | 12 | if (title === targetTitle) return; 13 | 14 | changePageTitleNow(title); 15 | targetTitle = title; 16 | } 17 | 18 | function changePageTitleNow(title: string) { 19 | if (!onMobile()) { 20 | const app = document.querySelector("ytd-app"); 21 | if (app) { 22 | app.dispatchEvent(new CustomEvent("yt-update-title", { detail: title })); 23 | } 24 | } else { 25 | const withoutNotificationValue = document.title.replace(/^\(\d+\)/, ""); 26 | const withoutEndValue = withoutNotificationValue.replace(/-[^-]+$/, "").trim(); 27 | const titleSections = withoutEndValue !== "" 28 | ? document.title.split(withoutEndValue) 29 | : ["", document.title]; 30 | 31 | targetFullTitle = [titleSections[0], title, titleSections[1]] 32 | .map((s) => s.trim()) 33 | .join(" ") 34 | .trim(); 35 | document.title = targetFullTitle; 36 | 37 | setupTitleChangeListener(); 38 | } 39 | } 40 | 41 | export function setupPageTitleHandler() { 42 | if (onMobile()) { 43 | const navigateStartListener = () => { 44 | targetTitle = null; 45 | }; 46 | window.addEventListener("state-navigatestart", navigateStartListener); 47 | 48 | addCleanupListener(() => { 49 | window.removeEventListener("state-navigatestart", navigateStartListener); 50 | }); 51 | } 52 | } 53 | 54 | let titleChangeObserver: MutationObserver | null = null; 55 | /** 56 | * Only used on mobile 57 | */ 58 | function setupTitleChangeListener() { 59 | if (titleChangeObserver) return; 60 | const titleElement = document.querySelector("title"); 61 | if (titleElement) { 62 | titleChangeObserver = new MutationObserver(() => { 63 | if (targetTitle && document.title !== targetFullTitle) { 64 | document.title = targetFullTitle; 65 | } 66 | }); 67 | 68 | titleChangeObserver.observe(titleElement, { 69 | childList: true, 70 | subtree: true, 71 | characterData: true 72 | }); 73 | } 74 | 75 | addCleanupListener(() => { 76 | titleChangeObserver?.disconnect?.(); 77 | }); 78 | } -------------------------------------------------------------------------------- /src/titles/titleAntiTranslateData.ts: -------------------------------------------------------------------------------- 1 | import { sendRequestToCustomServer } from "../../maze-utils/src/background-request-proxy"; 2 | import { DataCache } from "../../maze-utils/src/cache"; 3 | import { VideoID } from "../../maze-utils/src/video"; 4 | 5 | interface AntiTranslateData { 6 | title: string; 7 | } 8 | 9 | const titleAntiTranslateCache = new DataCache(() => ({ 10 | title: "" 11 | })); 12 | 13 | export async function getAntiTranslatedTitle(videoID: VideoID): Promise { 14 | const cache = titleAntiTranslateCache.getFromCache(videoID); 15 | 16 | if (cache) { 17 | titleAntiTranslateCache.cacheUsed(videoID); 18 | return cache.title; 19 | } 20 | 21 | const title = await getAntiTranslatedTitleFromServer(videoID); 22 | if (title) { 23 | titleAntiTranslateCache.setupCache(videoID).title = title; 24 | } 25 | 26 | return title; 27 | } 28 | 29 | async function getAntiTranslatedTitleFromServer(videoID: VideoID): Promise { 30 | const response = await sendRequestToCustomServer("GET", `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoID}`); 31 | 32 | if (response.ok) { 33 | try { 34 | const json = JSON.parse(response.responseText); 35 | if (json.title) { 36 | return json.title; 37 | } 38 | } catch (e) {} // eslint-disable-line no-empty 39 | } 40 | 41 | return null; 42 | } -------------------------------------------------------------------------------- /src/titles/titleData.ts: -------------------------------------------------------------------------------- 1 | import { BrandingUUID } from "../videoBranding/videoBranding"; 2 | import { getYouTubeTitleNode } from "../../maze-utils/src/elements" 3 | 4 | export interface TitleSubmission { 5 | title: string; 6 | original: boolean; 7 | } 8 | 9 | export interface TitleResult extends TitleSubmission { 10 | votes: number; 11 | locked: boolean; 12 | UUID: BrandingUUID; 13 | } 14 | 15 | export function getCurrentPageTitle(): string | null { 16 | const titleNode = getYouTubeTitleNode(); 17 | 18 | if (titleNode) { 19 | const formattedText = titleNode.querySelector("yt-formatted-string.ytd-watch-metadata, .slim-video-information-title .yt-core-attributed-string:not(cbCustomTitle)") as HTMLElement; 20 | if (formattedText) { 21 | return formattedText.innerText; 22 | } else { 23 | for (const elem of titleNode.children) { 24 | if (elem.nodeName === "#text" && elem.nodeValue 25 | && elem.nodeValue.trim() !== "") { 26 | return elem.nodeValue; 27 | } 28 | } 29 | } 30 | } 31 | 32 | return null; 33 | } -------------------------------------------------------------------------------- /src/types/messaging.ts: -------------------------------------------------------------------------------- 1 | export interface BackgroundToContentMessage { 2 | message: "update"; 3 | } -------------------------------------------------------------------------------- /src/unactivatedWarning.ts: -------------------------------------------------------------------------------- 1 | import { isFirefoxOrSafari } from "../maze-utils/src"; 2 | import { cleanPage } from "./utils/pageCleaner"; 3 | 4 | if (isFirefoxOrSafari()) { 5 | cleanPage(); 6 | 7 | const possibleHiddenElements = document.querySelectorAll("img"); 8 | for (const element of possibleHiddenElements) { 9 | if (element.style.display === "none") { 10 | element.style.removeProperty("display"); 11 | } 12 | } 13 | } 14 | 15 | let warningElement: HTMLElement | null = document.getElementById("dearrow-unactivated-warning"); 16 | closeWarningButton(); 17 | 18 | if (document.hasFocus()) { 19 | displayWarningIfNeeded(); 20 | } else { 21 | window.addEventListener("mousemove", displayWarningIfNeeded, { once: true }); 22 | } 23 | 24 | function displayWarningIfNeeded() { 25 | chrome.storage.sync.get(["activated", "freeTrialStart", "freeTrialEnded", "freeAccessRequestStart"], (v) => { 26 | if (!v.activated && !v.freeTrialStart && !v.freeTrialEnded && !v.freeAccessRequestStart) { 27 | const addWarningElement = () => { 28 | warningElement = document.createElement("div"); 29 | warningElement.id = "dearrow-unactivated-warning"; 30 | warningElement.style.position = "fixed"; 31 | warningElement.style.top = "0"; 32 | warningElement.style.left = "0"; 33 | warningElement.style.right = "0"; 34 | warningElement.style.display = "flex"; 35 | warningElement.style.alignItems = "center"; 36 | warningElement.style.justifyContent = "center"; 37 | warningElement.style.flexDirection = "column"; 38 | warningElement.style.backgroundColor = "#171717"; 39 | warningElement.style.zIndex = "10000000"; 40 | warningElement.style.padding = "20px"; 41 | 42 | const icon = document.createElement("img"); 43 | icon.src = chrome.runtime.getURL("icons/logo.svg"); 44 | icon.style.width = "30px"; 45 | 46 | const text = document.createElement("span"); 47 | text.innerText = chrome.i18n.getMessage("DeArrowNotActivated"); 48 | text.style.color = "white"; 49 | text.style.fontSize = "17px"; 50 | text.style.padding = "20px"; 51 | 52 | const activateButton = createButton(chrome.i18n.getMessage("ActivateDeArrow")); 53 | activateButton.addEventListener("click", () => { 54 | void chrome.runtime.sendMessage({ message: "openPayment" }); 55 | }); 56 | 57 | const closeButton = createButton(chrome.i18n.getMessage("Close")); 58 | closeButton.addEventListener("click", closeWarningButton); 59 | 60 | warningElement.appendChild(icon); 61 | warningElement.appendChild(text); 62 | warningElement.appendChild(activateButton); 63 | warningElement.appendChild(closeButton); 64 | 65 | document.body.appendChild(warningElement); 66 | 67 | chrome.storage.sync.onChanged.addListener((changes) => { 68 | if (changes.activated || changes.freeTrialStart || changes.freeTrialEnded || changes.freeAccessRequestStart) { 69 | closeWarningButton(); 70 | } 71 | }); 72 | }; 73 | 74 | if (document.readyState === "complete") { 75 | addWarningElement(); 76 | } else { 77 | window.addEventListener("DOMContentLoaded", addWarningElement); 78 | } 79 | } 80 | }); 81 | } 82 | 83 | function createButton(text: string): HTMLElement { 84 | const button = document.createElement("div"); 85 | button.innerText = text; 86 | button.style.cursor = "pointer"; 87 | button.style.backgroundColor = "#0e79ca"; 88 | button.style.color = "white"; 89 | button.style.padding = "10px"; 90 | button.style.borderRadius = "5px"; 91 | button.style.fontSize = "14px"; 92 | button.style.width = "max-content"; 93 | button.style.marginBottom = "10px"; 94 | 95 | return button; 96 | } 97 | 98 | function closeWarningButton() { 99 | if (warningElement) { 100 | warningElement.remove(); 101 | warningElement = null; 102 | } 103 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajayyy/DeArrow/f755fa2c2d0975688a5853afb11d08ea0cf98737/src/utils.ts -------------------------------------------------------------------------------- /src/utils/configUtils.ts: -------------------------------------------------------------------------------- 1 | import Config from "../config/config"; 2 | 3 | export function showDonationLink(): boolean { 4 | return navigator.vendor !== "Apple Computer, Inc." && Config.config!.showDonationLink && Config.config!.freeActivation; 5 | } 6 | 7 | export function shouldStoreVotes(): boolean { 8 | return Config.config!.keepUnsubmitted 9 | && (!chrome.extension.inIncognitoContext || Config.config!.keepUnsubmittedInPrivate); 10 | } -------------------------------------------------------------------------------- /src/utils/cssInjector.ts: -------------------------------------------------------------------------------- 1 | import { isFirefoxOrSafari, waitFor } from "../../maze-utils/src"; 2 | import Config from "../config/config"; 3 | import { brandingBoxSelector, watchPageThumbnailSelector } from "../videoBranding/videoBranding"; 4 | import { logError } from "./logger"; 5 | import { getThumbnailElements } from "../../maze-utils/src/thumbnail-selectors"; 6 | import { onMobile } from "../../maze-utils/src/pageInfo"; 7 | 8 | const cssFiles = [ 9 | "content.css", 10 | "shared.css" 11 | ]; 12 | 13 | export function addCssToPage() { 14 | const head = document.getElementsByTagName("head")[0] || document.documentElement; 15 | 16 | // Add css related to hiding branding boxes by default 17 | const titleStyle = document.createElement("style"); 18 | titleStyle.className = "cb-css"; 19 | titleStyle.innerHTML = buildHideTitleCss(); 20 | 21 | const thumbStyle = document.createElement("style"); 22 | thumbStyle.className = "cb-css"; 23 | thumbStyle.innerHTML = buildHideThumbnailCss(); 24 | 25 | head.appendChild(titleStyle); 26 | head.appendChild(thumbStyle); 27 | 28 | const onLoad = async () => { 29 | await waitFor(() => Config.isReady()); 30 | 31 | const head = document.getElementsByTagName("head")[0]; 32 | if (!isFirefoxOrSafari() && Config.config!.invidiousInstances?.includes(new URL(document.URL).host)) { 33 | for (const file of cssFiles) { 34 | const fileref = document.createElement("link"); 35 | fileref.className = "cb-css"; 36 | fileref.rel = "stylesheet"; 37 | fileref.type = "text/css"; 38 | fileref.href = chrome.runtime.getURL(file); 39 | 40 | head.appendChild(fileref); 41 | } 42 | } 43 | 44 | if (onMobile()) { 45 | setTimeout(() => injectMobileCss(), 200); 46 | } 47 | }; 48 | 49 | 50 | if (document.readyState === "complete") { 51 | onLoad().catch(logError); 52 | } else { 53 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 54 | window.addEventListener("DOMContentLoaded", onLoad); 55 | } 56 | 57 | waitFor(() => Config.isReady()).then(() => { 58 | addMaxTitleLinesCssToPage(); 59 | 60 | if (!Config.config!.extensionEnabled || !Config.config!.replaceTitles) { 61 | titleStyle.remove(); 62 | } 63 | 64 | if (!Config.config!.extensionEnabled || !Config.config!.replaceThumbnails) { 65 | thumbStyle.remove(); 66 | } 67 | }).catch(logError); 68 | } 69 | 70 | export function addMaxTitleLinesCssToPage() { 71 | const head = document.getElementsByTagName("head")[0] || document.documentElement; 72 | 73 | const existingStyle = document.querySelector(".cb-title-lines"); 74 | if (existingStyle) { 75 | existingStyle.remove(); 76 | } 77 | 78 | const style = document.createElement("style"); 79 | style.className = "cb-title-lines"; 80 | style.innerHTML = buildMaxLinesTitleCss(); 81 | 82 | head.appendChild(style); 83 | } 84 | 85 | function buildHideThumbnailCss(): string { 86 | const result: string[] = [ 87 | ".ytp-ce-covering-image:not(.cb-visible)", // Endcards 88 | "div.ytp-autonav-endscreen-upnext-thumbnail:not(.cb-visible)", // Autoplay 89 | "div.ytp-videowall-still-image:not(.cb-visible)" // End recommendations 90 | ]; 91 | 92 | const boxesToHide = brandingBoxSelector.split(", ").concat([ 93 | "ytd-video-preview" 94 | ]); 95 | for (const start of boxesToHide) { 96 | const thumbnailTypes = getThumbnailElements(); 97 | 98 | for (const thumbnailType of thumbnailTypes) { 99 | result.push(`${start} ${thumbnailType} img:not(.cb-visible, ytd-moving-thumbnail-renderer img, .cbCustomThumbnailCanvas, .yt-spec-avatar-shape__image, .cbShowOriginalImage)`); 100 | } 101 | } 102 | 103 | result.push(`${watchPageThumbnailSelector} div:not(.cb-visible, .cbLiveCover)`); 104 | 105 | return `${result.join(", ")} { visibility: hidden !important; }\n`; 106 | } 107 | 108 | function buildHideTitleCss(): string { 109 | const result: string[] = []; 110 | for (const start of brandingBoxSelector.split(", ")) { 111 | if (!onMobile()) { 112 | // Fix smaller titles in playlists on search pages from being hidden 113 | // https://github.com/ajayyy/DeArrow/issues/162 114 | const extra = start === "ytd-playlist-renderer" ? " a.ytd-playlist-renderer" : ""; 115 | 116 | result.push(`${start}${extra} #video-title:not(.cbCustomTitle)`); 117 | } else { 118 | result.push(`${start} .media-item-headline .yt-core-attributed-string:not(.cbCustomTitle)`); 119 | } 120 | } 121 | 122 | if (onMobile()) { 123 | result.push(".compact-media-item-headline .yt-core-attributed-string:not(.cbCustomTitle)"); 124 | } 125 | 126 | return `${result.join(", ")} { display: none !important; }\n`; 127 | } 128 | 129 | function buildMaxLinesTitleCss(): string { 130 | // For safety, ensure nothing can be injected 131 | if (typeof (Config.config!.titleMaxLines) !== "number" || onMobile()) return ""; 132 | 133 | const result: string[] = []; 134 | for (const start of brandingBoxSelector.split(", ")) { 135 | if (!onMobile()) { 136 | // .ta-title-container for compatibility with Tube Archivist 137 | result.push(`${start} #video-title:not(.ta-title-container)`); 138 | result.push(`${start} .yt-lockup-metadata-view-model-wiz__title > .yt-core-attributed-string:not(.ta-title-container)`); 139 | } 140 | } 141 | 142 | return `${result.join(", ")} { -webkit-line-clamp: ${Config.config!.titleMaxLines} !important; max-height: unset !important; }\n`; 143 | } 144 | 145 | function injectMobileCss() { 146 | const head = document.getElementsByTagName("head")[0]; 147 | 148 | const style = document.createElement("style"); 149 | style.className = "cb-mobile-css"; 150 | style.innerHTML = buildMobileCss(); 151 | 152 | head.appendChild(style); 153 | } 154 | 155 | function buildMobileCss(): string { 156 | if (!onMobile()) return ""; 157 | 158 | const html = document.getElementsByTagName("html")[0]; 159 | if (html) { 160 | const style = window.getComputedStyle(html); 161 | if (style) { 162 | const color = style.getPropertyValue("color"); 163 | return ` 164 | :root { 165 | --yt-spec-text-primary: ${color}; 166 | } 167 | `; 168 | } 169 | } 170 | 171 | return ""; 172 | } 173 | -------------------------------------------------------------------------------- /src/utils/extensionCompatibility.ts: -------------------------------------------------------------------------------- 1 | import { waitForElement } from "../../maze-utils/src/dom"; 2 | import { getVideoID } from "../../maze-utils/src/video"; 3 | import { attachSubmitButtonToPage } from "../video"; 4 | import { replaceCurrentVideoBranding } from "../videoBranding/videoBranding"; 5 | import { logError } from "./logger"; 6 | import { getOrCreateTitleButtonContainer } from "./titleBar"; 7 | 8 | let reduxInstalled: boolean | null = null; 9 | export function isReduxInstalled() { 10 | if (reduxInstalled === null) { 11 | reduxInstalled = !!document.querySelector("#redux-style"); 12 | } 13 | 14 | return reduxInstalled; 15 | } 16 | 17 | export async function reduxCompatiblity() { 18 | const node = await getOrCreateTitleButtonContainer(); 19 | 20 | if (node && isReduxInstalled() && node.parentElement 21 | && !node.parentElement.classList.contains("title")) { 22 | // Wait for redux to replace the node with a new one 23 | const newTitle = await waitForElement(".ytd-video-primary-info-renderer.title", true); 24 | 25 | if (newTitle) { 26 | attachSubmitButtonToPage(); 27 | 28 | if (getVideoID()) { 29 | replaceCurrentVideoBranding().catch(logError); 30 | } 31 | } 32 | } 33 | } 34 | 35 | export function runCompatibilityFunctions() { 36 | reduxCompatiblity().catch(logError); 37 | } -------------------------------------------------------------------------------- /src/utils/keybinds.ts: -------------------------------------------------------------------------------- 1 | import { addCleanupListener } from "../../maze-utils/src/cleanup"; 2 | import { Keybind, keybindEquals } from "../../maze-utils/src/config"; 3 | import Config from "../config/config"; 4 | import { submitButton } from "../video"; 5 | import { logError } from "./logger"; 6 | 7 | function hotkeyListener(e: KeyboardEvent): void { 8 | const currentTag = document.activeElement?.tagName?.toLowerCase(); 9 | 10 | if (currentTag && ["textarea", "input"].includes(currentTag) 11 | || document.activeElement?.id?.toLowerCase()?.includes("editable")) return; 12 | 13 | const key: Keybind = { 14 | key: e.key, 15 | code: e.code, 16 | alt: e.altKey, 17 | ctrl: e.ctrlKey, 18 | shift: e.shiftKey 19 | }; 20 | 21 | 22 | if (keybindEquals(key, Config.config!.openMenuKey)) { 23 | submitButton.openOrClose().catch(logError); 24 | return; 25 | } else if (keybindEquals(key, Config.config!.enableExtensionKey)) { 26 | Config.config!.extensionEnabled = !Config.config!.extensionEnabled; 27 | return; 28 | } 29 | } 30 | 31 | export function addHotkeyListener(): void { 32 | document.addEventListener("keydown", hotkeyListener); 33 | 34 | const onLoad = () => { 35 | // Allow us to stop propagation to YouTube by being deeper 36 | document.removeEventListener("keydown", hotkeyListener); 37 | document.body.addEventListener("keydown", hotkeyListener); 38 | 39 | addCleanupListener(() => { 40 | document.body.removeEventListener("keydown", hotkeyListener); 41 | }); 42 | }; 43 | 44 | if (document.readyState === "complete") { 45 | onLoad(); 46 | } else { 47 | document.addEventListener("DOMContentLoaded", onLoad); 48 | } 49 | } -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import * as CompileConfig from "../../config.json"; 2 | 3 | export function logError(error: unknown): void { 4 | console.error("[CB]", error); 5 | } 6 | 7 | export function log(...text: unknown[]): void { 8 | if (CompileConfig.debug) { 9 | console.log(...text); 10 | } else { 11 | window["CBLogs"] ??= []; 12 | window["CBLogs"].push({ 13 | time: Date.now(), 14 | text 15 | }); 16 | 17 | if (window["CBLogs"].length > 100) { 18 | window["CBLogs"].shift(); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/utils/pageCleaner.ts: -------------------------------------------------------------------------------- 1 | export function cleanPage() { 2 | // For live-updates 3 | if (document.readyState === "complete") { 4 | for (const element of document.querySelectorAll(".cbShowOriginal, .cb-css, #cbSubmitMenu, .cbTitleButtonContainer, .cbCustomThumbnailCanvas, #dearrow-unactivated-warning")) { 5 | element.remove(); 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/utils/requests.ts: -------------------------------------------------------------------------------- 1 | import { FetchResponse, sendRequestToCustomServer } from "../../maze-utils/src/background-request-proxy"; 2 | import Config from "../config/config"; 3 | 4 | 5 | export function sendRequestToServer(type: string, url: string, data = {}): Promise { 6 | return sendRequestToCustomServer(type, Config.config!.serverAddress + url, data); 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { GenericTooltip, TooltipProps } from "../../maze-utils/src/components/Tooltip"; 2 | 3 | export class Tooltip extends GenericTooltip { 4 | constructor(props: TooltipProps) { 5 | super(props, "icons/logo.svg") 6 | } 7 | } -------------------------------------------------------------------------------- /src/video.ts: -------------------------------------------------------------------------------- 1 | import { BackgroundToContentMessage } from "./types/messaging"; 2 | import { logError } from "./utils/logger"; 3 | import { ChannelIDInfo, checkIfNewVideoID, getVideoID, isOnYTTV, setupVideoModule, VideoID } from "../maze-utils/src/video" 4 | import Config from "./config/config"; 5 | import { SubmitButton } from "./submission/submitButton"; 6 | import { BrandingLocation, BrandingResult, clearVideoBrandingInstances, replaceCurrentVideoBranding } from "./videoBranding/videoBranding"; 7 | import { getVideoBranding } from "./dataFetching"; 8 | import * as documentScript from "../dist/js/document.js"; 9 | import { listenForBadges, listenForMiniPlayerTitleChange, listenForTitleChange } from "./utils/titleBar"; 10 | import { getPlaybackFormats } from "./thumbnails/thumbnailData"; 11 | import { replaceVideoPlayerSuggestionsBranding, setupMobileAutoplayHandler } from "./videoBranding/watchPageBrandingHandler"; 12 | import { onMobile } from "../maze-utils/src/pageInfo"; 13 | import { resetShownWarnings } from "./submission/autoWarning"; 14 | import { getAntiTranslatedTitle } from "./titles/titleAntiTranslateData"; 15 | import { CasualVoteButton } from "./submission/casualVoteButton"; 16 | 17 | export const submitButton = new SubmitButton(); 18 | export const casualVoteButton = new CasualVoteButton(); 19 | 20 | async function videoIDChange(videoID: VideoID | null): Promise { 21 | if (!videoID || isOnYTTV()) return; 22 | 23 | replaceCurrentVideoBranding().catch(logError); 24 | 25 | if (!onMobile()) { 26 | replaceVideoPlayerSuggestionsBranding().catch(logError); 27 | } 28 | 29 | try { 30 | // To update videoID 31 | submitButton.render(); 32 | casualVoteButton.render(); 33 | 34 | const branding = await getVideoBranding(videoID, true, BrandingLocation.Watch); 35 | if (branding && getVideoID() === videoID) { 36 | submitButton.setSubmissions(branding); 37 | casualVoteButton.setExistingVotes(branding.casualVotes); 38 | } 39 | } catch (e) { 40 | logError(e); 41 | } 42 | } 43 | 44 | export function updateSubmitButton(branding: BrandingResult) { 45 | submitButton.setSubmissions(branding) 46 | casualVoteButton.setExistingVotes(branding.casualVotes); 47 | } 48 | 49 | export function attachSubmitButtonToPage() { 50 | casualVoteButton.attachToPage().catch(logError); 51 | submitButton.attachToPage().catch(logError); 52 | } 53 | 54 | function resetValues() { 55 | submitButton.clearSubmissions(); 56 | submitButton.close(); 57 | casualVoteButton.clearExistingVotes(); 58 | casualVoteButton.close(); 59 | 60 | clearVideoBrandingInstances(); 61 | 62 | resetShownWarnings(); 63 | } 64 | 65 | // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars 66 | function channelIDChange(channelIDInfo: ChannelIDInfo): void { 67 | } 68 | 69 | function videoElementChange(newVideo: boolean) { 70 | if (newVideo) { 71 | attachSubmitButtonToPage(); 72 | 73 | listenForBadges().catch(logError); 74 | listenForTitleChange().catch(logError); 75 | listenForMiniPlayerTitleChange().catch(console.error); 76 | 77 | submitButton.render(); 78 | casualVoteButton.render(); 79 | } 80 | } 81 | 82 | // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars 83 | function windowListenerHandler(event: MessageEvent) { 84 | const data = event.data; 85 | if (!data) return; 86 | } 87 | 88 | function newVideosLoaded(videoIDs: VideoID[]) { 89 | // Pre-cache the data for these videos 90 | for (const videoID of videoIDs) { 91 | getVideoBranding(videoID, false).catch(logError); 92 | getPlaybackFormats(videoID).catch(logError); 93 | 94 | if (Config.config!.ignoreTranslatedTitles) { 95 | getAntiTranslatedTitle(videoID).catch(logError); 96 | } 97 | } 98 | } 99 | 100 | function onNavigateToChannel() { 101 | // For channel trailers 102 | replaceCurrentVideoBranding().catch(logError); 103 | } 104 | 105 | export function setupCBVideoModule(): void { 106 | chrome.runtime.onMessage.addListener((request: BackgroundToContentMessage) => { 107 | if (request.message === "update") { 108 | checkIfNewVideoID().catch(logError); 109 | } 110 | }); 111 | 112 | setupVideoModule({ 113 | videoIDChange: (videoID) => void videoIDChange(videoID).catch(logError), 114 | channelIDChange, 115 | videoElementChange, 116 | resetValues, 117 | windowListenerHandler, 118 | newVideosLoaded, 119 | onNavigateToChannel, 120 | documentScript: chrome.runtime.getManifest().manifest_version === 2 ? documentScript : undefined, 121 | allowClipPage: true 122 | }, () => Config); 123 | 124 | if (onMobile()) { 125 | setupMobileAutoplayHandler().catch(logError); 126 | } else { 127 | document.addEventListener("fullscreenchange", () => { 128 | // Fix title sometimes being the old title 129 | setTimeout(() => { 130 | replaceCurrentVideoBranding().catch(logError); 131 | }, 100); 132 | }) 133 | } 134 | } -------------------------------------------------------------------------------- /src/videoBranding/mediaSessionHandler.ts: -------------------------------------------------------------------------------- 1 | export function setMediaSessionTitle(title: string) { 2 | if (!title) return; 3 | 4 | if ("mediaSession" in navigator) { 5 | if (navigator.mediaSession.metadata?.title !== title) { 6 | setMediaSessionInfo({ 7 | title: title 8 | }); 9 | } 10 | } 11 | } 12 | 13 | export function setMediaSessionThumbnail(url: string) { 14 | if ("mediaSession" in navigator) { 15 | setMediaSessionInfo({ 16 | artwork: [{ 17 | src: url 18 | }] 19 | }); 20 | } 21 | } 22 | 23 | function setMediaSessionInfo(data: MediaMetadataInit) { 24 | window.postMessage({ 25 | source: "dearrow-media-session", 26 | data 27 | }, "/"); 28 | } 29 | 30 | 31 | export function resetMediaSessionThumbnail() { 32 | window.postMessage({ 33 | source: "dearrow-reset-media-session-thumbnail" 34 | }, "/"); 35 | } -------------------------------------------------------------------------------- /src/videoBranding/notificationHandler.ts: -------------------------------------------------------------------------------- 1 | import { waitFor } from "../../maze-utils/src"; 2 | import { waitForElement } from "../../maze-utils/src/dom"; 3 | import { onMobile } from "../../maze-utils/src/pageInfo"; 4 | import { isOnInvidious } from "../../maze-utils/src/video"; 5 | import { getOriginalTitleElement } from "../titles/titleRenderer"; 6 | import { logError } from "../utils/logger"; 7 | import { BrandingLocation, replaceVideoCardBranding } from "./videoBranding"; 8 | 9 | let mutationObserver: MutationObserver | null = null; 10 | async function onNotificationMenuOpened() { 11 | const notificationMenu = await waitForElement("ytd-multi-page-menu-renderer")!; 12 | 13 | if (mutationObserver) { 14 | mutationObserver.disconnect(); 15 | } else { 16 | mutationObserver = new MutationObserver((mutations) => { 17 | mutations.forEach((mutation) => { 18 | if (mutation.addedNodes.length > 0) { 19 | for (const node of mutation.addedNodes) { 20 | if (node instanceof HTMLElement) { 21 | if (node.tagName.toLowerCase() === "ytd-notification-renderer") { 22 | replaceNotificationBranding(node, BrandingLocation.Notification); 23 | } else if (node.tagName.toLowerCase() === "ytd-comment-video-thumbnail-header-renderer") { 24 | // At the top after clicking a notification about recieving a comment on a video 25 | replaceNotificationBranding(node, BrandingLocation.NotificationTitle); 26 | } 27 | } 28 | } 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | mutationObserver.observe(notificationMenu, { childList: true, subtree: true }); 35 | } 36 | 37 | function replaceNotificationBranding(notification: HTMLElement, brandingLocation: BrandingLocation) { 38 | // Only if this notification format is supported 39 | const originalTitle = getOriginalTitleElement(notification as HTMLElement, brandingLocation)?.textContent; 40 | const hasThumbnail = !!notification.querySelector(".thumbnail-container img"); 41 | if (hasThumbnail) { 42 | const validTitle = brandingLocation === BrandingLocation.NotificationTitle 43 | || (originalTitle && notificationToTitle(originalTitle)); 44 | replaceVideoCardBranding(notification as HTMLElement, brandingLocation, { dontReplaceTitle: !validTitle }).catch(logError); 45 | } 46 | } 47 | 48 | export async function setupNotificationHandler() { 49 | if (!onMobile() && !isOnInvidious()) { 50 | try { 51 | const notificationButton = await waitFor(() => document.querySelector("ytd-notification-topbar-button-renderer"), 20000, 500); 52 | 53 | if (notificationButton) { 54 | notificationButton.addEventListener("click", () => void(onNotificationMenuOpened())); 55 | } 56 | } catch (e) { } // eslint-disable-line no-empty 57 | } 58 | } 59 | 60 | const notificationFormats = [ 61 | "$CHANNEL$ uploaded: $TITLE$", 62 | "$CHANNEL$ premiering now: $TITLE$", 63 | "$CHANNEL$ is live: $TITLE$", 64 | "Watch $CHANNEL$ live in 30 minutes: $TITLE$", 65 | "$CHANNEL$ premiering in 30 minutes: $TITLE$", 66 | "$CHANNEL$ latasi videon: $TITLE$", 67 | "$CHANNEL$ alotti livestriimin: $TITLE$", 68 | "$CHANNEL$ hat ein Video hochgeladen: $TITLE$", 69 | "$CHANNEL$ hat $TITLE$ hochgeladen", 70 | "$CHANNEL$ startet gerade die Premiere von $TITLE$", 71 | "$CHANNEL$ überträgt einen Livestream: $TITLE$", 72 | "$CHANNEL$ ha subido: $TITLE$", 73 | "$CHANNEL$ está emitiendo en directo: $TITLE$", 74 | "Na kanal $CHANNEL$ został przesłany film $TITLE$", 75 | "$CHANNEL$ laddade upp:? $TITLE$", 76 | "Na kanale $CHANNEL$ trwa premiera filmu: $TITLE$", 77 | "Har premiär nu på $CHANNEL$: $TITLE$", 78 | "$CHANNEL$ nadaje: $TITLE$", 79 | "Za 30 min na kanale $CHANNEL$ premiera filmu: $TITLE$", 80 | "Oglądaj transmisję na żywo „$TITLE$” na kanale $CHANNEL$ za 30 min", 81 | "$CHANNEL$ est en direct : $TITLE$", 82 | "$CHANNEL$ a mis en ligne $TITLE$", 83 | "Première en cours sur la chaîne $CHANNEL$ : $TITLE$", 84 | ]; 85 | const channelTemplate = "$CHANNEL$"; 86 | const titleTemplate = "$TITLE$"; 87 | function formatToRegex(format: string): RegExp { 88 | return new RegExp(format.replace(channelTemplate, "(.+)").replace(titleTemplate, "(.+)"), "i"); 89 | } 90 | export function notificationToTitle(title: string): string { 91 | for (const format of notificationFormats) { 92 | const titleMatch = title.match(formatToRegex(format))?.[2]; 93 | 94 | if (titleMatch) { 95 | return titleMatch; 96 | } 97 | } 98 | 99 | return ""; 100 | } 101 | 102 | export function titleToNotificationFormat(newTitle: string, originalTitle: string): string { 103 | for (const format of notificationFormats) { 104 | const titleMatch = originalTitle.match(formatToRegex(format))?.[2]; 105 | 106 | if (titleMatch) { 107 | return originalTitle.replace(titleMatch, newTitle); 108 | } 109 | } 110 | 111 | return ""; 112 | } -------------------------------------------------------------------------------- /src/videoBranding/onboarding.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Config, { TitleFormatting } from "../config/config"; 3 | import { getVideoThumbnailIncludingUnsubmitted, getVideoTitleIncludingUnsubmitted } from "../dataFetching"; 4 | import { VideoID } from "../../maze-utils/src/video"; 5 | import { FormattingOptionsComponent } from "../popup/FormattingOptionsComponent"; 6 | import { Tooltip } from "../utils/tooltip"; 7 | import { BrandingLocation, ShowCustomBrandingInfo, getActualShowCustomBranding } from "./videoBranding"; 8 | import * as CompileConfig from "../../config.json" 9 | import { isLiveOrUpcoming } from "../thumbnails/thumbnailData"; 10 | 11 | export async function handleOnboarding(element: HTMLElement, videoID: VideoID, 12 | brandingLocation: BrandingLocation, showCustomBranding: ShowCustomBrandingInfo, result: [boolean, boolean]): Promise { 13 | 14 | if (Config.config!.showInfoAboutRandomThumbnails && await getActualShowCustomBranding(showCustomBranding) && element && videoID 15 | && brandingLocation === BrandingLocation.Related && document.URL === "https://www.youtube.com/" 16 | && !CompileConfig.debug) { 17 | 18 | if (Config.config!.thumbnailFallback !== Config.syncDefaults.thumbnailFallback 19 | || Config.config!.titleFormatting !== Config.syncDefaults.titleFormatting) { 20 | // Defaults were already changed 21 | Config.config!.showInfoAboutRandomThumbnails = false; 22 | return; 23 | } 24 | 25 | const ignoreTitleChange = Config.config!.titleFormatting === TitleFormatting.Disable; 26 | 27 | // Both title and thumbnail changed due to random time or title format 28 | // Ignore title changes if title formatting is disabled 29 | if (result[0] && !(await getVideoThumbnailIncludingUnsubmitted(videoID, brandingLocation, false)) 30 | && (ignoreTitleChange || (result[1] && !(await getVideoTitleIncludingUnsubmitted(videoID, brandingLocation)))) 31 | && !await isLiveOrUpcoming(videoID)) { 32 | 33 | // Check if notice will be visible (since it appears to the left of the element) 34 | const box = element.closest("#contents"); 35 | const boundingRect = element.getBoundingClientRect(); 36 | const elementAtLeft = document.elementFromPoint(boundingRect.x - boundingRect.width, boundingRect.y); 37 | if (Config.config!.showInfoAboutRandomThumbnails && box && box.contains(elementAtLeft)) { 38 | Config.config!.showInfoAboutRandomThumbnails = false; 39 | 40 | const firstMessage = ignoreTitleChange ? chrome.i18n.getMessage("RandomThumbnailExplanation") 41 | : chrome.i18n.getMessage("RandomThumbnailAndAutoFormattedExplanation"); 42 | 43 | new Tooltip({ 44 | text: [firstMessage, chrome.i18n.getMessage("YouCanChangeThisDefaultBelow")].join(". "), 45 | referenceNode: element, 46 | prependElement: element.firstElementChild as HTMLElement, 47 | positionRealtive: false, 48 | containerAbsolute: true, 49 | bottomOffset: "inherit", 50 | rightOffset: "100%", 51 | innerBottomMargin: "10px", 52 | displayTriangle: true, 53 | center: true, 54 | opacity: 1, 55 | extraClass: "rightSBTriangle cbOnboarding", 56 | elements: [ 57 | 58 | ] 59 | }); 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /test/selenium.test.ts: -------------------------------------------------------------------------------- 1 | import { Builder, By, until, WebDriver } from "selenium-webdriver"; 2 | import * as Chrome from "selenium-webdriver/chrome"; 3 | import * as Path from "path"; 4 | 5 | import * as fs from "fs"; 6 | 7 | test("Selenium Chrome test", async () => { 8 | let driver: WebDriver; 9 | try { 10 | driver = await setup(); 11 | } catch (e) { 12 | console.warn("A browser is probably not installed, skipping selenium tests"); 13 | console.warn(e); 14 | 15 | return; 16 | } 17 | 18 | try { 19 | await waitForInstall(driver); 20 | // This video has no ads 21 | await goToVideo(driver, "QjjpDhHh_QI"); 22 | 23 | await checkTitle(driver, "Demo of DeArrow - A Browser Extension for Crowdsourcing Better Titles and Thumbnails on YouTube"); 24 | } catch (e) { 25 | // Save file incase there is a layout change 26 | const source = await driver.getPageSource(); 27 | 28 | if (!fs.existsSync("./test-results")) fs.mkdirSync("./test-results"); 29 | fs.writeFileSync("./test-results/source.html", source); 30 | 31 | throw e; 32 | } finally { 33 | await driver.quit(); 34 | } 35 | }, 100_000); 36 | 37 | async function setup(): Promise { 38 | const options = new Chrome.Options(); 39 | options.addArguments("--load-extension=" + Path.join(__dirname, "../dist/")); 40 | options.addArguments("--mute-audio"); 41 | options.addArguments("--disable-features=PreloadMediaEngagementData, MediaEngagementBypassAutoplayPolicies"); 42 | options.addArguments("--headless=new"); 43 | options.addArguments("--window-size=1920,1080"); 44 | 45 | const driver = await new Builder().forBrowser("chrome").setChromeOptions(options).build(); 46 | driver.manage().setTimeouts({ 47 | implicit: 5000 48 | }); 49 | 50 | return driver; 51 | } 52 | 53 | async function waitForInstall(driver: WebDriver, startingTab = 0): Promise { 54 | // Selenium only knows about the one tab it's on, 55 | // so we can't wait for the help page to appear 56 | await driver.sleep(3000); 57 | 58 | const handles = await driver.getAllWindowHandles(); 59 | await driver.switchTo().window(handles[startingTab]); 60 | } 61 | 62 | async function goToVideo(driver: WebDriver, videoId: string): Promise { 63 | await driver.get("https://www.youtube.com/watch?v=" + videoId); 64 | await driver.wait(until.elementIsVisible(await driver.findElement(By.css(".ytd-video-primary-info-renderer, #above-the-fold")))); 65 | } 66 | 67 | async function checkTitle(driver: WebDriver, expectedTitle: string): Promise { 68 | const title = await driver.findElement(By.css("#above-the-fold #title .cbCustomTitle")); 69 | const titleText = await title.getText(); 70 | expect(titleText).toContain(expectedTitle); 71 | } -------------------------------------------------------------------------------- /tsconfig-production.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": false, 6 | "strictNullChecks": true, 7 | "noImplicitReturns": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "sourceMap": false, 10 | "outDir": "dist/js", 11 | "noEmitOnError": false, 12 | "typeRoots": [ "node_modules/@types" ], 13 | "resolveJsonModule": true, 14 | "jsx": "react", 15 | "lib": [ 16 | "es2019", 17 | "dom", 18 | "dom.iterable" 19 | ] 20 | } 21 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "noImplicitAny": false, 6 | "strictNullChecks": true, 7 | "noImplicitReturns": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "sourceMap": true, 10 | "outDir": "dist/js", 11 | "noEmitOnError": false, 12 | "typeRoots": [ "node_modules/@types" ], 13 | "resolveJsonModule": true, 14 | "jsx": "react", 15 | "lib": [ 16 | "es2019", 17 | "dom", 18 | "dom.iterable" 19 | ] 20 | } 21 | } -------------------------------------------------------------------------------- /webpack/webpack.dev.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { merge } = require('webpack-merge'); 3 | const common = require('./webpack.common.js'); 4 | 5 | module.exports = env => merge(common(env), { 6 | devtool: 'inline-source-map', 7 | mode: 'development' 8 | }); -------------------------------------------------------------------------------- /webpack/webpack.manifest.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 3 | const webpack = require("webpack"); 4 | const path = require('path'); 5 | const { validate } = require('schema-utils'); 6 | 7 | const fs = require('fs'); 8 | 9 | const manifest = require("../manifest/manifest.json"); 10 | const firefoxManifestExtra = require("../manifest/firefox-manifest-extra.json"); 11 | const chromeManifestExtra = require("../manifest/chrome-manifest-extra.json"); 12 | const safariManifestExtra = require("../manifest/safari-manifest-extra.json"); 13 | const betaManifestExtra = require("../manifest/beta-manifest-extra.json"); 14 | const firefoxBetaManifestExtra = require("../manifest/firefox-beta-manifest-extra.json"); 15 | const manifestV2ManifestExtra = require("../manifest/manifest-v2-extra.json"); 16 | 17 | // schema for options object 18 | const schema = { 19 | type: 'object', 20 | properties: { 21 | browser: { 22 | type: 'string' 23 | }, 24 | pretty: { 25 | type: 'boolean' 26 | }, 27 | steam: { 28 | type: 'string' 29 | } 30 | } 31 | }; 32 | 33 | class BuildManifest { 34 | constructor (options = {}) { 35 | validate(schema, options, "Build Manifest Plugin"); 36 | 37 | this.options = options; 38 | } 39 | 40 | apply() { 41 | const distFolder = path.resolve(__dirname, "../dist/"); 42 | const distManifestFile = path.resolve(distFolder, "manifest.json"); 43 | 44 | // Add missing manifest elements 45 | if (this.options.browser.toLowerCase() === "firefox") { 46 | mergeObjects(manifest, manifestV2ManifestExtra); 47 | mergeObjects(manifest, firefoxManifestExtra); 48 | } else if (this.options.browser.toLowerCase() === "chrome" 49 | || this.options.browser.toLowerCase() === "chromium" 50 | || this.options.browser.toLowerCase() === "edge") { 51 | mergeObjects(manifest, chromeManifestExtra); 52 | } else if (this.options.browser.toLowerCase() === "safari") { 53 | mergeObjects(manifest, manifestV2ManifestExtra); 54 | mergeObjects(manifest, safariManifestExtra); 55 | manifest.optional_permissions = manifest.optional_permissions.filter((a) => a !== "*://*/*"); 56 | } 57 | 58 | if (this.options.stream === "beta") { 59 | mergeObjects(manifest, betaManifestExtra); 60 | 61 | if (this.options.browser.toLowerCase() === "firefox") { 62 | mergeObjects(manifest, firefoxBetaManifestExtra); 63 | } 64 | } 65 | 66 | let result = JSON.stringify(manifest); 67 | if (this.options.pretty) result = JSON.stringify(manifest, null, 2); 68 | 69 | fs.mkdirSync(distFolder, {recursive: true}); 70 | fs.writeFileSync(distManifestFile, result); 71 | } 72 | } 73 | 74 | function mergeObjects(object1, object2) { 75 | for (const key in object2) { 76 | if (key in object1) { 77 | if (Array.isArray(object1[key])) { 78 | object1[key] = object1[key].concat(object2[key]); 79 | } else if (typeof object1[key] == 'object') { 80 | mergeObjects(object1[key], object2[key]); 81 | } else { 82 | object1[key] = object2[key]; 83 | } 84 | } else { 85 | object1[key] = object2[key]; 86 | } 87 | } 88 | } 89 | 90 | module.exports = BuildManifest; -------------------------------------------------------------------------------- /webpack/webpack.prod.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { merge } = require('webpack-merge'); 3 | const common = require('./webpack.common.js'); 4 | 5 | module.exports = env => { 6 | let mode = "production"; 7 | env.mode = mode; 8 | 9 | return merge(common(env), { 10 | mode 11 | }); 12 | }; --------------------------------------------------------------------------------