├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── background-darkmode.png ├── background.png ├── button-bg.svg ├── fonts │ ├── Exo-Regular.ttf │ ├── Exo-SemiBold.ttf │ ├── Exo2-Regular.woff2 │ ├── Exo2-SemiBold.woff2 │ └── Other-Icons.woff2 ├── mod-badges │ ├── dt.png │ ├── ez.png │ ├── fl.png │ ├── hd.png │ ├── hr.png │ ├── ht.png │ ├── nf.png │ └── so.png └── page-light.png ├── background ├── background.ts └── content.ts ├── common ├── constants.ts └── styles │ └── fonts.sass ├── package.json ├── popup ├── analytics.ts ├── calculators │ ├── standard.ts │ └── taiko.ts ├── converters │ └── taiko.ts ├── index.ts ├── notifications.ts ├── objects │ ├── difficultyHitObject.ts │ ├── hitObject.ts │ ├── sliderEventDescriptor.ts │ ├── sliderEventGenerator.ts │ ├── sliderEventType.ts │ └── taiko │ │ ├── hitType.ts │ │ ├── objectType.ts │ │ ├── parsedTaikoObject.ts │ │ ├── parsedTaikoResult.ts │ │ ├── swell.ts │ │ ├── taikoDifficultyAttributes.ts │ │ ├── taikoDifficultyHitObject.ts │ │ ├── taikoDifficultyHitObjectRhythm.ts │ │ ├── taikoObject.ts │ │ └── taikoReader.ts ├── settings.ts ├── skills │ ├── skill.ts │ └── taiko │ │ ├── colour.ts │ │ ├── rhythm.ts │ │ ├── stamina.ts │ │ └── staminaCheeseDetector.ts ├── styles │ ├── main.sass │ ├── modules │ │ ├── _calculation-settings.sass │ │ ├── _error.sass │ │ ├── _header.sass │ │ ├── _notification.sass │ │ ├── _result.sass │ │ ├── _settings.sass │ │ └── _spinner.scss │ └── partials │ │ └── _base.sass ├── translations.ts └── util │ ├── arrays.ts │ ├── beatmap.ts │ ├── beatmaps.ts │ ├── console.ts │ ├── limitedCapacityQueue.ts │ ├── limitedCapacityStack.ts │ ├── mth.ts │ ├── pageInfo.ts │ ├── precision.ts │ ├── sliders.ts │ ├── timingPoint.ts │ └── timingsReader.ts ├── static ├── icon128.png ├── icon48.png ├── manifest.json └── popup.html ├── translations ├── bg.json ├── de.json ├── en.json ├── es.json ├── fi.json ├── fr.json ├── hr.json ├── hu.json ├── it.json ├── ja.json ├── ko.json ├── languages.json ├── pl.json ├── pt_BR.json ├── ro.json ├── ru.json ├── sk.json ├── sv.json ├── tr.json ├── vi.json ├── zh_CN.json └── zh_TW.json ├── tsconfig.json ├── typings.d.ts ├── webpack.config.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["prettier"], 3 | "parser": "@typescript-eslint/parser", 4 | "env": { 5 | "webextensions": true, 6 | "browser": true, 7 | }, 8 | "globals": { 9 | "__FIREFOX__": true, 10 | "__CHROME__": true, 11 | "_gaq": true 12 | }, 13 | "plugins": ["prettier", "prefer-arrow", "@typescript-eslint"], 14 | "rules": { 15 | "prettier/prettier": ["error", { 16 | "printWidth": 80, 17 | "singleQuote": true, 18 | "semi": false, 19 | "quoteProps": "consistent" 20 | }], 21 | "prefer-arrow/prefer-arrow-functions": ["error", {} ], 22 | "no-console": ["error", { }] 23 | } 24 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js text eol=lf 2 | *.ts text eol=lf 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. Please search for the open/closed issues before reporting the bug! 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Do A 16 | 2. Do B 17 | 3. Do C 18 | 4. Do D 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - Browser: 28 | - [ ] Chrome 29 | - [ ] Firefox 30 | - Version: 31 | - Beatmap URL: 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered if any. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js v14.15.4 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: '14.15.4' 21 | - name: Install packages and run tests 22 | run: | 23 | yarn 24 | yarn lint 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | 4 | *.xpi 5 | *.pem 6 | *.crx 7 | *.zip 8 | *.fuse_hidden* 9 | yarn-error.log -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ezpp! changelog 2 | 3 | [google web store](https://chrome.google.com/webstore/detail/ezpp/aimihpobjpagjiakhcpijibnaafdniol) - [firefox add-ons](https://addons.mozilla.org/fi/firefox/addon/ezpp/) - [source code](https://github.com/oamaok/ezpp) - [github](https://github.com/oamaok/ezpp) - [issues](https://github.com/oamaok/ezpp/issues) 4 | 5 | ## v1.10.2 6 | - Fix a bug which caused all the text to disappear in some cases 7 | 8 | ## v1.10.1 9 | - Fix AR calculations (thanks to [acrylic-style](https://github.com/acrylic-style)) 10 | - Add setting to use original song metadata (thanks to [acrylic-style](https://github.com/acrylic-style)) 11 | - Fix Swedish translation file encoding (thanks to [Walavouchey](https://github.com/Walavouchey)) 12 | - Remove local changelog, use GitHub link instead 13 | - Refactor settings system 14 | 15 | ## v1.10.0 16 | - Add Taiko support (thanks to [acrylic-style](https://github.com/acrylic-style)) 17 | - Add Swedish translations (thanks to [Walavouchey](https://github.com/Walavouchey)) 18 | - Calculator code refactoring 19 | - Minor stylistic improvements 20 | 21 | ## v1.9.0 22 | - Display AR (thanks to [acrylic-style](https://github.com/acrylic-style)) 23 | - Fix BPM display for HT (thanks to [acrylic-style](https://github.com/acrylic-style)) 24 | - Add Hungarian translations (thanks to [Tudi20](https://github.com/Tudi20)) 25 | - Add Turkish translations (thanks to [EmirhaN998](https://github.com/EmirhaN998)) 26 | - Update pp calculation package [ojsama](https://github.com/Francesco149/ojsama) (thanks to [hesch](https://github.com/hesch)) 27 | 28 | ## v1.8.6 29 | - Display BPM 30 | - Remove analytics for Firefox 31 | 32 | ## v1.8.5 33 | - Update [ojsama](https://github.com/Francesco149/ojsama) to v1.2.5 34 | 35 | ## v1.8.4 36 | - Update Japanese translations (thanks to [sjcl](https://github.com/sjcl)) 37 | - Update Spanish translations (thanks to [Alejandro Loarca](https://github.com/loarca)) 38 | - Update Finnish translations 39 | 40 | ## v1.8.3 41 | - Add darkmode 42 | - Add Korean translations (thanks to [pine5508](https://github.com/pine5508)) 43 | 44 | ## v1.8.2 45 | - Even more accurate pp calculations 46 | - Add Simplified Chinese translations (thanks to [TsukiPoi](https://github.com/TsukiPoi)) 47 | 48 | ## v1.8.1 49 | - Update pp calculations to accurately match the pp rebalance 50 | - Add Brazilian Portuguese translations (thanks to [ekisu](https://github.com/ekisu)) 51 | 52 | ## v1.8.0 53 | - Update pp calculations to match the rebalance (almost) 54 | - Add Traditional Chinese translations (thanks to [tomokarin](https://github.com/tomokarin)) 55 | - Add Italian translations (thanks to [Frank1907](https://github.com/Frank1907)) 56 | 57 | ## v1.7.3 58 | - Fix addon not working on the old site while not logged in 59 | - Add error reporting to content script 60 | 61 | ## v1.7.2 62 | - Hide extension after changing to a non-beatmap page on new site 63 | - Use Exo as font only for Vietnamese 64 | 65 | ## v1.7.1 66 | - Use Exo as font for Vietnamese support 67 | - Bundle images and fonts as assets 68 | 69 | ## v1.7.0 70 | - Add Japanese translation (thanks to [sjcl](https://github.com/sjcl)) 71 | - Add Vietnamese translation (thanks to [natsukagami](https://github.com/natsukagami)) 72 | - Rework project structure 73 | - Rework page information resolving 74 | 75 | ## v1.6.0 76 | - Display calculation results immediately 77 | - Remove input limitations 78 | - Load beatmap and beatmap background in parallel for faster boot 79 | - Minor stylistic enhancements 80 | 81 | ## v1.5.12 82 | - Fix font size on some Windows machines 83 | - Display current version inside the header 84 | 85 | ## v1.5.11 86 | - Fix star rating display logic 87 | - Fix notification system 88 | 89 | ## v1.5.10 90 | - Add Polish translation (thanks to [Desconocidosmh](https://github.com/Desconocidosmh)) 91 | - Display star difficulty 92 | - Minor content align fixes 93 | - Improve translation system 94 | - Upgrade bundling system 95 | 96 | ## v1.5.9 97 | - Remove erroneous analytics data object 98 | - Format analytics numerals properly 99 | 100 | ## v1.5.8 101 | - Improve calculation analytics format 102 | - Minor code style improvements 103 | 104 | ## v1.5.7 105 | - Add Romanian translation (thanks to [NoireHime](https://github.com/NoireHime)) 106 | - Add French translation (thanks to [xZoomy](https://github.com/xZoomy)) 107 | - Fix error display 108 | - Sort languages by language code 109 | 110 | ## v1.5.6 111 | - Add Russian translation (thanks to [pazzaru](https://github.com/pazzaru)) 112 | - Add Slovakian translation (thanks to [Thymue](https://github.com/Thymue)) 113 | - Update [ojsama](https://github.com/Francesco149/ojsama) to fix rare calculation error 114 | 115 | ## v1.5.5 116 | - Fix settings panel opening under main panel 117 | 118 | ## v1.5.4 119 | - Minor stylistic changes 120 | - Harden the extension against network errors 121 | - Fix bug affecting some Vietnamese users (Cốc Cốc browser) 122 | - Send calculation settings in error reports 123 | 124 | ## v1.5.3 125 | - Allow 'Delete' key in fields 126 | - Add Github link 127 | - Send current browser and beatmap URL in error reports 128 | 129 | ## v1.5.2 130 | - Improved translation system 131 | - Improved error reporting 132 | - Minor analytics enhancements 133 | 134 | ## v1.5.1 135 | - Fix update notification popping up every time 136 | 137 | ## v1.5.0 138 | - Add settings menu 139 | - Add translations (fi, en, de, es) 140 | - Add analytics toggle 141 | 142 | ## v1.4.8 143 | - Update PP calculations to match the HD rebalance 144 | - Allow comma as a decimal separator 145 | - Minor stylistic changes 146 | 147 | ## v1.4.7 148 | - Fix URL parsing, again :( 149 | 150 | ## v1.4.6 151 | - Fix URL parsing error causing failures with the new osu! website 152 | 153 | ## v1.4.5 154 | - Send errors to Google Analytics for easier debugging 155 | 156 | ## v1.4.4. 157 | - Try to fix error related to new osu! website rollout 158 | 159 | ## v1.4.3 160 | - Analytics fix 161 | 162 | ## v1.4.2 163 | - Allow navigation with arrow keys in numeric inputs 164 | - Improve analytics 165 | 166 | ## v1.4.1 167 | - Fix HalfTime mod 168 | 169 | ## v1.4.0 170 | - Use [ojsama](https://github.com/Francesco149/ojsama) for pp calculations 171 | 172 | ## v1.3.2 173 | - Add keyboard shortcuts for mods (thanks to [Artikash](https://github.com/Artikash) for initial implementation!) 174 | - Minor refactoring 175 | 176 | ## v1.3.1 177 | - Use local fonts and images 178 | - Remove analytics from the Firefox version 179 | - Minor code cleaning 180 | 181 | ## v1.3.0 182 | - Build process improvements 183 | - Separation of styles from script files 184 | - Firefox update \o/ 185 | 186 | ## v1.2.15 187 | - Fix proper error messages not being displayed 188 | 189 | ## v1.2.14 190 | - Fix site version detection 191 | - Add cleaner error display 192 | - Minor stylistic changes 193 | 194 | ## v1.2.13 195 | - Fix errors caused by changes in osu.ppy.sh HTML generation 196 | 197 | ## v1.2.12 198 | - Add analytics 199 | 200 | ## v1.2.11 201 | - Fix the extension not working on [http://new.ppy.sh](http://new.ppy.sh) beatmapset pages 202 | 203 | ## v1.2.10 204 | - Fix CS buff not capping at 5 205 | 206 | ## v1.2.9 207 | - Fix not being able to select mods 208 | 209 | ## v1.2.8 210 | - Limit accuracy, combo and miss field values 211 | - Add missing charset meta tag 212 | - Add local changelog 213 | - Remove JS minification for Firefox builds 214 | 215 | ## v1.2.7 216 | - Add Firefox support 217 | 218 | ## v1.2.6 219 | - Add proper error handling 220 | 221 | ## v1.2.5 222 | - Add missing version number from notification (whoops!) 223 | 224 | ## v1.2.4 225 | - Add check for conflicting mods and disable the counterpart automatically 226 | - Add version update notifications bar 227 | - Add version change detection 228 | 229 | ## v1.2.3 230 | - Fix bug where miss count would default to -1 resulting in erroneous PP calculations 231 | - Fix bug where on some beatmaps certain accuracies would cause the extension to crash 232 | 233 | ## v1.2.2 234 | - Add support for old beatmaps missing the game mode field, default to standard mode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Teemu Pääkkönen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ezpp! 2 | 3 | ezpp! is a browser extension that allows you to calculate pp 4 | values for a beatmap without manually downloading the beatmap. 5 | 6 | 7 | ## Translating 8 | 9 | All translation efforts are warmly welcome! The base English translations can be found [here](https://github.com/oamaok/ezpp/blob/master/translations/en.json). After translating the file you should also add relevant information to [this file](https://github.com/oamaok/ezpp/blob/master/translations/languages.json), where the `code` field should match with the `.json` file you created. If you are unsure on how to edit the files, feel free to [raise an issue](https://github.com/oamaok/ezpp/issues/new) or ask away in the pull request. 10 | 11 | ## Developing 12 | 13 | ### Prerequisities 14 | 15 | Versions of software used at the time of writing: 16 | 17 | ```shell 18 | [teemu@home ezpp]$ node -v 19 | v14.15.4 20 | [teemu@home ezpp]$ yarn -v 21 | 1.22.4 22 | ``` 23 | 24 | ### Setup 25 | 26 | Clone the repository and run the following commands. 27 | ``` 28 | yarn 29 | ``` 30 | 31 | ### Chromium-based browsers 32 | 33 | - Run `yarn start:chrome`. This will create a `build/` directory to the repository root. 34 | - Open up Chrome and navigate to `chrome://extensions`. 35 | - Enable `Developer mode`. 36 | - Click the `Load unpacked` button and select the previously mentioned `build` directory. 37 | - The extension is now ready to go! 38 | 39 | All the changes made are compiled automatically as long as the `yarn start:chrome` script is running. 40 | 41 | To build a production version of the package, run `yarn build:chrome`. 42 | 43 | ### Firefox 44 | 45 | - Run `yarn start:firefox`. This will create a `build/` directory to the repository root. 46 | - Open up Firefox and navigate to `about:debugging#/runtime/this-firefox`. 47 | - Click the `Load Temporary Add-on` button and select any file in the previously mentioned directory. 48 | - The extension is now ready to go! 49 | 50 | All the changes made are compiled automatically as long as the `yarn start:firefox` script is running. 51 | 52 | To build a production version of the package, run `yarn build:firefox`. 53 | 54 | ### Production builds 55 | 56 | Run `yarn build:all`. Two files, `ezpp-chrome.zip` and `ezpp-firefox.zip`, are generated. 57 | 58 | ## Installing 59 | 60 | Chrome/Chromium: [Install from Google WebStore](https://chrome.google.com/webstore/detail/ezpp/aimihpobjpagjiakhcpijibnaafdniol) 61 | 62 | Firefox: [Install from addons.mozilla.org](https://addons.mozilla.org/en-US/firefox/addon/ezpp/) 63 | 64 | ## License 65 | 66 | [MIT](https://github.com/oamaok/ezpp/blob/master/LICENSE) 67 | -------------------------------------------------------------------------------- /assets/background-darkmode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/background-darkmode.png -------------------------------------------------------------------------------- /assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/background.png -------------------------------------------------------------------------------- /assets/button-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /assets/fonts/Exo-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/fonts/Exo-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/Exo-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/fonts/Exo-SemiBold.ttf -------------------------------------------------------------------------------- /assets/fonts/Exo2-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/fonts/Exo2-Regular.woff2 -------------------------------------------------------------------------------- /assets/fonts/Exo2-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/fonts/Exo2-SemiBold.woff2 -------------------------------------------------------------------------------- /assets/fonts/Other-Icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/fonts/Other-Icons.woff2 -------------------------------------------------------------------------------- /assets/mod-badges/dt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/mod-badges/dt.png -------------------------------------------------------------------------------- /assets/mod-badges/ez.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/mod-badges/ez.png -------------------------------------------------------------------------------- /assets/mod-badges/fl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/mod-badges/fl.png -------------------------------------------------------------------------------- /assets/mod-badges/hd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/mod-badges/hd.png -------------------------------------------------------------------------------- /assets/mod-badges/hr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/mod-badges/hr.png -------------------------------------------------------------------------------- /assets/mod-badges/ht.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/mod-badges/ht.png -------------------------------------------------------------------------------- /assets/mod-badges/nf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/mod-badges/nf.png -------------------------------------------------------------------------------- /assets/mod-badges/so.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/mod-badges/so.png -------------------------------------------------------------------------------- /assets/page-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/assets/page-light.png -------------------------------------------------------------------------------- /background/background.ts: -------------------------------------------------------------------------------- 1 | import { BEATMAP_URL_REGEX } from '../common/constants' 2 | 3 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 4 | if (changeInfo.status === 'complete' && tab.url!.match(BEATMAP_URL_REGEX)) { 5 | chrome.action.enable(tabId) 6 | } else if (!tab.url!.match(BEATMAP_URL_REGEX)) { 7 | chrome.action.disable(tabId) 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /background/content.ts: -------------------------------------------------------------------------------- 1 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 2 | if (request.action === 'GET_BEATMAP_INFO') { 3 | try { 4 | const activeTabHref = document 5 | .querySelector('.beatmapTab.active')! 6 | .getAttribute('href')! 7 | const graphImageSrc = document 8 | .querySelector('img[src^="/pages/include/beatmap-rating-graph.php"]')! 9 | .getAttribute('src')! 10 | const [, beatmapId] = activeTabHref.match(/\/b\/(\d+)/i)! || [] 11 | const [, beatmapSetId] = graphImageSrc.match(/=(\d+)$/i)! || [] 12 | sendResponse({ 13 | status: 'SUCCESS', 14 | beatmapId, 15 | beatmapSetId, 16 | }) 17 | } catch (error) { 18 | sendResponse({ 19 | status: 'ERROR', 20 | error: { 21 | message: error.message, 22 | arguments: error.arguments, 23 | type: error.type, 24 | name: error.name, 25 | stack: error.stack, 26 | }, 27 | }) 28 | } 29 | } 30 | if (request.action === 'GET_BEATMAP_STATS') { 31 | try { 32 | const beatmapSet = JSON.parse( 33 | document.getElementById('json-beatmapset')!.textContent! 34 | ) 35 | const beatmap = beatmapSet.beatmaps.find( 36 | (map: { id: number }) => 37 | map.id.toString() === request.beatmapId.toString() 38 | ) 39 | const convert = beatmapSet.converts.find( 40 | (map: { id: number; mode: string }) => 41 | map.id.toString() === request.beatmapId.toString() && 42 | map.mode === request.mode 43 | ) 44 | 45 | sendResponse({ 46 | status: 'SUCCESS', 47 | beatmap, 48 | convert, 49 | }) 50 | } catch (error) { 51 | sendResponse({ 52 | status: 'ERROR', 53 | error: { 54 | message: error.message, 55 | arguments: error.arguments, 56 | type: error.type, 57 | name: error.name, 58 | stack: error.stack, 59 | }, 60 | }) 61 | } 62 | } 63 | }) 64 | -------------------------------------------------------------------------------- /common/constants.ts: -------------------------------------------------------------------------------- 1 | export const BEATMAP_URL_REGEX = /^https?:\/\/(osu|new).ppy.sh\/([bs]|beatmapsets)\/(\d+)\/?(#(osu|taiko|fruits|mania)\/(\d+))?/i 2 | -------------------------------------------------------------------------------- /common/styles/fonts.sass: -------------------------------------------------------------------------------- 1 | @font-face 2 | font-family: 'Exo 2' 3 | font-style: normal 4 | font-weight: 600 5 | src: local('Exo 2 Semi Bold'), local('Exo2-SemiBold'), url('/assets/fonts/Exo2-SemiBold.woff2') format('woff2') 6 | 7 | @font-face 8 | font-family: 'Exo 2' 9 | font-style: normal 10 | font-weight: 400 11 | src: local('Exo 2 Regular'), local('Exo2-Regular'), url('/assets/fonts/Exo2-Regular.woff2') format('woff2') 12 | 13 | @font-face 14 | font-family: 'Exo' 15 | font-style: normal 16 | font-weight: 600 17 | src: local('Exo Semi Bold'), local('Exo-SemiBold'), url('/assets/fonts/Exo-SemiBold.ttf') format('truetype') 18 | 19 | @font-face 20 | font-family: 'Exo' 21 | font-style: normal 22 | font-weight: 400 23 | src: local('Exo Regular'), local('Exo-Regular'), url('/assets/fonts/Exo-Regular.ttf') format('truetype') 24 | 25 | @font-face 26 | font-family: 'Other Icons' 27 | font-style: normal 28 | font-weight: 400 29 | src: url('/assets/fonts/Other-Icons.woff2')format('woff2') 30 | 31 | .icon-other 32 | font-family: 'Other Icons' 33 | font-weight: normal 34 | font-style: normal 35 | font-size: 24px 36 | line-height: 1 37 | letter-spacing: normal 38 | text-transform: none 39 | display: inline-block 40 | white-space: nowrap 41 | word-wrap: normal 42 | direction: ltr 43 | -webkit-font-feature-settings: 'liga' 44 | -webkit-font-smoothing: antialiased 45 | 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ezpp!", 3 | "version": "1.10.2", 4 | "description": "Browser extension for calculating pp!", 5 | "main": "popup/index.js", 6 | "scripts": { 7 | "start": "cross-env NODE_ENV=development webpack --config webpack.config.js -w", 8 | "start:chrome": "cross-env BUILD_CHROME=1 npm start", 9 | "start:firefox": "cross-env BUILD_FF=1 npm start", 10 | "build": "rm -rf build/ && cross-env NODE_ENV=production webpack --config webpack.config.js", 11 | "build:chrome": "cross-env BUILD_CHROME=1 npm run build && (cd build; zip -qr ../ezpp-chrome.zip *) && rm -rf build/", 12 | "build:firefox": "cross-env BUILD_FF=1 npm run build && (cd build; zip -qr ../ezpp-firefox.zip *) && rm -rf build/", 13 | "build:all": "rm -f ezpp-chrome.zip ezpp-firefox.zip && npm run build:chrome && npm run build:firefox", 14 | "lint": "eslint .", 15 | "lint:fix": "eslint . --fix" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/oamaok/ezpp.git" 20 | }, 21 | "author": "Teemu Pääkkönen ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/oamaok/ezpp/issues" 25 | }, 26 | "homepage": "https://github.com/oamaok/ezpp#readme", 27 | "typings": "./typings.d.ts", 28 | "devDependencies": { 29 | "@types/chrome": "^0.0.133", 30 | "@typescript-eslint/eslint-plugin": "^4.19.0", 31 | "@typescript-eslint/parser": "^4.19.0", 32 | "copy-webpack-plugin": "^8.0.0", 33 | "cross-env": "^7.0.3", 34 | "css-loader": "^5.1.3", 35 | "eslint": "^7.22.0", 36 | "eslint-config-prettier": "^8.1.0", 37 | "eslint-plugin-prefer-arrow": "^1.2.3", 38 | "eslint-plugin-prettier": "^3.3.1", 39 | "file-loader": "^6.2.0", 40 | "material-design-icons": "^3.0.1", 41 | "mini-css-extract-plugin": "^1.3.9", 42 | "ojsama": "^2.2.0", 43 | "prettier": "^2.2.1", 44 | "regenerator-runtime": "^0.13.7", 45 | "sass": "^1.32.8", 46 | "sass-loader": "^11.0.1", 47 | "ts-loader": "^8.0.18", 48 | "typescript": "^4.2.3", 49 | "webpack": "^5.27.0", 50 | "webpack-cli": "^4.5.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /popup/analytics.ts: -------------------------------------------------------------------------------- 1 | window._gaq = [] 2 | 3 | // @ts-ignore 4 | if (__DEV__) { 5 | window._gaq.push = (data) => { 6 | // eslint-disable-next-line no-console 7 | console.log('Analytics event:', JSON.stringify(data, null, 2)) 8 | return 0 9 | } 10 | } 11 | 12 | let analyticsLoaded = false 13 | 14 | export const loadAnalytics = () => { 15 | if (__FIREFOX__ || __DEV__ || analyticsLoaded) { 16 | return 17 | } 18 | _gaq.push(['_setAccount', 'UA-77789641-4']) 19 | _gaq.push(['_trackPageview']) 20 | 21 | const ga = document.createElement('script') 22 | ga.type = 'text/javascript' 23 | ga.async = true 24 | ga.src = 'https://ssl.google-analytics.com/ga.js' 25 | const s = document.getElementsByTagName('script')[0] 26 | s.parentNode?.insertBefore(ga, s) 27 | 28 | analyticsLoaded = true 29 | } 30 | -------------------------------------------------------------------------------- /popup/calculators/standard.ts: -------------------------------------------------------------------------------- 1 | import ojsama from 'ojsama' 2 | 3 | const calculateDTAR = (ms: number): number => { 4 | if (ms < 300) { 5 | return 11 // with DT, the AR is capped at 11 6 | } 7 | if (ms < 1200) { 8 | return 11 - (ms - 300) / 150 9 | } 10 | return 5 - (ms - 1200) / 120 11 | } 12 | 13 | export const calculateApproachRate = ( 14 | modifiers: number, 15 | ar: number 16 | ): number => { 17 | let ms: number 18 | switch ( 19 | modifiers & 20 | (ojsama.modbits.ht | 21 | ojsama.modbits.dt | 22 | ojsama.modbits.ez | 23 | ojsama.modbits.hr) 24 | ) { 25 | case ojsama.modbits.hr: 26 | return Math.min(10, ar * 1.4) 27 | case ojsama.modbits.ez: 28 | return ar / 2 29 | 30 | case ojsama.modbits.dt + ojsama.modbits.hr: { 31 | if (ar < 4) { 32 | ms = 1200 - 112 * ar 33 | } else if (ar > 4) { 34 | ms = 740 - 140 * (ar - 4) 35 | } else { 36 | ms = 864 - 124 * (ar - 3) 37 | } 38 | return calculateDTAR(ms) 39 | } 40 | case ojsama.modbits.dt + ojsama.modbits.ez: 41 | return calculateDTAR(1200 - 40 * ar) 42 | 43 | case ojsama.modbits.dt: 44 | return calculateDTAR(ar > 5 ? 200 + (11 - ar) * 100 : 800 + (5 - ar) * 80) 45 | case ojsama.modbits.ht: { 46 | if (ar <= 5) return (1600 - (600 + 160 * (10 - ar))) / 120 47 | ms = 600 + (10 - ar) * 200 48 | if (ms >= 1200) return 15 - ms / 120 49 | return 13 - ms / 150 50 | } 51 | 52 | case ojsama.modbits.ht + ojsama.modbits.hr: { 53 | if (ar > 7) return 8.5 54 | if (ar < 4) { 55 | ms = 2700 - 252 * ar 56 | } else if (ar < 5) { 57 | ms = 1944 - 279 * (ar - 3) 58 | } else { 59 | ms = 1665 - 315 * (ar - 4) 60 | } 61 | if (ar < 6) { 62 | return 15 - ms / 120 63 | } 64 | if (ar > 7) { 65 | return 13 - ms / 150 66 | } 67 | return 15 - ms / 120 68 | } 69 | case ojsama.modbits.ht + ojsama.modbits.ez: 70 | return (1800 - (1600 + 80 * (10 - ar))) / 120 71 | 72 | default: 73 | return ar 74 | } 75 | } 76 | 77 | export const calculatePerformance = ( 78 | map: ojsama.beatmap, 79 | mods: number, 80 | combo: number, 81 | misses: number, 82 | accuracy: number 83 | ): { 84 | pp: ojsama.std_ppv2 85 | stars: ojsama.std_diff 86 | } => { 87 | const stars = new ojsama.diff().calc({ map, mods }) 88 | 89 | const pp = ojsama.ppv2({ 90 | stars, 91 | combo, 92 | nmiss: misses, 93 | acc_percent: accuracy, 94 | }) 95 | 96 | return { pp, stars } 97 | } 98 | -------------------------------------------------------------------------------- /popup/calculators/taiko.ts: -------------------------------------------------------------------------------- 1 | import ojsama, { beatmap } from 'ojsama' 2 | import TaikoDifficultyAttributes from '../objects/taiko/taikoDifficultyAttributes' 3 | import TaikoDifficultyHitObject from '../objects/taiko/taikoDifficultyHitObject' 4 | import TaikoObject from '../objects/taiko/taikoObject' 5 | import Skill from '../skills/skill' 6 | import StaminaCheeseDetector from '../skills/taiko/staminaCheeseDetector' 7 | import Colour from '../skills/taiko/colour' 8 | import Rhythm from '../skills/taiko/rhythm' 9 | import Stamina from '../skills/taiko/stamina' 10 | import * as taikoConverter from '../converters/taiko' 11 | import { ParsedTaikoResult } from '../objects/taiko/parsedTaikoResult' 12 | import Mth from '../util/mth' 13 | import { beatmaps } from '../util/beatmaps' 14 | import { ObjectType } from '../objects/taiko/objectType' 15 | 16 | export const GREAT_MIN = 50 17 | export const GREAT_MID = 35 18 | export const GREAT_MAX = 20 19 | 20 | export const COLOUR_SKILL_MULTIPLIER = 0.01 21 | export const RHYTHM_SKILL_MULTIPLIER = 0.014 22 | export const STAMINA_SKILL_MULTIPLIER = 0.02 23 | 24 | export const createDifficultyHitObjects = ( 25 | map: beatmap, 26 | parsedTaikoResult: ParsedTaikoResult, 27 | clockRate: number, 28 | convert: boolean, 29 | mods: number 30 | ): { 31 | objects: Array 32 | rawObjects: Array 33 | } => { 34 | let rawTaikoObjects: Array 35 | if (convert) { 36 | rawTaikoObjects = parsedTaikoResult.objects 37 | .sort((a, b) => a.time - b.time) 38 | .map((obj) => 39 | new TaikoObject( 40 | beatmaps.getHitObjectOrDefaultAt(map, obj.time, obj), 41 | obj.objectType, 42 | obj.hitType, 43 | obj.hitSounds, 44 | obj.edgeSounds 45 | ).setSpinnerEndTime(obj.spinnerEndTime) 46 | ) 47 | } else { 48 | rawTaikoObjects = map.objects.map((obj, i) => 49 | new TaikoObject( 50 | obj, 51 | parsedTaikoResult.objects[i].objectType, 52 | parsedTaikoResult.objects[i].hitType, 53 | parsedTaikoResult.objects[i].hitSounds, 54 | parsedTaikoResult.objects[i].edgeSounds 55 | ).setSpinnerEndTime(parsedTaikoResult.objects[i].spinnerEndTime) 56 | ) 57 | } 58 | const rawObjects = taikoConverter.convertHitObjects( 59 | rawTaikoObjects, 60 | map, 61 | mods, 62 | !convert 63 | ) 64 | const objects = rawObjects.flatMap((obj, i) => 65 | i < 2 66 | ? [] 67 | : new TaikoDifficultyHitObject( 68 | obj, 69 | rawObjects[i - 1], 70 | rawObjects[i - 2], 71 | clockRate, 72 | i 73 | ) 74 | ) 75 | // Remember: objects.length = rawObjects.length - 2 76 | if (objects.length + 2 !== rawObjects.length) 77 | throw new Error( 78 | `objects count and raw objects count does not match: ${objects.length}, ${rawObjects.length}` 79 | ) 80 | new StaminaCheeseDetector(objects).findCheese() // this method name makes me hungry... 81 | return { objects, rawObjects } 82 | } 83 | 84 | export const calculate = ( 85 | map: beatmap, 86 | mods: number, 87 | parsedTaikoResult: ParsedTaikoResult, 88 | convert: boolean 89 | ) => { 90 | const originalOverallDifficulty = map.od 91 | let clockRate = 1 92 | if (mods & ojsama.modbits.dt) clockRate = 1.5 93 | if (mods & ojsama.modbits.ht) clockRate = 0.75 94 | if (mods & ojsama.modbits.hr) { 95 | const ratio = 1.4 96 | map.od = Math.min(map.od * ratio, 10.0) 97 | } 98 | if (mods & ojsama.modbits.ez) { 99 | const ratio = 0.5 100 | map.od *= ratio 101 | } 102 | 103 | const skills = [ 104 | new Colour(mods), 105 | new Rhythm(mods), 106 | new Stamina(mods, true), 107 | new Stamina(mods, false), 108 | ] 109 | if (map.objects.length === 0) 110 | return createDifficultyAttributes(map, mods, skills, clockRate, []) 111 | 112 | const difficultyHitObjects = createDifficultyHitObjects( 113 | map, 114 | parsedTaikoResult, 115 | clockRate, 116 | convert, 117 | mods 118 | ) 119 | const sectionLength = 400 * clockRate 120 | let currentSectionEnd = 121 | Math.ceil(map.objects[0].time / sectionLength) * sectionLength 122 | 123 | difficultyHitObjects.objects.forEach((h) => { 124 | while (h.baseObject.time > currentSectionEnd) { 125 | skills.forEach((s) => { 126 | s.saveCurrentPeak() 127 | s.startNewSectionFrom(currentSectionEnd) 128 | }) 129 | 130 | currentSectionEnd += sectionLength 131 | } 132 | 133 | skills.forEach((s) => s.process(h)) 134 | }) 135 | 136 | // The peak strain will not be saved for the last section in the above loop 137 | skills.forEach((s) => s.saveCurrentPeak()) 138 | 139 | const attr = createDifficultyAttributes( 140 | map, 141 | mods, 142 | skills, 143 | clockRate, 144 | difficultyHitObjects.rawObjects 145 | ) 146 | map.od = originalOverallDifficulty 147 | return attr 148 | } 149 | 150 | export const createDifficultyAttributes = ( 151 | map: beatmap, 152 | mods: number, 153 | skills: Array>, 154 | clockRate: number, 155 | rawObjects: Array 156 | ): TaikoDifficultyAttributes => { 157 | if (map.objects.length === 0) { 158 | return new TaikoDifficultyAttributes(0, mods, 0, 0, 0, 0, 0, skills) 159 | } 160 | const colour = skills[0] as Colour 161 | const rhythm = skills[1] as Rhythm 162 | const staminaRight = skills[2] as Stamina 163 | const staminaLeft = skills[3] as Stamina 164 | 165 | const colourRating = colour.getDifficultyValue() * COLOUR_SKILL_MULTIPLIER 166 | const rhythmRating = rhythm.getDifficultyValue() * RHYTHM_SKILL_MULTIPLIER 167 | let staminaRating = 168 | (staminaRight.getDifficultyValue() + staminaLeft.getDifficultyValue()) * 169 | STAMINA_SKILL_MULTIPLIER 170 | 171 | const staminaPenalty = simpleColourPenalty(staminaRating, colourRating) 172 | staminaRating *= staminaPenalty 173 | 174 | const combinedRating = locallyCombinedDifficulty( 175 | colour, 176 | rhythm, 177 | staminaRight, 178 | staminaLeft, 179 | staminaPenalty 180 | ) 181 | const separatedRating = norm(1.5, colourRating, rhythmRating, staminaRating) 182 | let starRating = 1.4 * separatedRating + 0.5 * combinedRating 183 | starRating = rescale(starRating) 184 | 185 | const greatHitWindow = 186 | Mth.difficultyRange(map.od, GREAT_MIN, GREAT_MID, GREAT_MAX) | 0 187 | 188 | return new TaikoDifficultyAttributes( 189 | starRating, 190 | mods, 191 | staminaRating, 192 | rhythmRating, 193 | colourRating, 194 | greatHitWindow / clockRate, 195 | rawObjects.filter((obj) => obj.objectType === ObjectType.Hit).length, // max combo 196 | skills 197 | ) 198 | } 199 | 200 | export const simpleColourPenalty = ( 201 | staminaDifficulty: number, 202 | colorDifficulty: number 203 | ): number => { 204 | if (colorDifficulty <= 0) return 0.79 - 0.25 205 | return ( 206 | 0.79 - Math.atan(staminaDifficulty / colorDifficulty - 12) / Math.PI / 2 207 | ) 208 | } 209 | 210 | export const norm = (p: number, ...values: Array) => { 211 | let e = 0 212 | values.forEach((n) => { 213 | e += Math.pow(n, p) 214 | }) 215 | return Math.pow(e, 1 / p) 216 | } 217 | 218 | export const locallyCombinedDifficulty = ( 219 | colour: Colour, 220 | rhythm: Rhythm, 221 | staminaRight: Stamina, 222 | staminaLeft: Stamina, 223 | staminaPenalty: number 224 | ) => { 225 | const peaks = colour.strainPeaks.map((colour, i) => { 226 | const colourPeak = colour * COLOUR_SKILL_MULTIPLIER 227 | const rhythmPeak = rhythm.strainPeaks[i] * RHYTHM_SKILL_MULTIPLIER 228 | const staminaPeak = 229 | (staminaRight.strainPeaks[i] + staminaLeft.strainPeaks[i]) * 230 | STAMINA_SKILL_MULTIPLIER * 231 | staminaPenalty 232 | return norm(2, colourPeak, rhythmPeak, staminaPeak) 233 | }) 234 | let difficulty = 0 235 | let weight = 1 236 | peaks 237 | .sort((a, b) => b - a) 238 | .forEach((strain) => { 239 | difficulty += strain * weight 240 | weight *= 0.9 241 | }) 242 | return difficulty 243 | } 244 | 245 | export const rescale = (sr: number): number => 246 | sr < 0 ? sr : 10.43 * Math.log(sr / 8 + 1) 247 | 248 | // javascript implementation of osu!lazer's pp calculator implementation: https://github.com/ppy/osu/blob/master/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs 249 | export const calculatePerformance = ( 250 | map: beatmap, 251 | attr: TaikoDifficultyAttributes, 252 | mods: number, 253 | combo: number, 254 | misses: number, 255 | accuracy: number 256 | ): { 257 | total: number 258 | strain: number 259 | accuracy: number 260 | } => { 261 | let multiplier = 1.1 262 | if (mods & ojsama.modbits.nf) multiplier *= 0.9 263 | if (mods & ojsama.modbits.hd) multiplier *= 1.1 264 | const strainValue = calculateStrainPerformance( 265 | attr.starRating, 266 | mods, 267 | misses, 268 | accuracy / 100, 269 | combo 270 | ) 271 | const greatHitWindow = 272 | attr.greatHitWindow !== -1 273 | ? attr.greatHitWindow 274 | : Mth.difficultyRange(map.od, GREAT_MIN, GREAT_MID, GREAT_MAX) | 0 275 | const accuracyValue = calculateAccuracyPerformance( 276 | greatHitWindow, 277 | accuracy / 100, 278 | combo 279 | ) 280 | const total = 281 | Math.pow( 282 | Math.pow(strainValue, 1.1) + Math.pow(accuracyValue, 1.1), 283 | 1.0 / 1.1 284 | ) * multiplier 285 | return { total, strain: strainValue, accuracy: accuracyValue } 286 | } 287 | 288 | export const calculateStrainPerformance = ( 289 | stars: number, 290 | mods: number, 291 | misses: number, 292 | accuracy: number, 293 | combo: number 294 | ): number => { 295 | let strainValue = 296 | Math.pow(5.0 * Math.max(1.0, stars / 0.0075) - 4.0, 2.0) / 100000.0 297 | const lengthBonus = 1 + 0.1 * Math.min(1.0, combo / 1500.0) 298 | strainValue *= lengthBonus 299 | strainValue *= Math.pow(0.985, misses) 300 | if (mods & ojsama.modbits.hd) strainValue *= 1.025 301 | if (mods & ojsama.modbits.fl) strainValue *= 1.05 * lengthBonus 302 | return strainValue * accuracy 303 | } 304 | 305 | export const calculateAccuracyPerformance = ( 306 | greatHitWindow: number, 307 | accuracy: number, 308 | combo: number 309 | ): number => { 310 | if (greatHitWindow <= 0) return 0 311 | const accValue = 312 | Math.pow(150.0 / greatHitWindow, 1.1) * Math.pow(accuracy, 15) * 22.0 313 | return accValue * Math.min(1.15, Math.pow(combo / 1500.0, 0.3)) 314 | } 315 | -------------------------------------------------------------------------------- /popup/converters/taiko.ts: -------------------------------------------------------------------------------- 1 | import TaikoObject from '../objects/taiko/taikoObject' 2 | import ojsama, { slider } from 'ojsama' 3 | import { beatmaps } from '../util/beatmaps' 4 | import { Precision } from '../util/precision' 5 | import { ObjectType } from '../objects/taiko/objectType' 6 | import Mth from '../util/mth' 7 | import Swell from '../objects/taiko/swell' 8 | import { HitType } from '../objects/taiko/hitType' 9 | import Console from '../util/console' 10 | 11 | // Do NOT remove Math.fround, this is VERY IMPORTANT. 12 | // taiko conversion will fail if you remove Math.fround. 13 | export const LEGACY_VELOCITY_MULTIPLIER = Math.fround(1.4) 14 | export const BASE_SCORING_DISTANCE = Math.fround(100) 15 | export const SWELL_HIT_MULTIPLIER = Math.fround(1.65) 16 | 17 | export const convertHitObjects = ( 18 | objects: Array, 19 | map: ojsama.beatmap, 20 | mods: number, 21 | isForCurrentRuleset: boolean 22 | ): Array => { 23 | const result = objects.flatMap((obj) => 24 | convertHitObject(obj, map, mods, isForCurrentRuleset) 25 | ) 26 | return result.sort((a, b) => a.time - b.time) 27 | } 28 | 29 | export const convertHitObject = ( 30 | obj: TaikoObject, 31 | map: ojsama.beatmap, 32 | mods: number, 33 | isForCurrentRuleset: boolean 34 | ): Array => { 35 | const result: Array = [] 36 | // const strong = obj.hitSounds & 4 // we don't need this thing 37 | if (obj.type & ojsama.objtypes.slider) { 38 | const res = shouldConvertSliderToHits(obj, map, mods, isForCurrentRuleset) 39 | if (res.shouldConvertSliderToHits) { 40 | let i = 0 41 | for ( 42 | let j = obj.time; 43 | j <= obj.time + res.taikoDuration + res.tickSpacing / 8; 44 | j += res.tickSpacing 45 | ) { 46 | let hitSounds = obj.edgeSounds[i] || 0 47 | const hitType = 48 | hitSounds & 8 || hitSounds & 2 ? HitType.Rim : HitType.Centre 49 | const taikoObject = new TaikoObject( 50 | { 51 | time: j, 52 | type: obj.type, 53 | typestr: obj.typestr, 54 | }, 55 | ObjectType.Hit, 56 | hitType, 57 | obj.hitSounds, 58 | [] 59 | ) 60 | taikoObject.time = j 61 | result.push(taikoObject) 62 | 63 | i = (i + 1) % obj.edgeSounds.length 64 | 65 | if (Precision.almostEquals(0, res.tickSpacing)) break 66 | } 67 | } else { 68 | const taikoObject = new TaikoObject( 69 | obj.hitObject, 70 | ObjectType.DrumRoll, 71 | obj.hitType, 72 | obj.hitSounds, 73 | [] 74 | ) 75 | const sl = obj.data as slider 76 | taikoObject.data = { 77 | pos: sl.pos, 78 | distance: sl.distance, 79 | repetitions: sl.repetitions, 80 | } 81 | result.push(taikoObject) 82 | } 83 | } else if (obj.type & ojsama.objtypes.spinner) { 84 | const hitMultiplier = 85 | Mth.difficultyRange(Math.fround(map.od), 3, 5, 7.5) * SWELL_HIT_MULTIPLIER 86 | 87 | const swell = new Swell(obj.hitObject, obj.hitType, obj.hitSounds) 88 | swell.duration = swell.spinnerEndTime! - swell.time 89 | swell.requiredHits = 90 | Math.max(1, (swell.duration / 1000) * hitMultiplier) | 0 91 | result.push(swell) 92 | } else { 93 | result.push(obj) // obj type is circle 94 | } 95 | return result 96 | } 97 | 98 | export const shouldConvertSliderToHits = ( 99 | obj: TaikoObject, 100 | map: ojsama.beatmap, 101 | mods: number, 102 | isForCurrentRuleset: boolean 103 | ) => { 104 | // DO NOT CHANGE OR REFACTOR ANYTHING IN HERE WITHOUT TESTING AGAINST _ALL_ BEATMAPS. 105 | // Some of these calculations look redundant, but they are not - extremely small floating point errors are introduced to maintain 1:1 compatibility with stable. 106 | // Rounding cannot be used as an alternative since the error deltas have been observed to be between 1e-2 and 1e-6. 107 | // You should not remove Math.fround, or you'll see wrong result! 108 | 109 | const slider = obj.hitObject.data as ojsama.slider 110 | 111 | // The true distance, accounting for any repeats. This ends up being the drum roll distance later 112 | const spans = slider.repetitions ?? 1 113 | const distance = slider.distance * spans * LEGACY_VELOCITY_MULTIPLIER 114 | 115 | const ms_per_beat = beatmaps.getMsPerBeatAt(map, obj.time) 116 | const speedMultiplier = beatmaps.getSpeedMultiplierAt(map, obj.time) 117 | 118 | let beatLength = ms_per_beat / speedMultiplier 119 | 120 | let sliderScoringPointDistance = 121 | (BASE_SCORING_DISTANCE * (map.sv * LEGACY_VELOCITY_MULTIPLIER)) / 122 | map.tick_rate 123 | 124 | const taikoVelocity = sliderScoringPointDistance * map.tick_rate 125 | const taikoDuration = ((distance / taikoVelocity) * beatLength) | 0 126 | 127 | if (isForCurrentRuleset) { 128 | return { 129 | shouldConvertSliderToHits: false, 130 | taikoDuration, 131 | tickSpacing: 0, 132 | } 133 | } 134 | 135 | const osuVelocity = taikoVelocity * (1000 / beatLength) 136 | 137 | const bL2 = beatLength 138 | if (map.format_version >= 8) { 139 | beatLength = ms_per_beat 140 | } 141 | 142 | const tickSpacing = Math.min( 143 | beatLength / map.tick_rate, 144 | taikoDuration / spans 145 | ) 146 | 147 | return { 148 | shouldConvertSliderToHits: 149 | tickSpacing > 0 && (distance / osuVelocity) * 1000 < 2 * beatLength, 150 | taikoDuration, 151 | tickSpacing, 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /popup/index.ts: -------------------------------------------------------------------------------- 1 | import ojsama from 'ojsama' 2 | 3 | import manifest from '../static/manifest.json' 4 | import { setLanguage, createTextSetter } from './translations' 5 | import { loadSettings, onSettingsChange } from './settings' 6 | import { BEATMAP_URL_REGEX } from '../common/constants' 7 | import { loadAnalytics } from './analytics' 8 | import * as std from './calculators/standard' 9 | import * as taiko from './calculators/taiko' 10 | import * as taikoReader from './objects/taiko/taikoReader' 11 | import * as timingsReader from './util/timingsReader' 12 | import { ParsedTaikoResult } from './objects/taiko/parsedTaikoResult' 13 | import { PageInfo } from './util/pageInfo' 14 | import Mth from './util/mth' 15 | import { TimingPoint } from './util/timingPoint' 16 | import Console from './util/console' 17 | 18 | require('./notifications') 19 | 20 | const FETCH_ATTEMPTS = 3 21 | const UNSUPPORTED_GAMEMODE = 'Unsupported gamemode!' // TODO: Add to translations 22 | 23 | const containerElement = document.getElementById('container')! 24 | const headerElement = document.getElementById('header')! 25 | const versionElement = document.querySelector('.version') as HTMLElement 26 | const titleElement = document.querySelector('.song-title') as HTMLElement 27 | const artistElement = document.querySelector('.artist') as HTMLElement 28 | const fcResetButton = document.querySelector('.fc-reset') as HTMLElement 29 | const difficultyNameElement = document.getElementById( 30 | 'difficulty-name' 31 | ) as HTMLElement 32 | const difficultyStarsElement = document.getElementById( 33 | 'difficulty-stars' 34 | ) as HTMLElement 35 | // @ts-ignore 36 | const modifierElements = [...document.querySelectorAll('.mod>input')] 37 | const accuracyElement = document.getElementById('accuracy') as HTMLFormElement 38 | const comboElement = document.getElementById('combo') as HTMLFormElement 39 | const missesElement = document.getElementById('misses') as HTMLFormElement 40 | const resultElement = document.getElementById('result') as HTMLElement 41 | const errorElement = document.getElementById('error') as HTMLElement 42 | const bpmElement = document.getElementById('bpm') as HTMLElement 43 | const arElement = document.getElementById('ar') as HTMLElement 44 | 45 | const setResultText = createTextSetter(resultElement, 'result') 46 | 47 | versionElement.innerText = `ezpp! v${manifest.version}` 48 | 49 | // Set after the extension initializes, used for additional error information. 50 | let currentUrl: string 51 | let cleanBeatmap: ojsama.beatmap 52 | let parsedTaikoResult: ParsedTaikoResult 53 | let timingPoints: Array 54 | let pageInfo: PageInfo 55 | 56 | const keyModMap: { [key: string]: string } = { 57 | Q: 'mod-ez', 58 | W: 'mod-nf', 59 | E: 'mod-ht', 60 | A: 'mod-hr', 61 | D: 'mod-dt', 62 | F: 'mod-hd', 63 | G: 'mod-fl', 64 | C: 'mod-so', 65 | } 66 | 67 | const MODE_STANDARD = 0 68 | const MODE_TAIKO = 1 69 | const MODE_CATCH = 2 70 | const MODE_MANIA = 3 71 | 72 | const setSongDetails = (metadataInOriginalLanguage: boolean) => { 73 | if (!cleanBeatmap) return 74 | 75 | const { artist, artist_unicode, title, title_unicode } = cleanBeatmap 76 | titleElement.innerText = metadataInOriginalLanguage 77 | ? title_unicode || title 78 | : title 79 | artistElement.innerText = metadataInOriginalLanguage 80 | ? artist_unicode || artist 81 | : artist 82 | } 83 | 84 | const getMaxCombo = () => { 85 | if (!cleanBeatmap) return -1 86 | if (cleanBeatmap.mode === MODE_STANDARD) { 87 | return cleanBeatmap.max_combo() 88 | } 89 | if (cleanBeatmap.mode === MODE_TAIKO) { 90 | return pageInfo.beatmap.max_combo || 0 91 | } 92 | 93 | return -1 94 | } 95 | 96 | const getCalculationSettings = () => { 97 | // Bitwise OR the mods together 98 | const modifiers: number = modifierElements.reduce( 99 | (num, element) => num | (element.checked ? parseInt(element.value) : 0), 100 | 0 101 | ) 102 | 103 | // An error might be reported before the beatmap is loaded. 104 | const maxCombo = getMaxCombo() 105 | 106 | const accuracy = Mth.clamp( 107 | parseFloat((accuracyElement.value || '0').replace(',', '.')), 108 | 0, 109 | 100 110 | ) 111 | const combo = Mth.clamp( 112 | parseInt(comboElement.value) || maxCombo, 113 | 0, 114 | maxCombo || 0 115 | ) 116 | const misses = Mth.clamp(parseInt(missesElement.value) || 0, 0, maxCombo) 117 | 118 | return { 119 | modifiers, 120 | accuracy, 121 | combo, 122 | misses, 123 | } 124 | } 125 | 126 | const trackError = (error: ErrorEvent | Error): any => { 127 | // Don't report unsupported gamemode errors. 128 | if (error.message === UNSUPPORTED_GAMEMODE) { 129 | return 130 | } 131 | const name = error instanceof ErrorEvent ? error.error.name : error.name 132 | const stack = error instanceof ErrorEvent ? error.error.stack : error.stack 133 | Console.error(stack) 134 | 135 | const report = { 136 | version: manifest.version, 137 | url: currentUrl, 138 | calculationSettings: getCalculationSettings(), 139 | pageInfo, 140 | 141 | error: { 142 | message: error.message, 143 | // @ts-ignore 144 | arguments: error.arguments, 145 | // @ts-ignore 146 | type: error.type, 147 | name, 148 | stack, 149 | }, 150 | 151 | navigator: { 152 | userAgent: window.navigator.userAgent, 153 | }, 154 | } 155 | 156 | _gaq.push(['_trackEvent', 'error', JSON.stringify(report)]) 157 | } 158 | 159 | const displayError = (error: Error) => { 160 | trackError(error) 161 | errorElement.innerText = error.message 162 | containerElement.classList.toggle('error', true) 163 | containerElement.classList.toggle('preloading', false) 164 | } 165 | 166 | const debounce = ( 167 | fn: (args: Array) => void, 168 | timeout: number 169 | ): ((args: any) => void) => { 170 | let debounceTimeout: number 171 | 172 | return (...args) => { 173 | clearTimeout(debounceTimeout) 174 | // @ts-ignore it's actually number 175 | debounceTimeout = setTimeout(() => fn(...args), timeout) 176 | } 177 | } 178 | 179 | const trackCalculate = (() => { 180 | let lastData: { [key: string]: any } = {} 181 | 182 | return (analyticsData: { [key: string]: any }) => { 183 | // Don't repeat calculation analytics 184 | const isClean = Object.keys(analyticsData).every( 185 | (key) => lastData[key] === analyticsData[key] 186 | ) 187 | if (isClean) return 188 | 189 | lastData = { ...analyticsData } 190 | 191 | _gaq.push(['_trackEvent', 'calculate', JSON.stringify(analyticsData)]) 192 | } 193 | })() 194 | 195 | const trackCalculateDebounced = debounce(trackCalculate, 500) 196 | 197 | const calculate = () => { 198 | try { 199 | const { modifiers, accuracy, combo, misses } = getCalculationSettings() 200 | 201 | let bpmMultiplier = 1 202 | if (modifiers & ojsama.modbits.dt) bpmMultiplier = 1.5 203 | if (modifiers & ojsama.modbits.ht) bpmMultiplier = 0.75 204 | const msPerBeat = cleanBeatmap.timing_points[0].ms_per_beat 205 | const bpm = (1 / msPerBeat) * 1000 * 60 * bpmMultiplier 206 | 207 | let stars = { total: 0 } 208 | let pp 209 | 210 | switch (cleanBeatmap.mode) { 211 | case MODE_STANDARD: 212 | document.documentElement.classList.add('mode-standard') 213 | const stdResult = std.calculatePerformance( 214 | cleanBeatmap, 215 | modifiers, 216 | combo, 217 | misses, 218 | accuracy 219 | ) 220 | pp = stdResult.pp 221 | stars = stdResult.stars 222 | arElement.innerText = 223 | cleanBeatmap.ar === undefined 224 | ? '?' 225 | : ( 226 | Math.round( 227 | std.calculateApproachRate(modifiers, cleanBeatmap.ar) * 10 228 | ) / 10 229 | ).toString() 230 | break 231 | 232 | case MODE_TAIKO: 233 | document.documentElement.classList.add('mode-taiko') 234 | const attr = taiko.calculate( 235 | cleanBeatmap, 236 | modifiers, 237 | parsedTaikoResult, 238 | !!pageInfo.convert 239 | ) 240 | Console.log('osu!taiko star rating calculation result:', attr) 241 | pageInfo.beatmap.max_combo = attr.maxCombo 242 | resetCombo() // we changed max combo above, so we need to apply changes here. 243 | stars = { total: attr.starRating } 244 | pp = taiko.calculatePerformance( 245 | cleanBeatmap, 246 | attr, 247 | modifiers, 248 | combo, 249 | misses, 250 | accuracy 251 | ) 252 | break 253 | 254 | default: 255 | } 256 | if (!pp) throw new Error('pp is null') // this shouldn't happen 257 | 258 | const { beatmapId } = pageInfo 259 | 260 | const analyticsData = { 261 | beatmapId: parseInt(beatmapId), 262 | modifiers, 263 | accuracy, 264 | combo, 265 | misses, 266 | stars: parseFloat(stars.total.toFixed(1)), 267 | pp: parseFloat(pp.total.toFixed(2)), 268 | } 269 | 270 | // Track results 271 | trackCalculateDebounced(analyticsData) 272 | 273 | difficultyStarsElement.innerText = stars.total.toFixed(2) 274 | bpmElement.innerText = (Math.round(bpm * 10) / 10).toString() 275 | 276 | setResultText(Math.round(pp.total)) 277 | } catch (error) { 278 | displayError(error) 279 | } 280 | } 281 | 282 | const opposingModifiers = [ 283 | ['mod-hr', 'mod-ez'], 284 | ['mod-ht', 'mod-dt'], 285 | ] 286 | 287 | const toggleOpposingModifiers = (mod: string) => { 288 | opposingModifiers.forEach((mods) => { 289 | const index = mods.indexOf(mod) 290 | if (index !== -1) { 291 | const name = mods[1 - index] 292 | modifierElements.find(({ id }) => id === name).checked = false 293 | } 294 | }) 295 | } 296 | 297 | const resetCombo = () => { 298 | comboElement.value = getMaxCombo() 299 | } 300 | 301 | const fetchBeatmapById = (id: number) => 302 | fetch(`https://osu.ppy.sh/osu/${id}`, { 303 | credentials: 'include', 304 | }).then((res) => res.text()) 305 | 306 | const getPageInfo = (url: string, tabId: number): Promise => 307 | new Promise((resolve, reject) => { 308 | const info = { 309 | isOldSite: false, 310 | beatmapSetId: '', 311 | beatmapId: '', 312 | stars: 0, 313 | beatmap: {}, 314 | mode: '', 315 | convert: {}, 316 | } 317 | 318 | const match = url.match(BEATMAP_URL_REGEX)! 319 | info.isOldSite = match[2] !== 'beatmapsets' 320 | 321 | if (!info.isOldSite) { 322 | const mode = match[5] 323 | const beatmapId = match[6] 324 | 325 | info.mode = mode 326 | info.beatmapSetId = match[3] 327 | info.beatmapId = beatmapId 328 | 329 | chrome.tabs.sendMessage( 330 | tabId, 331 | { action: 'GET_BEATMAP_STATS', beatmapId, mode }, 332 | (response) => { 333 | if (!response) { 334 | // FIXME(acrylic-style): I don't know why but it happened to me multiple times 335 | reject(new Error('Empty response from content script')) 336 | return 337 | } 338 | 339 | if (response.status === 'ERROR') { 340 | reject(response.error) 341 | return 342 | } 343 | 344 | info.beatmap = response.beatmap 345 | info.convert = response.convert 346 | // @ts-ignore 347 | resolve(info) 348 | } 349 | ) 350 | } else { 351 | // Fetch data from the content script so we don't need to fetch the page 352 | // second time. 353 | chrome.tabs.sendMessage( 354 | tabId, 355 | { action: 'GET_BEATMAP_INFO' }, 356 | (response) => { 357 | if (response.status === 'ERROR') { 358 | reject(response.error) 359 | return 360 | } 361 | 362 | const { beatmapId, beatmapSetId } = response 363 | info.beatmapSetId = beatmapSetId 364 | info.beatmapId = beatmapId 365 | // @ts-ignore 366 | resolve(info) 367 | } 368 | ) 369 | } 370 | }) 371 | 372 | const attemptToFetchBeatmap = (id: number, attempts: number): Promise => 373 | fetchBeatmapById(id).catch((error) => { 374 | // Retry fetching until no attempts are left. 375 | if (attempts) return attemptToFetchBeatmap(id, attempts - 1) 376 | 377 | throw error 378 | }) 379 | 380 | const processBeatmap = (rawBeatmap: string) => { 381 | const { map } = new ojsama.parser().feed(rawBeatmap) 382 | parsedTaikoResult = taikoReader.feed(rawBeatmap, map.mode !== MODE_TAIKO) 383 | timingPoints = timingsReader.feed(rawBeatmap) 384 | 385 | cleanBeatmap = map 386 | 387 | // Support old beatmaps 388 | cleanBeatmap.mode = Number(cleanBeatmap.mode || MODE_STANDARD) 389 | if (pageInfo.mode === 'taiko') cleanBeatmap.mode = MODE_TAIKO 390 | if (pageInfo.mode === 'fruits') cleanBeatmap.mode = MODE_CATCH 391 | if (pageInfo.mode === 'mania') cleanBeatmap.mode = MODE_MANIA 392 | 393 | if (cleanBeatmap.mode !== MODE_STANDARD && cleanBeatmap.mode !== MODE_TAIKO) { 394 | throw Error(UNSUPPORTED_GAMEMODE) 395 | } 396 | } 397 | 398 | const fetchBeatmapBackground = ( 399 | beatmapSetId: string 400 | ): Promise => 401 | new Promise((resolve) => { 402 | // Preload beatmap cover 403 | const cover = new Image() 404 | cover.src = `https://assets.ppy.sh/beatmaps/${beatmapSetId}/covers/cover@2x.jpg` 405 | cover.onload = () => resolve(cover) 406 | cover.onerror = () => resolve(null) 407 | cover.onabort = () => resolve(null) 408 | }) 409 | 410 | const handleSettings = (settings: { [key: string]: any }) => { 411 | document.documentElement.classList.toggle('darkmode', settings.darkmode) 412 | 413 | setLanguage(settings.language) 414 | 415 | setSongDetails(settings.metadataInOriginalLanguage) 416 | 417 | if (settings.analytics) { 418 | loadAnalytics() 419 | } 420 | } 421 | 422 | const initializeExtension = async ({ 423 | url: tabUrl, 424 | id: tabId, 425 | }: { 426 | url?: string 427 | id?: number 428 | }) => { 429 | try { 430 | const settings = await loadSettings() 431 | 432 | handleSettings(settings) 433 | onSettingsChange(handleSettings) 434 | 435 | currentUrl = tabUrl! 436 | pageInfo = await getPageInfo(tabUrl!, tabId!) 437 | 438 | const [, backgroundImage] = await Promise.all([ 439 | attemptToFetchBeatmap(parseInt(pageInfo.beatmapId), FETCH_ATTEMPTS).then( 440 | processBeatmap 441 | ), 442 | fetchBeatmapBackground(pageInfo.beatmapSetId), 443 | ]) 444 | 445 | // Set header background 446 | if (backgroundImage) { 447 | headerElement.style.backgroundImage = `url('${backgroundImage.src}')` 448 | } 449 | 450 | // Set header content 451 | setSongDetails(settings.metadataInOriginalLanguage) 452 | difficultyNameElement.innerText = cleanBeatmap.version 453 | 454 | // Display content since we're done loading all the stuff. 455 | containerElement.classList.toggle('preloading', false) 456 | 457 | modifierElements.forEach((modElement) => { 458 | modElement.addEventListener( 459 | 'click', 460 | ({ target }: { target: Element }) => { 461 | toggleOpposingModifiers(target.id) 462 | calculate() 463 | } 464 | ) 465 | }) 466 | 467 | window.addEventListener('keydown', ({ key = '' }) => { 468 | const mod = keyModMap[key.toUpperCase()] 469 | 470 | if (mod) { 471 | const element = modifierElements.find(({ id }) => id === mod) 472 | element.checked = !element.checked 473 | 474 | toggleOpposingModifiers(mod) 475 | calculate() 476 | } 477 | }) 478 | 479 | accuracyElement.addEventListener('input', calculate) 480 | comboElement.addEventListener('input', calculate) 481 | missesElement.addEventListener('input', calculate) 482 | 483 | fcResetButton.addEventListener('click', () => { 484 | resetCombo() 485 | calculate() 486 | }) 487 | 488 | // Set the combo to the max combo by default 489 | resetCombo() 490 | 491 | calculate() 492 | } catch (err) { 493 | displayError(err) 494 | } 495 | } 496 | 497 | // Track errors with GA 498 | window.addEventListener('error', trackError) 499 | 500 | if (__FIREFOX__) { 501 | containerElement.classList.toggle('firefox', true) 502 | document.documentElement.classList.toggle('firefox', true) 503 | } 504 | 505 | chrome.tabs.query( 506 | { 507 | active: true, // Select active tabs 508 | lastFocusedWindow: true, // In the current window 509 | }, 510 | ([tab]) => { 511 | initializeExtension(tab) 512 | } 513 | ) 514 | -------------------------------------------------------------------------------- /popup/notifications.ts: -------------------------------------------------------------------------------- 1 | import manifest from '../static/manifest.json' 2 | import { createTextSetter } from './translations' 3 | 4 | const notificationElement = document.getElementById('notification')! 5 | const notificationClearElement = document.getElementById('notification-clear')! 6 | const versionElement = document.getElementById('notification-version')! 7 | 8 | const setVersionText = createTextSetter( 9 | versionElement, 10 | 'version-update-message' 11 | ) 12 | 13 | const clearNotification = () => { 14 | chrome.storage.local.set({ 15 | displayNotification: false, 16 | }) 17 | 18 | notificationElement.classList.toggle('hidden', true) 19 | } 20 | 21 | // Clear the notification 22 | notificationClearElement.addEventListener('click', clearNotification) 23 | 24 | // Version change detection 25 | chrome.storage.local.get( 26 | ['version', 'displayNotification', 'updatedAt'], 27 | ({ version, displayNotification, updatedAt = 0 }) => { 28 | setVersionText(manifest.version) 29 | 30 | const now = new Date().getTime() 31 | 32 | // First time using the extension, set the version 33 | // but don't display notifications 34 | if (!version) { 35 | chrome.storage.local.set({ 36 | version: manifest.version, 37 | updatedAt: now, 38 | displayNotification: false, 39 | }) 40 | return 41 | } 42 | 43 | // Update detected, show notification and set the version 44 | if (version !== manifest.version) { 45 | chrome.storage.local.set({ 46 | version: manifest.version, 47 | updatedAt: now, 48 | displayNotification: true, 49 | }) 50 | notificationElement.classList.toggle('hidden', false) 51 | } else { 52 | // Display the notification for max one hour 53 | const dayAfterUpdate = updatedAt + 60 * 60 * 1000 54 | if (dayAfterUpdate < now) { 55 | clearNotification() 56 | } 57 | 58 | if (displayNotification) { 59 | notificationElement.classList.toggle('hidden', false) 60 | } 61 | } 62 | } 63 | ) 64 | -------------------------------------------------------------------------------- /popup/objects/difficultyHitObject.ts: -------------------------------------------------------------------------------- 1 | import { hitobject } from 'ojsama' 2 | 3 | export default class DifficultyHitObject { 4 | public time: number 5 | public baseObject: hitobject 6 | public lastObject: hitobject 7 | public deltaTime: number 8 | 9 | /** 10 | * @param clockRate The rate at which the gameplay clock is run at. 11 | */ 12 | constructor(baseObject: hitobject, lastObject: hitobject, clockRate: number) { 13 | if (!baseObject) throw new Error('baseObject cannot be null') 14 | if (!lastObject) throw new Error('lastObject cannot be null') 15 | this.time = baseObject.time 16 | this.baseObject = baseObject 17 | this.lastObject = lastObject 18 | this.deltaTime = (baseObject.time - lastObject.time) / clockRate 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /popup/objects/hitObject.ts: -------------------------------------------------------------------------------- 1 | import { hitobject } from 'ojsama' 2 | 3 | export default class HitObject { 4 | public hitObject: hitobject 5 | public hitSounds: number 6 | public nestedHitObjects = new Array() 7 | 8 | constructor(hitObject: hitobject, hitSounds: number) { 9 | this.hitObject = hitObject 10 | this.hitSounds = hitSounds 11 | } 12 | 13 | public createNestedHitObjects(): void {} 14 | 15 | public addNested(hitObject: HitObject) { 16 | this.nestedHitObjects.push(hitObject) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /popup/objects/sliderEventDescriptor.ts: -------------------------------------------------------------------------------- 1 | import { SliderEventType } from './sliderEventType' 2 | 3 | export default class SliderEventDescriptor { 4 | public type: SliderEventType 5 | public time: number 6 | public spanIndex: number 7 | public spanStartTime: number 8 | public pathProgress: number 9 | 10 | public constructor( 11 | type: SliderEventType, 12 | time: number, 13 | spanIndex: number, 14 | spanStartTime: number, 15 | pathProgress: number 16 | ) { 17 | this.type = type 18 | this.time = time 19 | this.spanIndex = spanIndex 20 | this.spanStartTime = spanStartTime 21 | this.pathProgress = pathProgress 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /popup/objects/sliderEventGenerator.ts: -------------------------------------------------------------------------------- 1 | import Mth from '../util/mth' 2 | import SliderEventDescriptor from './sliderEventDescriptor' 3 | import { SliderEventType } from './sliderEventType' 4 | 5 | // comment from osu!lazer: 6 | // A very lenient maximum length of a slider for ticks to be generated. 7 | // This exists for edge cases such as b/1573664 where the beatmap has been edited by the user, and should never be reached in normal usage. 8 | export const MAX_LENGTH = 100000 9 | 10 | export default class SliderEventGenerator { 11 | public static generate( 12 | startTime: number, 13 | spanDuration: number, 14 | velocity: number, 15 | tickDistance: number, 16 | totalDistance: number, 17 | spanCount: number, 18 | legacyLastTickOffset?: number 19 | ): Array { 20 | const result: Array = [] 21 | 22 | const length = Math.min(MAX_LENGTH, totalDistance) 23 | tickDistance = Mth.clamp(tickDistance, 0, length) 24 | 25 | const minDistanceFromEnd = velocity * 10 26 | 27 | result.push( 28 | new SliderEventDescriptor( 29 | SliderEventType.Head, 30 | 0, 31 | startTime, 32 | startTime, 33 | 0 34 | ) 35 | ) 36 | 37 | if (tickDistance !== 0) { 38 | for (let span = 0; span < spanCount; span++) { 39 | const spanStartTime = startTime + span * spanDuration 40 | const reversed = span % 2 === 1 41 | 42 | const ticks: Array = SliderEventGenerator.generateTicks( 43 | span, 44 | spanStartTime, 45 | spanDuration, 46 | reversed, 47 | length, 48 | tickDistance, 49 | minDistanceFromEnd 50 | ) 51 | 52 | if (reversed) ticks.reverse() 53 | 54 | result.push(...ticks) 55 | 56 | if (span < spanCount - 1) { 57 | result.push( 58 | new SliderEventDescriptor( 59 | SliderEventType.Repeat, 60 | span, 61 | startTime + span * spanDuration, 62 | spanStartTime + spanDuration, 63 | (span + 1) % 2 64 | ) 65 | ) 66 | } 67 | } 68 | } 69 | 70 | const totalDuration = spanCount * spanDuration 71 | 72 | // Okay, I'll level with you. I made a mistake. It was 2007. 73 | // Times were simpler. osu! was but in its infancy and sliders were a new concept. 74 | // A hack was made, which has unfortunately lived through until this day. 75 | // 76 | // This legacy tick is used for some calculations and judgements where audio output is not required. 77 | // Generally we are keeping this around just for difficulty compatibility. 78 | // Optimistically we do not want to ever use this for anything user-facing going forwards. 79 | 80 | const finalSpanIndex = spanCount - 1 81 | const finalSpanStartTime = startTime + finalSpanIndex * spanDuration 82 | const finalSpanEndTime = Math.max( 83 | startTime + totalDuration / 2, 84 | finalSpanStartTime + spanDuration - (legacyLastTickOffset ?? 0) 85 | ) 86 | let finalProgress = (finalSpanEndTime - finalSpanStartTime) / spanDuration 87 | 88 | if (spanCount % 2 === 0) finalProgress = 1 - finalProgress 89 | 90 | result.push( 91 | new SliderEventDescriptor( 92 | SliderEventType.LegacyLastTick, 93 | finalSpanIndex, 94 | finalSpanStartTime, 95 | finalSpanEndTime, 96 | finalProgress 97 | ) 98 | ) 99 | 100 | result.push( 101 | new SliderEventDescriptor( 102 | SliderEventType.Tail, 103 | finalSpanIndex, 104 | startTime + (spanCount - 1) * spanDuration, 105 | startTime + totalDuration, 106 | spanCount % 2 107 | ) 108 | ) 109 | 110 | return result 111 | } 112 | 113 | public static generateTicks( 114 | spanIndex: number, 115 | spanStartTime: number, 116 | spanDuration: number, 117 | reversed: boolean, 118 | length: number, 119 | tickDistance: number, 120 | minDistanceFromEnd: number 121 | ): Array { 122 | const result: Array = [] 123 | for (let d = tickDistance; d < length; d += tickDistance) { 124 | if (d >= length - minDistanceFromEnd) break 125 | const pathProgress = d / length 126 | const timeProgress = reversed ? 1 - pathProgress : pathProgress 127 | result.push( 128 | new SliderEventDescriptor( 129 | SliderEventType.Tick, 130 | spanIndex, 131 | spanStartTime, 132 | spanStartTime + timeProgress * spanDuration, 133 | pathProgress 134 | ) 135 | ) 136 | } 137 | return result 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /popup/objects/sliderEventType.ts: -------------------------------------------------------------------------------- 1 | export enum SliderEventType { 2 | Tick, 3 | LegacyLastTick, 4 | Head, 5 | Tail, 6 | Repeat, 7 | } 8 | -------------------------------------------------------------------------------- /popup/objects/taiko/hitType.ts: -------------------------------------------------------------------------------- 1 | export enum HitType { 2 | Centre, 3 | Rim, 4 | } 5 | -------------------------------------------------------------------------------- /popup/objects/taiko/objectType.ts: -------------------------------------------------------------------------------- 1 | export enum ObjectType { 2 | Hit, // = Hit (Circle) 3 | DrumRoll, // = Slider 4 | Swell, // = Spinner 5 | SwellTick, // = ? 6 | } 7 | 8 | export namespace ObjectType { 9 | export const fromNumber = (num: number): ObjectType => { 10 | if (num & 1) return ObjectType.Hit 11 | if (num & 2) return ObjectType.DrumRoll 12 | if (num & 12) return ObjectType.Swell 13 | return ObjectType.Hit // defaults to Hit 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /popup/objects/taiko/parsedTaikoObject.ts: -------------------------------------------------------------------------------- 1 | import { HitType } from './hitType' 2 | import { ObjectType } from './objectType' 3 | 4 | export type ParsedTaikoObject = { 5 | time: number 6 | type: number 7 | hitSounds: number 8 | objectType: ObjectType 9 | hitType: HitType 10 | spinnerEndTime?: number 11 | edgeSounds: Array 12 | typestr: () => string 13 | } 14 | -------------------------------------------------------------------------------- /popup/objects/taiko/parsedTaikoResult.ts: -------------------------------------------------------------------------------- 1 | import { ParsedTaikoObject } from './parsedTaikoObject' 2 | 3 | export type ParsedTaikoResult = { 4 | objects: Array 5 | } 6 | -------------------------------------------------------------------------------- /popup/objects/taiko/swell.ts: -------------------------------------------------------------------------------- 1 | import { hitobject } from 'ojsama' 2 | import { HitType } from './hitType' 3 | import { ObjectType } from './objectType' 4 | import TaikoObject from './taikoObject' 5 | 6 | export default class Swell extends TaikoObject { 7 | public requiredHits = 10 8 | public duration: number 9 | 10 | public constructor( 11 | hitObject: hitobject, 12 | hitType: HitType, 13 | hitSounds: number 14 | ) { 15 | super(hitObject, ObjectType.Swell, hitType, hitSounds, []) 16 | } 17 | 18 | public createNestedHitObjects(): void { 19 | super.createNestedHitObjects() 20 | for (let i = 0; i < this.requiredHits; i++) { 21 | this.addNested( 22 | new TaikoObject( 23 | this.hitObject, 24 | ObjectType.SwellTick, 25 | this.hitType, 26 | this.hitSounds, 27 | [] 28 | ) 29 | ) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /popup/objects/taiko/taikoDifficultyAttributes.ts: -------------------------------------------------------------------------------- 1 | import Skill from '../../skills/skill' 2 | import TaikoDifficultyHitObject from './taikoDifficultyHitObject' 3 | 4 | export default class TaikoDifficultyAttributes { 5 | public starRating: number 6 | public mods: number 7 | public staminaStrain: number 8 | public rhythmStrain: number 9 | public colourStrain: number 10 | public greatHitWindow: number 11 | public maxCombo: number 12 | public skills: Array> 13 | 14 | constructor( 15 | starRating: number, 16 | mods: number, 17 | staminaStrain: number, 18 | rhythmStrain: number, 19 | colourStrain: number, 20 | greatHitWindow: number, 21 | maxCombo: number, 22 | skills: Array> 23 | ) { 24 | this.starRating = starRating 25 | this.mods = mods 26 | this.staminaStrain = staminaStrain 27 | this.rhythmStrain = rhythmStrain 28 | this.colourStrain = colourStrain 29 | this.greatHitWindow = greatHitWindow 30 | this.maxCombo = maxCombo 31 | this.skills = skills 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /popup/objects/taiko/taikoDifficultyHitObject.ts: -------------------------------------------------------------------------------- 1 | import DifficultyHitObject from '../difficultyHitObject' 2 | import TaikoDifficultyHitObjectRhythm from './taikoDifficultyHitObjectRhythm' 3 | import TaikoObject from './taikoObject' 4 | import { HitType } from './hitType' 5 | import Arrays from '../../util/arrays' 6 | 7 | export const COMMON_RHYTHMS = [ 8 | new TaikoDifficultyHitObjectRhythm(1, 1, 0.0), 9 | new TaikoDifficultyHitObjectRhythm(2, 1, 0.3), 10 | new TaikoDifficultyHitObjectRhythm(1, 2, 0.5), 11 | new TaikoDifficultyHitObjectRhythm(3, 1, 0.3), 12 | new TaikoDifficultyHitObjectRhythm(1, 3, 0.35), 13 | new TaikoDifficultyHitObjectRhythm(3, 2, 0.6), // purposefully higher (requires hand switch in full alternating gameplay style) 14 | new TaikoDifficultyHitObjectRhythm(2, 3, 0.4), 15 | new TaikoDifficultyHitObjectRhythm(5, 4, 0.5), 16 | new TaikoDifficultyHitObjectRhythm(4, 5, 0.7), 17 | ] 18 | 19 | export default class TaikoDifficultyHitObject extends DifficultyHitObject { 20 | // overrides properties on super class 21 | public baseObject: TaikoObject 22 | public lastObject: TaikoObject 23 | public lastLastObject: TaikoObject 24 | 25 | public rhythm: TaikoDifficultyHitObjectRhythm 26 | public hitType: HitType 27 | public objectIndex: number 28 | public staminaCheese = false 29 | 30 | /** 31 | * @param clockRate 1 = 100%, 1.5 = 150% (DT), 0.75 = 75% (HT) 32 | */ 33 | constructor( 34 | baseObject: TaikoObject, 35 | lastObject: TaikoObject, 36 | lastLastObject: TaikoObject, 37 | clockRate: number, 38 | objectIndex: number 39 | ) { 40 | super(baseObject, lastObject, clockRate) 41 | this.baseObject = baseObject 42 | this.lastObject = lastObject 43 | this.lastLastObject = lastLastObject 44 | this.rhythm = this.getClosestRhythm(lastObject, lastLastObject, clockRate) 45 | this.objectIndex = objectIndex 46 | this.hitType = baseObject.hitType 47 | this.staminaCheese = false 48 | } 49 | 50 | public getClosestRhythm( 51 | lastObject: TaikoObject, 52 | lastLastObject: TaikoObject, 53 | clockRate: number 54 | ) { 55 | const prevLength = (lastObject.time - lastLastObject.time) / clockRate 56 | const ratio = this.deltaTime / prevLength 57 | 58 | return Arrays.copyArray(COMMON_RHYTHMS).sort( 59 | (a, b) => Math.abs(a.ratio - ratio) - Math.abs(b.ratio - ratio) 60 | )[0] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /popup/objects/taiko/taikoDifficultyHitObjectRhythm.ts: -------------------------------------------------------------------------------- 1 | export default class TaikoDifficultyHitObjectRhythm { 2 | public ratio: number 3 | public difficulty: number 4 | 5 | constructor(numerator: number, denominator: number, difficulty: number) { 6 | this.ratio = numerator / denominator 7 | this.difficulty = difficulty 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /popup/objects/taiko/taikoObject.ts: -------------------------------------------------------------------------------- 1 | import { circle, hitobject, slider } from 'ojsama' 2 | import HitObject from '../hitObject' 3 | import { HitType } from './hitType' 4 | import { ObjectType } from './objectType' 5 | 6 | export default class TaikoObject extends HitObject { 7 | public objectType: ObjectType 8 | public hitType: HitType 9 | public type: number 10 | public time: number 11 | public data?: circle | slider 12 | public spinnerEndTime?: number 13 | public edgeSounds: Array 14 | 15 | public constructor( 16 | hitObject: hitobject, 17 | objectType: ObjectType, 18 | hitType: HitType, 19 | hitSounds: number, 20 | edgeSounds: Array 21 | ) { 22 | super(hitObject, hitSounds) 23 | this.objectType = objectType 24 | this.hitType = hitType 25 | this.type = this.hitObject.type 26 | this.time = this.hitObject.time 27 | this.data = this.hitObject.data 28 | this.edgeSounds = edgeSounds 29 | } 30 | 31 | public setSpinnerEndTime(spinnerEndTime?: number): TaikoObject { 32 | this.spinnerEndTime = spinnerEndTime 33 | return this 34 | } 35 | 36 | public typestr(): string { 37 | return this.hitObject.typestr() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /popup/objects/taiko/taikoReader.ts: -------------------------------------------------------------------------------- 1 | import Console from '../../util/console' 2 | import { HitType } from './hitType' 3 | import { ObjectType } from './objectType' 4 | import { ParsedTaikoObject } from './parsedTaikoObject' 5 | import { ParsedTaikoResult } from './parsedTaikoResult' 6 | 7 | // x,y,time,type,hit sounds,extra 8 | export const REGEX = /^(\d+),(\d+),(\d+),(\d+),(\d+),?(.*?)?,?/ 9 | // ^ ^ ^ ^ ^ ^ 10 | // x[1] y[2] time[3] | | extra[6] 11 | // type[4] | 12 | // hitSounds[5] 13 | 14 | export const SLIDER_REGEX = /^(\d+),(\d+),(\d+),(\d+),(\d+),(.*?),(\d+),(.*?),(.*?),/ 15 | // ^ ^ ^ ^ ^ ^ ^ ^ ^ 16 | // x[1] y[2] time[3] | | extra[6] | length[8] | 17 | // type[4] | slides[7] edgeSounds[9] 18 | // hitSounds[5] 19 | // extra: curveType|curvePoints 20 | // curveType: character 21 | // curvePoints: pipe-separated list of strings 22 | // slides: integer 23 | // length: decimal 24 | // edgeSounds: pipe-separated list of integers 25 | 26 | export const feed = ( 27 | rawBeatmap: string, 28 | convert: boolean 29 | ): ParsedTaikoResult => { 30 | const objects = [] as Array 31 | let doRead = false 32 | rawBeatmap.split('\n').forEach((s) => { 33 | if (s.startsWith('[HitObjects]')) { 34 | doRead = true 35 | return 36 | } 37 | if (!doRead || s.length === 0) return 38 | let isSlider = convert && s.includes('|') 39 | let match = isSlider ? s.match(SLIDER_REGEX) : s.match(REGEX) 40 | if (!match) { 41 | if (isSlider) { 42 | match = s.match(REGEX) 43 | isSlider = false 44 | } 45 | if (!match) { 46 | Console.warn('Did not match the regex for the input: ' + s) // can be useful for debugging 47 | return 48 | } 49 | } 50 | try { 51 | const time: number = parseInt(match[3]) 52 | const type: number = parseInt(match[4]) 53 | const hitSounds: number = parseInt(match[5]) 54 | const extra: string | undefined = match[6] 55 | const objectType = ObjectType.fromNumber(type) 56 | let spinnerEndTime: number | undefined 57 | if (objectType === ObjectType.Swell) { 58 | spinnerEndTime = parseInt(extra) 59 | } 60 | const edgeSounds = new Array() 61 | if (isSlider) { 62 | edgeSounds.push(...match[9].split('|').map((s) => parseInt(s))) 63 | } 64 | /* 65 | * type (ObjectType, equivalent to ojsama.) 66 | * & 1 = circle 67 | * & 2 = slider 68 | * & 12 = spinner 69 | * 70 | * hitSounds 71 | * 0 = red (centre) 72 | * 4 = big red (centre) 73 | * 8 = blue (rim) 74 | * 12 = big blue (rim) 75 | * 76 | * or: 77 | * 0 = red (dons) (centre) 78 | * & 4 = big 79 | * & 8 = blue (kats) (rim) 80 | */ 81 | objects.push({ 82 | time, 83 | type, 84 | hitSounds, 85 | objectType, 86 | hitType: hitSounds & 8 || hitSounds & 2 ? HitType.Rim : HitType.Centre, 87 | spinnerEndTime, 88 | edgeSounds, 89 | typestr: () => type.toString(), // we don't use typestr anyway 90 | }) 91 | } catch (e) { 92 | Console.error('Error trying to read "' + s + '"') 93 | throw e 94 | } 95 | }) 96 | return { objects } 97 | } 98 | -------------------------------------------------------------------------------- /popup/settings.ts: -------------------------------------------------------------------------------- 1 | const SETTINGS = [ 2 | { 3 | key: 'language', 4 | element: document.getElementById('language-selector'), 5 | type: 'string', 6 | default: 'en', 7 | }, 8 | { 9 | key: 'metadataInOriginalLanguage', 10 | element: document.getElementById('metadata-in-original-language-toggle'), 11 | type: 'boolean', 12 | default: false, 13 | }, 14 | { 15 | key: 'darkmode', 16 | element: document.getElementById('darkmode-toggle'), 17 | type: 'boolean', 18 | default: false, 19 | }, 20 | { 21 | key: 'analytics', 22 | element: document.getElementById('analytics-toggle'), 23 | type: 'boolean', 24 | default: !__FIREFOX__, 25 | }, 26 | ] 27 | 28 | let currentSettings: Record = {} 29 | const settingsChangeListeners: Array<(settings: {}) => void> = [] 30 | 31 | SETTINGS.forEach((setting) => { 32 | // Initialize currentSettings to default values 33 | currentSettings[setting.key] = setting.default 34 | 35 | // Add event listeneres for all the setting elements 36 | setting.element?.addEventListener('change', (evt) => { 37 | let value 38 | if (setting.type === 'boolean') 39 | value = (evt.target as HTMLFormElement)?.checked 40 | if (setting.type === 'string') 41 | value = (evt.target as HTMLFormElement)?.value 42 | 43 | chrome.storage.local.set({ [setting.key]: value }) 44 | currentSettings[setting.key] = value 45 | 46 | settingsChangeListeners.forEach((fn) => fn(currentSettings)) 47 | }) 48 | }) 49 | 50 | export const loadSettings = async () => { 51 | const keys = SETTINGS.map((setting) => setting.key) 52 | currentSettings = await new Promise((resolve) => 53 | chrome.storage.local.get(keys, (storedSettings) => { 54 | const settings: { [key: string]: any } = {} 55 | SETTINGS.forEach((setting) => { 56 | const value = storedSettings[setting.key] ?? setting.default // TODO(acrylic-style) 2021-03-24: fixed potential typo: settings -> setting - remove this comment if you're ok with this 57 | if (setting.type === 'string') 58 | (setting.element as HTMLFormElement).value = value 59 | if (setting.type === 'boolean') 60 | (setting.element as HTMLFormElement).checked = value 61 | settings[setting.key] = value 62 | }) 63 | resolve(settings) 64 | }) 65 | ) 66 | 67 | return currentSettings 68 | } 69 | 70 | export const onSettingsChange = (fn: (settings: {}) => void) => { 71 | settingsChangeListeners.push(fn) 72 | } 73 | 74 | const settingsOpenButton = document.getElementById('open-settings')! 75 | const settingsCloseButton = document.getElementById('close-settings')! 76 | const settingsContainer = document.getElementById('settings')! 77 | 78 | settingsOpenButton.addEventListener('click', () => { 79 | _gaq.push(['_trackEvent', 'settings', 'open']) 80 | settingsContainer.classList.toggle('open', true) 81 | }) 82 | 83 | settingsCloseButton.addEventListener('click', () => { 84 | _gaq.push(['_trackEvent', 'settings', 'close']) 85 | settingsContainer.classList.toggle('open', false) 86 | }) 87 | -------------------------------------------------------------------------------- /popup/skills/skill.ts: -------------------------------------------------------------------------------- 1 | import DifficultyHitObject from '../objects/difficultyHitObject' 2 | import Arrays from '../util/arrays' 3 | import LimitedCapacityStack from '../util/limitedCapacityStack' 4 | 5 | export default abstract class Skill { 6 | public readonly strainPeaks: Array = [] 7 | public abstract skillMultiplier: number 8 | public abstract strainDecayBase: number 9 | public decayWeight = 0.9 10 | #currentStrain = 1 // it's protected in osu!lazer, but we don't need protected access for now (mania will use this field though) 11 | public mods: number 12 | protected currentSectionPeak = 1 13 | protected readonly previous = new LimitedCapacityStack(2) 14 | 15 | public constructor(mods: number) { 16 | this.strainPeaks = [] 17 | this.decayWeight = 0.9 18 | this.#currentStrain = 1 19 | this.mods = mods 20 | this.currentSectionPeak = 1 21 | } 22 | 23 | public process(current: T): void { 24 | this.#currentStrain *= this.strainDecay(current.deltaTime) 25 | this.#currentStrain += this.strainValueOf(current) * this.skillMultiplier 26 | this.currentSectionPeak = Math.max( 27 | this.#currentStrain, 28 | this.currentSectionPeak 29 | ) 30 | this.previous.push(current) 31 | } 32 | 33 | public saveCurrentPeak(): void { 34 | if (this.previous.count > 0) { 35 | this.strainPeaks.push(this.currentSectionPeak) 36 | } 37 | } 38 | 39 | /** 40 | * Sets the initial strain level for a new section. 41 | * @param time The beginning of the new section in milliseconds. 42 | */ 43 | public startNewSectionFrom(time: number): void { 44 | if (this.previous.count > 0) { 45 | this.currentSectionPeak = this.getPeakStrain(time) 46 | } 47 | } 48 | 49 | /** 50 | * Retrieves the peak strain at a point in time. 51 | * @param time The time to retrieve the peak strain at. 52 | */ 53 | protected getPeakStrain(time: number): number { 54 | return ( 55 | this.#currentStrain * 56 | this.strainDecay(time - this.previous.get(0).baseObject.time) 57 | ) 58 | } 59 | 60 | public strainDecay(ms: number): number { 61 | return Math.pow(this.strainDecayBase, ms / 1000) 62 | } 63 | 64 | public abstract strainValueOf(current: T): number 65 | 66 | public getDifficultyValue(): number { 67 | let difficulty = 0 68 | let weight = 1 69 | Arrays.copyArray(this.strainPeaks) 70 | .sort((a, b) => b - a) 71 | .forEach((strain) => { 72 | difficulty += strain * weight 73 | weight *= this.decayWeight 74 | }) 75 | return difficulty 76 | } 77 | 78 | public get currentStrain() { 79 | return this.#currentStrain 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /popup/skills/taiko/colour.ts: -------------------------------------------------------------------------------- 1 | import { HitType } from '../../objects/taiko/hitType' 2 | import { ObjectType } from '../../objects/taiko/objectType' 3 | import TaikoDifficultyHitObject from '../../objects/taiko/taikoDifficultyHitObject' 4 | import LimitedCapacityQueue from '../../util/limitedCapacityQueue' 5 | import Skill from '../skill' 6 | 7 | export const MONO_HISTORY_MAX_LENGTH = 5 8 | export const MOST_RECENT_PATTERNS_TO_COMPARE = 2 9 | 10 | export default class Colour extends Skill { 11 | public skillMultiplier = 1 12 | public strainDecayBase = 0.4 13 | private readonly monoHistory = new LimitedCapacityQueue( 14 | MONO_HISTORY_MAX_LENGTH 15 | ) 16 | private previousHitType?: HitType 17 | private currentMonoLength: number = 0 18 | 19 | public constructor(mods: number) { 20 | super(mods) 21 | } 22 | 23 | public strainValueOf(current: TaikoDifficultyHitObject): number { 24 | if ( 25 | !( 26 | current.lastObject.objectType === ObjectType.Hit && 27 | current.baseObject.objectType === ObjectType.Hit && 28 | current.deltaTime < 1000 29 | ) 30 | ) { 31 | this.monoHistory.clear() 32 | 33 | const currentHit = current.baseObject 34 | if (currentHit.objectType === ObjectType.Hit) { 35 | this.currentMonoLength = 1 36 | this.previousHitType = currentHit.hitType 37 | } else { 38 | this.currentMonoLength = 0 39 | this.previousHitType = undefined 40 | } 41 | 42 | return 0.0 43 | } 44 | 45 | let objectStrain = 0.0 46 | 47 | if ( 48 | this.previousHitType != undefined && 49 | current.hitType != this.previousHitType 50 | ) { 51 | // The colour has changed. 52 | objectStrain = 1.0 53 | 54 | if (this.monoHistory.count < 2) { 55 | // There needs to be at least two streaks to determine a strain. 56 | objectStrain = 0.0 57 | } else if ( 58 | (this.monoHistory.get(this.monoHistory.count - 1) + 59 | this.currentMonoLength) % 60 | 2 == 61 | 0 62 | ) { 63 | objectStrain = 0.0 64 | } 65 | 66 | objectStrain *= this.repetitionPenalties() 67 | this.currentMonoLength = 1 68 | } else { 69 | this.currentMonoLength += 1 70 | } 71 | 72 | this.previousHitType = current.hitType 73 | return objectStrain 74 | } 75 | 76 | private repetitionPenalties(): number { 77 | let penalty = 1.0 78 | 79 | this.monoHistory.enqueue(this.currentMonoLength) 80 | 81 | for ( 82 | let start = this.monoHistory.count - MOST_RECENT_PATTERNS_TO_COMPARE - 1; 83 | start >= 0; 84 | start-- 85 | ) { 86 | if (!this.isSamePattern(start, MOST_RECENT_PATTERNS_TO_COMPARE)) continue 87 | 88 | let notesSince = 0 89 | for (let i = start; i < this.monoHistory.count; i++) { 90 | notesSince += this.monoHistory.get(i) 91 | } 92 | penalty *= this.repetitionPenalty(notesSince) 93 | break 94 | } 95 | 96 | return penalty 97 | } 98 | 99 | private isSamePattern(start: number, mostRecentPatternsToCompare: number) { 100 | for (let i = 0; i < mostRecentPatternsToCompare; i++) { 101 | if ( 102 | this.monoHistory.get(start + i) != 103 | this.monoHistory.get( 104 | this.monoHistory.count - mostRecentPatternsToCompare + i 105 | ) 106 | ) { 107 | return false 108 | } 109 | } 110 | 111 | return true 112 | } 113 | 114 | private repetitionPenalty(notesSince: number) { 115 | return Math.min(1.0, 0.032 * notesSince) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /popup/skills/taiko/rhythm.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType } from '../../objects/taiko/objectType' 2 | import TaikoDifficultyHitObject from '../../objects/taiko/taikoDifficultyHitObject' 3 | import LimitedCapacityQueue from '../../util/limitedCapacityQueue' 4 | import Mth from '../../util/mth' 5 | import Skill from '../skill' 6 | 7 | export const STRAIN_DECAY = 0.96 8 | 9 | export const RHYTHM_HISTORY_MAX_LENGTH = 8 10 | 11 | export default class Rhythm extends Skill { 12 | public rhythmHistory = new LimitedCapacityQueue( 13 | RHYTHM_HISTORY_MAX_LENGTH 14 | ) 15 | public notesSinceRhythmChange = 0 16 | public localCurrentStrain = 0 17 | public skillMultiplier = 10 18 | public strainDecayBase = 0 19 | 20 | public constructor(mods: number) { 21 | super(mods) 22 | } 23 | 24 | public strainValueOf(current: TaikoDifficultyHitObject): number { 25 | if (current.baseObject.objectType !== ObjectType.Hit) { 26 | this.resetRhythmAndStrain() 27 | return 0.0 28 | } 29 | 30 | this.localCurrentStrain *= STRAIN_DECAY 31 | 32 | this.notesSinceRhythmChange += 1 33 | 34 | // rhythm difficulty zero (due to rhythm not changing) => no rhythm strain. 35 | if (current.rhythm.difficulty === 0.0) { 36 | return 0.0 37 | } 38 | 39 | let objectStrain = current.rhythm.difficulty 40 | 41 | objectStrain *= this.repetitionPenalties(current) 42 | objectStrain *= this.patternLengthPenalty(this.notesSinceRhythmChange) 43 | objectStrain *= this.speedPenalty(current.deltaTime) 44 | 45 | // careful - needs to be done here since calls above read this value 46 | this.notesSinceRhythmChange = 0 47 | 48 | this.localCurrentStrain += objectStrain 49 | return this.localCurrentStrain 50 | } 51 | 52 | private repetitionPenalties(hitObject: TaikoDifficultyHitObject): number { 53 | let penalty = 1.0 54 | 55 | this.rhythmHistory.enqueue(hitObject) 56 | 57 | for ( 58 | let mostRecentPatternsToCompare = 2; 59 | mostRecentPatternsToCompare <= RHYTHM_HISTORY_MAX_LENGTH / 2; 60 | mostRecentPatternsToCompare++ 61 | ) { 62 | for ( 63 | let start = this.rhythmHistory.count - mostRecentPatternsToCompare - 1; 64 | start >= 0; 65 | start-- 66 | ) { 67 | if (!this.isSamePattern(start, mostRecentPatternsToCompare)) continue 68 | 69 | const notesSince = 70 | hitObject.objectIndex - this.rhythmHistory.get(start).objectIndex 71 | penalty *= this.repetitionPenalty(notesSince) 72 | break 73 | } 74 | } 75 | return penalty 76 | } 77 | 78 | private isSamePattern( 79 | start: number, 80 | mostRecentPatternsToCompare: number 81 | ): boolean { 82 | for (let i = 0; i < mostRecentPatternsToCompare; i++) { 83 | if ( 84 | this.rhythmHistory.get(start + i).rhythm != 85 | this.rhythmHistory.get( 86 | this.rhythmHistory.count - mostRecentPatternsToCompare + i 87 | ).rhythm 88 | ) 89 | return false 90 | } 91 | return true 92 | } 93 | 94 | private repetitionPenalty(notesSince: number): number { 95 | return Math.min(1.0, 0.032 * notesSince) 96 | } 97 | 98 | private patternLengthPenalty(patternLength: number): number { 99 | const shortPatternPenalty = Math.min(0.15 * patternLength, 1.0) 100 | const longPatternPenalty = Mth.clamp(2.5 - 0.15 * patternLength, 0.0, 1.0) 101 | return Math.min(shortPatternPenalty, longPatternPenalty) 102 | } 103 | 104 | private speedPenalty(deltaTime: number): number { 105 | if (deltaTime < 80) return 1 106 | if (deltaTime < 210) return Math.max(0, 1.4 - 0.005 * deltaTime) 107 | 108 | this.resetRhythmAndStrain() 109 | return 0.0 110 | } 111 | 112 | private resetRhythmAndStrain(): void { 113 | this.localCurrentStrain = 0.0 114 | this.notesSinceRhythmChange = 0 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /popup/skills/taiko/stamina.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType } from '../../objects/taiko/objectType' 2 | import TaikoDifficultyHitObject from '../../objects/taiko/taikoDifficultyHitObject' 3 | import LimitedCapacityQueue from '../../util/limitedCapacityQueue' 4 | import Skill from '../skill' 5 | 6 | export const HISTORY_MAX_LENGTH = 2 7 | 8 | export default class Stamina extends Skill { 9 | private readonly notePairDurationHistory = new LimitedCapacityQueue( 10 | HISTORY_MAX_LENGTH 11 | ) 12 | private offhandObjectDuration = 1.7976931348623157e308 13 | private readonly hand: number 14 | public skillMultiplier = 1.0 15 | public strainDecayBase = 0.4 16 | 17 | public constructor(mods: number, rightHand: boolean) { 18 | super(mods) 19 | this.hand = rightHand ? 1 : 0 20 | } 21 | 22 | public strainValueOf(current: TaikoDifficultyHitObject): number { 23 | if (current.baseObject.objectType !== ObjectType.Hit) { 24 | return 0.0 25 | } 26 | 27 | if (current.objectIndex % 2 === this.hand) { 28 | let objectStrain = 1 29 | 30 | if (current.objectIndex === 1) return 1 31 | 32 | this.notePairDurationHistory.enqueue( 33 | current.deltaTime + this.offhandObjectDuration 34 | ) 35 | 36 | const shortestRecentNote = this.notePairDurationHistory.min() 37 | objectStrain += this.speedBonus(shortestRecentNote) 38 | 39 | if (current.staminaCheese) { 40 | objectStrain *= this.cheesePenalty( 41 | current.deltaTime + this.offhandObjectDuration 42 | ) 43 | } 44 | return objectStrain 45 | } 46 | 47 | this.offhandObjectDuration = current.deltaTime 48 | return 0 49 | } 50 | 51 | private cheesePenalty(notePairDuration: number): number { 52 | if (notePairDuration > 125) return 1 53 | if (notePairDuration < 100) return 0.6 54 | return 0.6 + (notePairDuration - 100) * 0.016 55 | } 56 | 57 | private speedBonus(notePairDuration: number): number { 58 | if (notePairDuration >= 200) return 0 59 | 60 | let bonus = 200 - notePairDuration 61 | bonus *= bonus 62 | return bonus / 100000 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /popup/skills/taiko/staminaCheeseDetector.ts: -------------------------------------------------------------------------------- 1 | import { HitType } from '../../objects/taiko/hitType' 2 | import TaikoDifficultyHitObject from '../../objects/taiko/taikoDifficultyHitObject' 3 | import LimitedCapacityQueue from '../../util/limitedCapacityQueue' 4 | 5 | export const ROLL_MIN_REPETITIONS = 12 6 | export const TL_MIN_REPETITIONS = 16 7 | 8 | export default class StaminaCheeseDetector { 9 | public objects: Array 10 | 11 | public constructor(objects: Array) { 12 | this.objects = objects 13 | } 14 | 15 | public findCheese(): void { 16 | this.findRolls(3) 17 | this.findRolls(4) 18 | 19 | this.findTlTap(0, HitType.Rim) 20 | this.findTlTap(1, HitType.Rim) 21 | this.findTlTap(0, HitType.Centre) 22 | this.findTlTap(1, HitType.Centre) 23 | } 24 | 25 | private findRolls(patternLength: number): void { 26 | const history = new LimitedCapacityQueue( 27 | 2 * patternLength 28 | ) 29 | 30 | // for convenience, we're tracking the index of the item *before* our suspected repeat's start, 31 | // as that index can be simply subtracted from the current index to get the number of elements in between 32 | // without off-by-one errors 33 | let indexBeforeLastRepeat = -1 34 | let lastMarkEnd = 0 35 | 36 | this.objects.forEach((obj, i) => { 37 | history.enqueue(obj) 38 | if (!history.full) { 39 | return 40 | } 41 | 42 | if (!this.containsPatternRepeat(history, patternLength)) { 43 | // we're setting this up for the next iteration, hence the +1. 44 | // right here this index will point at the queue's front (oldest item), 45 | // but that item is about to be popped next loop with an enqueue. 46 | indexBeforeLastRepeat = i - history.count + 1 47 | return 48 | } 49 | 50 | const repeatedLength = i - indexBeforeLastRepeat 51 | if (repeatedLength < ROLL_MIN_REPETITIONS) return 52 | 53 | this.markObjectsAsCheese(Math.max(lastMarkEnd, i - repeatedLength + 1), i) 54 | lastMarkEnd = i 55 | }) 56 | } 57 | 58 | // can be static 59 | private containsPatternRepeat( 60 | history: LimitedCapacityQueue, 61 | patternLength: number 62 | ): boolean { 63 | for (let j = 0; j < patternLength; j++) { 64 | if (history.get(j).hitType != history.get(j + patternLength).hitType) 65 | return false 66 | } 67 | return true 68 | } 69 | 70 | /** 71 | * Finds and marks all sequences hittable using a TL tap. 72 | */ 73 | private findTlTap(parity: number, type: HitType): void { 74 | let tlLength = -2 75 | let lastMarkEnd = 0 76 | 77 | for (let i = parity; i < this.objects.length; i += 2) { 78 | if (this.objects[i].hitType == type) { 79 | tlLength += 2 80 | } else { 81 | tlLength = -2 82 | } 83 | 84 | if (tlLength < TL_MIN_REPETITIONS) continue 85 | 86 | this.markObjectsAsCheese(Math.max(lastMarkEnd, i - tlLength + 1), i) 87 | lastMarkEnd = i 88 | } 89 | } 90 | 91 | /** 92 | * Marks all objects from start to end (inclusive) as cheese. 93 | */ 94 | private markObjectsAsCheese(start: number, end: number): void { 95 | for (let i = start; i <= end; i++) { 96 | this.objects[i].staminaCheese = true 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /popup/styles/main.sass: -------------------------------------------------------------------------------- 1 | @import '../../common/styles/fonts' 2 | @import '~material-design-icons/iconfont/material-icons.css' 3 | 4 | @import 'partials/base' 5 | @import 'modules/spinner' 6 | @import 'modules/header' 7 | @import 'modules/notification' 8 | @import 'modules/calculation-settings' 9 | @import 'modules/settings' 10 | @import 'modules/result' 11 | @import 'modules/error' -------------------------------------------------------------------------------- /popup/styles/modules/_calculation-settings.sass: -------------------------------------------------------------------------------- 1 | .calculation-settings 2 | background-image: url('/assets/background.png') 3 | .darkmode & 4 | background-image: url('/assets/background-darkmode.png') 5 | color: #f7f7f7 6 | 7 | z-index: 3 8 | position: relative 9 | box-shadow: 0 1px 4px rgba(0,0,0,.6) 10 | 11 | .numerics 12 | padding: 1em 1em 0 1em 13 | user-select: none 14 | display: flex 15 | .numeric 16 | display: flex 17 | width: 33.333% 18 | padding: 0 0.5em 19 | flex-flow: row wrap 20 | align-items: center 21 | 22 | &:first-child 23 | padding-left: 0 24 | &:last-child 25 | padding-right: 0 26 | label 27 | font-weight: 600 28 | 29 | .fc-reset 30 | position: relative 31 | border: 0 32 | border-radius: 2px 33 | background-color: #29b 34 | box-shadow: 0 2px 0 darken(#29b, 10%) 35 | color: #fff 36 | font-size: 8px 37 | font-weight: 600 38 | padding: 0 0.6em 39 | height: 1.6em 40 | margin-left: 1em 41 | &:active 42 | top: 1px 43 | box-shadow: 0 1px 0 darken(#29b, 10%) 44 | 45 | input 46 | background-color: #fff 47 | margin-top: 0.5em 48 | padding: 0.5em 49 | font-size: 1em 50 | width: 100% 51 | border: 0 52 | box-shadow: inset 0 1px 3px rgba(0,0,0,.3) 53 | border-radius: 4px 54 | 55 | .darkmode & 56 | background-color: #222 57 | box-shadow: inset 0 1px 3px rgba(0,0,0,.3) 58 | 59 | 60 | @mixin mod-badge($mod) 61 | &.mod-#{$mod} 62 | label::before 63 | background-image: url('/assets/mod-badges/' + $mod + '.png') 64 | 65 | .mods 66 | display: flex 67 | flew-flow: row 68 | flex-wrap: wrap 69 | padding-bottom: 1em 70 | 71 | .mod 72 | position: relative 73 | margin-top: 0.75em 74 | width: 25% 75 | text-align: center 76 | [type="checkbox"] 77 | position: absolute 78 | opacity: 0 79 | label 80 | display: inline-block 81 | cursor: pointer 82 | font-weight: 600 83 | user-select: none 84 | transition: all 0.1s ease-out 85 | &:hover::before 86 | opacity: 0.7 87 | filter: none 88 | label::before 89 | margin: 0 auto 90 | transition: all 0.1s ease-out 91 | content: ' ' 92 | display: inline-block 93 | width: 92px 94 | height: 68px 95 | transform: scale(0.8) 96 | opacity: 0.5 97 | filter: grayscale(100%) 98 | background-repeat: no-repeat 99 | [type="checkbox"]:checked+label 100 | .darkmode & 101 | color: #fc2 102 | color: #29b 103 | transform: scale(1.08) 104 | &::before 105 | transform: scale(0.9091) rotate(10deg) 106 | opacity: 1 107 | filter: none 108 | @include mod-badge('dt') 109 | @include mod-badge('ez') 110 | @include mod-badge('fl') 111 | @include mod-badge('hd') 112 | @include mod-badge('hr') 113 | @include mod-badge('ht') 114 | @include mod-badge('nf') 115 | @include mod-badge('so') -------------------------------------------------------------------------------- /popup/styles/modules/_error.sass: -------------------------------------------------------------------------------- 1 | .error-message 2 | display: none 3 | position: relative 4 | z-index: 3 5 | 6 | .error>.error-message 7 | display: flex 8 | flex-direction: column 9 | justify-content: center 10 | align-items: center 11 | position: absolute 12 | z-index: 4 13 | height: 100% 14 | width: 100% 15 | background-image: url('/assets/button-bg.svg') 16 | background-color: #b00 17 | background-size: cover 18 | transition: 0.2s all ease-out 19 | color: #fff 20 | font-weight: 600 21 | font-size: 1.5em 22 | padding: 2em 23 | 24 | .frown 25 | font-size: 4em 26 | margin-bottom: 0.5em -------------------------------------------------------------------------------- /popup/styles/modules/_header.sass: -------------------------------------------------------------------------------- 1 | .header 2 | background-color: #000 3 | background-position: 50% 50% 4 | background-size: cover 5 | height: 10em 6 | position: relative 7 | border-bottom: 8px solid #b17 8 | 9 | .darkmode & 10 | border-color: #88b300 11 | 12 | text-shadow: 0 1px 3px rgba(0, 0, 0, 0.75) 13 | user-select: none 14 | 15 | .icon-container 16 | position: absolute 17 | top: 0 18 | right: 0 19 | padding: 0.8em 1em 20 | 21 | .icon 22 | float: right 23 | margin-left: 0.8em 24 | border: 0 25 | color: #fff 26 | background: none 27 | transition: all 0.1s ease-out 28 | text-shadow: inherit 29 | &:hover 30 | color: #29b 31 | 32 | 33 | 34 | .gradient 35 | height: 100% 36 | width: 100% 37 | background: linear-gradient(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.75)) 38 | .version 39 | padding: 1rem 40 | position: absolute 41 | bottom: 0 42 | right: 0 43 | text-align: right 44 | color: #7f7f7f 45 | font-size: 0.75em 46 | .song-info 47 | width: 24em 48 | height: 100% 49 | padding: 0.5em 1em 1em 1em 50 | position: absolute 51 | color: #fff 52 | 53 | display: flex 54 | flex-direction: column 55 | 56 | .song-title 57 | font-style: italic 58 | font-size: 1.5em 59 | font-weight: 600 60 | .artist 61 | font-size: 1.25em 62 | font-style: italic 63 | flex-grow: 1 64 | .difficulty 65 | display: flex 66 | align-items: center 67 | #difficulty-name 68 | margin-right: 0.5em 69 | font-size: 1em 70 | font-weight: 600 71 | .difficulty-stars, .bpm 72 | font-size: 1em 73 | display: flex 74 | align-items: center 75 | color: #fc2 76 | transition: 0.1s all ease-out 77 | i,span 78 | font-weight: 600 79 | font-size: 0.8rem 80 | i 81 | margin-right: 0.2em 82 | &.hidden 83 | opacity: 0 84 | .bpm 85 | padding-left: 0.5em 86 | 87 | -------------------------------------------------------------------------------- /popup/styles/modules/_notification.sass: -------------------------------------------------------------------------------- 1 | .notification-wrapper 2 | position: absolute 3 | z-index: 2 4 | width: 100% 5 | padding: 0.5em 6 | opacity: 1 7 | transition: 0.4s all ease-out 8 | &.hidden 9 | transform: translateY(-110%) 10 | opacity: 0 11 | 12 | .notification 13 | box-shadow: 0 1px 4px rgba(0,0,0,.6) 14 | box-sizing: border-box 15 | padding: 0.5em 3em 0.5em 0.75em 16 | overflow: hidden 17 | background-image: url('/assets/button-bg.svg') 18 | background-color: #fc2 19 | background-size: cover 20 | color: #fff 21 | font-weight: 600 22 | font-size: 1.2em 23 | border-radius: 2px 24 | text-shadow: 0 1px 3px rgba(0, 0, 0, 0.75) 25 | .clear 26 | position: absolute 27 | right: 1em 28 | top: 50% 29 | transform: translateY(-50%) 30 | cursor: pointer 31 | &:hover 32 | color: #29b -------------------------------------------------------------------------------- /popup/styles/modules/_result.sass: -------------------------------------------------------------------------------- 1 | .result 2 | position: relative 3 | top: 0 4 | box-sizing: border-box 5 | padding-top: 0.5em 6 | padding-bottom: 0.6em 7 | transition: 0.1s all ease-out 8 | overflow: hidden 9 | background-image: url('/assets/button-bg.svg') 10 | background-color: #88b300 11 | background-size: cover 12 | color: #fff 13 | font-weight: 600 14 | font-size: 2em 15 | text-align: center 16 | text-shadow: 0 1px 3px rgba(0, 0, 0, 0.75) 17 | z-index: 2 18 | box-shadow: 0 1px 4px rgba(0,0,0,.8) 19 | .darkmode & 20 | background-color: #b17 21 | -------------------------------------------------------------------------------- /popup/styles/modules/_settings.sass: -------------------------------------------------------------------------------- 1 | .settings 2 | height: 100% 3 | width: 100% 4 | position: absolute 5 | z-index: 4 6 | transform: translateX(100%) 7 | top: 0 8 | transition: 0.5s all ease 9 | 10 | background-image: url('/assets/background.png') 11 | .darkmode & 12 | background-image: url('/assets/background-darkmode.png') 13 | color: $darkmode-text 14 | 15 | padding: 1em 16 | border-left: 8px solid #29b 17 | 18 | #close-settings 19 | position: absolute 20 | top: 0 21 | right: 0 22 | padding: 0.8em 1em 23 | border: 0 24 | color: #444 25 | .darkmode & 26 | color: $darkmode-text 27 | background: none 28 | transition: all 0.1s ease-out 29 | &:hover 30 | color: #29b 31 | 32 | 33 | h1 34 | .darkmode & 35 | color: #f7f7f7 36 | color: #444 37 | margin-bottom: 1em 38 | 39 | .setting 40 | margin-bottom: 1em 41 | padding-bottom: 1em 42 | border-bottom: 2px solid #ccc 43 | display: flex 44 | label 45 | margin-right: 0.5em 46 | 47 | input[type=checkbox] 48 | position: relative 49 | top: 0.1em 50 | 51 | #language-selector 52 | max-width: 200px 53 | .darkmode & 54 | color: #333 55 | 56 | .changelog 57 | color: #29b 58 | 59 | &.open 60 | transform: none -------------------------------------------------------------------------------- /popup/styles/modules/_spinner.scss: -------------------------------------------------------------------------------- 1 | .spinner { 2 | position: absolute; 3 | top: 50%; 4 | left: 50%; 5 | margin: -8em; 6 | width: 16em; 7 | height: 16em; 8 | background-color: #b17; 9 | 10 | border-radius: 100%; 11 | -webkit-animation: sk-scaleout 0.7s infinite ease-in-out; 12 | animation: sk-scaleout 0.7s infinite ease-in-out; 13 | } 14 | 15 | @-webkit-keyframes sk-scaleout { 16 | 0% { -webkit-transform: scale(0) } 17 | 100% { 18 | -webkit-transform: scale(1.0); 19 | opacity: 0; 20 | } 21 | } 22 | 23 | @keyframes sk-scaleout { 24 | 0% { 25 | -webkit-transform: scale(0); 26 | transform: scale(0); 27 | } 100% { 28 | -webkit-transform: scale(1.0); 29 | transform: scale(1.0); 30 | opacity: 0; 31 | } 32 | } -------------------------------------------------------------------------------- /popup/styles/partials/_base.sass: -------------------------------------------------------------------------------- 1 | $darkmode-text: #eee 2 | 3 | * 4 | margin: 0 5 | padding: 0 6 | outline: 0 7 | color: inherit 8 | box-sizing: border-box 9 | 10 | html 11 | color: #222 12 | font-size: 16px 13 | font-family: 'Exo 2' 14 | &.firefox 15 | overflow: hidden 16 | 17 | // Use Exo only for Vietnamese 18 | &.lang-vi 19 | font-family: 'Exo' 20 | 21 | body 22 | background-color: #000 23 | clear: both 24 | 25 | background-image: url('/assets/background.png') 26 | .darkmode & 27 | background-image: url('/assets/background-darkmode.png') 28 | color: $darkmode-text 29 | 30 | font-family: inherit 31 | 32 | .firefox>body 33 | overflow: hidden 34 | 35 | h1,h2,h3,h4 36 | color: #f7f7f7 37 | margin-bottom: 0.5em 38 | font-weight: 700 39 | 40 | .clearfix 41 | clear: both 42 | 43 | button 44 | cursor: pointer 45 | 46 | .container 47 | position: relative 48 | width: 30em 49 | opacity: 1 50 | // For preloading 51 | transition: 0.2s opacity ease-out 52 | &.preloading 53 | opacity: 0 54 | &.error>* 55 | filter: grayscale(1) blur(2px) 56 | &.error>.error-message 57 | filter: grayscale(0) 58 | 59 | .mode-taiko .disabled-taiko 60 | display: none !important 61 | 62 | .mode-taiko-converted .disabled-taiko-converted 63 | display: none !important 64 | 65 | .firefox .disabled-firefox 66 | display: none !important -------------------------------------------------------------------------------- /popup/translations.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | import languages from '../translations/languages.json' 3 | export { languages } 4 | 5 | type TranslatableProperties = 'innerHTML' | 'innerText' | 'textContent' 6 | 7 | const FALLBACK_LANGUAGE = 'en' 8 | 9 | const languageSelector = document.getElementById('language-selector')! 10 | languages 11 | .sort((a, b) => a.name.localeCompare(b.name)) 12 | .forEach((language) => { 13 | const option = document.createElement('option') 14 | option.setAttribute('value', language.code) 15 | option.innerText = language.name 16 | languageSelector.appendChild(option) 17 | }) 18 | 19 | export const translations: Record< 20 | string, 21 | Record 22 | > = languages.reduce( 23 | (acc, lang) => ({ 24 | ...acc, 25 | [lang.code]: require(`../translations/${lang.code}.json`), 26 | }), 27 | {} 28 | ) 29 | 30 | let currentLanguage: string 31 | const setterHooks: { 32 | element: HTMLElement 33 | translationKey: string 34 | property: TranslatableProperties 35 | args: any[] 36 | }[] = [] 37 | 38 | export const getTranslation = (translationKey: string, ...args: any[]) => { 39 | const template = 40 | translations[currentLanguage || FALLBACK_LANGUAGE][translationKey] || 41 | translations[FALLBACK_LANGUAGE][translationKey] 42 | 43 | if (!args.length) return template 44 | 45 | return args.reduce( 46 | (str, arg, index) => str.replace(new RegExp(`\\{${index}\\}`, 'g'), arg), 47 | template 48 | ) 49 | } 50 | 51 | export const createTextSetter = ( 52 | element: HTMLElement, 53 | translationKey: string, 54 | property: TranslatableProperties = 'innerText' 55 | ) => { 56 | if (setterHooks.some((hook) => hook.element === element)) { 57 | throw new Error('This element already has a text setter') 58 | } 59 | 60 | const hook: { 61 | element: HTMLElement 62 | translationKey: string 63 | property: TranslatableProperties 64 | args: any[] 65 | } = { 66 | element, 67 | translationKey, 68 | property, 69 | args: [], 70 | } 71 | 72 | setterHooks.push(hook) 73 | 74 | return (...args: any[]) => { 75 | hook.args = args 76 | element[property] = getTranslation(translationKey, ...args) 77 | } 78 | } 79 | 80 | export const setLanguage = (language: string) => { 81 | if (language === currentLanguage) return 82 | 83 | if (currentLanguage) _gaq.push(['_trackEvent', 'language', language]) 84 | 85 | document.documentElement.classList.remove(`lang-${currentLanguage}`) 86 | document.documentElement.classList.add(`lang-${language}`) 87 | 88 | currentLanguage = language 89 | 90 | setterHooks.forEach(({ element, translationKey, property, args }) => { 91 | element[property] = getTranslation(translationKey, ...args) 92 | }) 93 | document.querySelectorAll('[data-t]').forEach((element) => { 94 | const translationKey = element.getAttribute('data-t')! 95 | ;(element as HTMLElement).innerText = getTranslation(translationKey) 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /popup/util/arrays.ts: -------------------------------------------------------------------------------- 1 | export default class Arrays extends Array { 2 | public constructor(...array: Array) { 3 | super(...array) 4 | } 5 | 6 | public count( 7 | filterFunction: (value: T, index: number, array: Array) => boolean 8 | ): number { 9 | return this.filter(filterFunction).length 10 | } 11 | 12 | public filter( 13 | predicate: (value: T, index: number, array: T[]) => unknown 14 | ): Arrays { 15 | return new Arrays(...super.filter(predicate)) 16 | } 17 | 18 | public map( 19 | callbackfn: (value: T, index: number, array: T[]) => U 20 | ): Arrays { 21 | return new Arrays(...super.map(callbackfn)) 22 | } 23 | 24 | public static copyArray(array: E[]): E[] { 25 | return [...array] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /popup/util/beatmap.ts: -------------------------------------------------------------------------------- 1 | export type Beatmap = { 2 | accuracy: number 3 | ar: number 4 | beatmapset_id: number 5 | bpm: number 6 | checksum: string 7 | convert: boolean 8 | count_circles: number 9 | count_sliders: number 10 | count_spinners: number 11 | cs: number 12 | deleted_at?: Date 13 | difficulty_rating: number 14 | drain: number 15 | failtimes: { 16 | fail: Array 17 | exit: Array 18 | } 19 | hit_length: number 20 | id: number 21 | is_scoreable: boolean 22 | last_updated: string 23 | max_combo: number | null 24 | mode: 'osu' | 'taiko' | 'fruits' | 'mania' 25 | mode_int: 0 | 1 | 2 | 3 26 | passcount: number 27 | playcount: number 28 | ranked: number 29 | status: 30 | | 'ranked' 31 | | 'loved' 32 | | 'graveyard' 33 | | 'wip' 34 | | 'pending' 35 | | 'qualified' 36 | | 'approved' 37 | total_length: number 38 | url: string 39 | version: string 40 | } 41 | -------------------------------------------------------------------------------- /popup/util/beatmaps.ts: -------------------------------------------------------------------------------- 1 | import { beatmap, hitobject, timing } from 'ojsama' 2 | 3 | export namespace beatmaps { 4 | export const getTimingPointAt = (map: beatmap, time: number): timing => { 5 | let value: timing = map.timing_points[0] // default 6 | map.timing_points.forEach((timing) => { 7 | if (timing.ms_per_beat < 0) return 8 | if (timing.time <= time && value.time < timing.time) { 9 | value = timing 10 | } 11 | }) 12 | return value 13 | } 14 | 15 | export const getNearestTimingPointAt = ( 16 | map: beatmap, 17 | time: number 18 | ): timing => { 19 | let value: timing = map.timing_points[0] // default 20 | map.timing_points.forEach((timing) => { 21 | if (timing.time <= time && value.time < timing.time) { 22 | value = timing 23 | } 24 | }) 25 | return value 26 | } 27 | 28 | export const getAdjustedMsPerBeatAt = ( 29 | map: beatmap, 30 | time: number 31 | ): number => { 32 | const theTiming = getNearestTimingPointAt(map, time) 33 | if (theTiming.ms_per_beat < 0) { 34 | const uninheritedTiming = getTimingPointAt(map, time) 35 | return uninheritedTiming.ms_per_beat / getSpeedMultiplierAt(map, time) 36 | } 37 | return theTiming.ms_per_beat 38 | } 39 | 40 | export const getMsPerBeatAt = (map: beatmap, time: number): number => { 41 | const theTiming = getNearestTimingPointAt(map, time) 42 | if (theTiming.ms_per_beat < 0) { 43 | const uninheritedTiming = getTimingPointAt(map, time) 44 | return uninheritedTiming.ms_per_beat 45 | } 46 | return theTiming.ms_per_beat 47 | } 48 | 49 | // TODO(acrylic-style): i think it's ok, but it may be wrong 50 | export const getSpeedMultiplierAt = (map: beatmap, time: number): number => { 51 | let i = Number.MAX_VALUE 52 | let value: timing = map.timing_points[0] // default 53 | let exactMatch: timing | undefined 54 | map.timing_points.forEach((timing) => { 55 | if (exactMatch) return 56 | if (timing.time === time) { 57 | exactMatch = timing 58 | return 59 | } 60 | let n = time - timing.time 61 | if (n > 0 && n < i && timing.time <= time) { 62 | i = n 63 | value = timing 64 | } 65 | }) 66 | if (exactMatch) value = exactMatch 67 | return value.ms_per_beat > 0 ? 1 : -100 / value.ms_per_beat 68 | } 69 | 70 | export const getHitObjectAt = ( 71 | map: beatmap, 72 | time: number 73 | ): hitobject | undefined => { 74 | let tim: number = Number.MAX_VALUE 75 | let value: hitobject | undefined 76 | map.objects.forEach((obj) => { 77 | if (tim > Math.abs(obj.time - time)) { 78 | tim = Math.abs(obj.time - time) 79 | value = obj 80 | } 81 | }) 82 | return value 83 | } 84 | 85 | export const getHitObjectOrDefaultAt = ( 86 | map: beatmap, 87 | time: number, 88 | def: hitobject 89 | ): hitobject => { 90 | return getHitObjectAt(map, time) || def 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /popup/util/console.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | export default class Console { 4 | public static log(s: any, ...params: any[]): void { 5 | if (__DEV__) console.log(s, ...params) 6 | } 7 | 8 | public static warn(s: any, ...params: any[]): void { 9 | if (__DEV__) console.warn(s, ...params) 10 | } 11 | 12 | public static error(s: any, ...params: any[]): void { 13 | if (__DEV__) console.error(s, ...params) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /popup/util/limitedCapacityQueue.ts: -------------------------------------------------------------------------------- 1 | export default class LimitedCapacityQueue { 2 | public readonly capacity: number 3 | private readonly array: Array 4 | 5 | // Markers tracking the queue's first and last element. 6 | private start = 0 7 | private end = -1 8 | 9 | /** 10 | * The number of elements in the queue. 11 | */ 12 | public count = 0 13 | 14 | /** 15 | * @param capacity The number of items the queue can hold. 16 | */ 17 | constructor(capacity: number) { 18 | if (capacity < 0) throw new Error('Invalid capacity: ' + capacity) 19 | this.capacity = capacity 20 | this.array = new Array(capacity) 21 | this.clear() 22 | } 23 | 24 | /** 25 | * Removes all elements from the LimitedCapacityQueue. 26 | */ 27 | public clear(): void { 28 | this.start = 0 29 | this.end = -1 30 | this.count = 0 31 | } 32 | 33 | /** 34 | * Whether the queue is full (adding any new items will cause removing existing ones). 35 | */ 36 | public get full(): boolean { 37 | return this.count === this.capacity 38 | } 39 | 40 | /** 41 | * Removes an item from the front of the LimitedCapacityQueue. 42 | * @returns The item removed from the front of the queue. 43 | */ 44 | public dequeue(): T { 45 | if (this.count === 0) throw new Error('Queue is empty.') 46 | const result = this.array[this.start] 47 | this.start = (this.start + 1) % this.capacity 48 | this.count-- 49 | return result 50 | } 51 | 52 | /** 53 | * Adds an item to the back of the LimitedCapacityQueue. 54 | * If the queue is holding [count] elements at the point of addition, 55 | * the item at the front of the queue will be dropped. 56 | * @param item The item to be added to the back of the queue. 57 | */ 58 | public enqueue(item: T): void { 59 | this.end = (this.end + 1) % this.capacity 60 | if (this.count === this.capacity) { 61 | this.start = (this.start + 1) % this.capacity 62 | } else { 63 | this.count++ 64 | } 65 | this.array[this.end] = item 66 | } 67 | 68 | /** 69 | * Retrieves the item at the given index in the queue. 70 | * @param index The index of the item to retrieve. 71 | * The item with index 0 is at the front of the queue (it was added the earliest). 72 | */ 73 | public get(index: number): T { 74 | if (index < 0 || index >= this.count) 75 | throw new Error(` 76 | Index out of bounds; Index: ${index}, Count: ${this.count}`) 77 | return this.array[(this.start + index) % this.capacity] 78 | } 79 | 80 | public forEach(action: (value: T) => void): void { 81 | this.getArray().forEach((e) => action(e)) 82 | } 83 | 84 | // throws error if T isn't a number 85 | public min(): number { 86 | let n = Number.MAX_VALUE 87 | this.forEach((e) => { 88 | const c = (e as unknown) as number 89 | if (isNaN(c)) throw new Error(e + ' is not a number') 90 | if (n > c) n = c 91 | }) 92 | return n 93 | } 94 | 95 | public getArray(): Array { 96 | if (this.count === 0) return [] 97 | const res: Array = [] 98 | for (let i = 0; i < this.count; i++) { 99 | res.push(this.array[(this.start + i) % this.capacity]) 100 | } 101 | return res 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /popup/util/limitedCapacityStack.ts: -------------------------------------------------------------------------------- 1 | export default class LimitedCapacityStack { 2 | #count: number = 0 3 | public readonly array: Array 4 | public readonly capacity: number 5 | private marker: number 6 | 7 | public constructor(capacity: number) { 8 | if (capacity < 0) throw new Error('Index out of bounds: ' + capacity) 9 | this.capacity = capacity 10 | this.array = new Array(capacity) 11 | this.marker = capacity // Set marker to the end of the array, outside of the indexed range by one. 12 | } 13 | 14 | public get(index: number): T { 15 | if (index < 0 || index >= this.count) 16 | throw new Error( 17 | `Index out of bounds; Index: ${index}, Count: ${this.count}` 18 | ) 19 | index += this.marker 20 | if (index > this.capacity - 1) { 21 | index -= this.capacity 22 | } 23 | return this.array[index] 24 | } 25 | 26 | public push(item: T): void { 27 | if (this.marker === 0) this.marker = this.capacity - 1 28 | else --this.marker 29 | 30 | this.array[this.marker] = item 31 | 32 | if (this.count < this.capacity) { 33 | ++this.#count 34 | } 35 | } 36 | 37 | public get count(): number { 38 | return this.#count 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /popup/util/mth.ts: -------------------------------------------------------------------------------- 1 | // Math 2 | export default class Mth { 3 | public static clamp(num: number, min: number, max: number): number { 4 | if (num > max) return max 5 | if (num < min) return min 6 | return num 7 | } 8 | 9 | public static difficultyRange( 10 | difficulty: number, 11 | min: number, 12 | mid: number, 13 | max: number 14 | ): number { 15 | if (difficulty > 5) return mid + ((max - mid) * (difficulty - 5)) / 5 16 | if (difficulty < 5) return mid - ((mid - min) * (5 - difficulty)) / 5 17 | return mid 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /popup/util/pageInfo.ts: -------------------------------------------------------------------------------- 1 | import { Beatmap } from './beatmap' 2 | 3 | export type PageInfo = { 4 | isOldSite: boolean 5 | beatmapSetId: string 6 | beatmapId: string 7 | beatmap: Beatmap 8 | convert?: { 9 | difficulty_rating: 0 10 | mode: 'taiko' | 'fruits' | 'mania' 11 | } 12 | mode: 'osu' | 'taiko' | 'fruits' | 'mania' 13 | } 14 | -------------------------------------------------------------------------------- /popup/util/precision.ts: -------------------------------------------------------------------------------- 1 | export namespace Precision { 2 | export const almostEquals = ( 3 | value1: number, 4 | value2: number, 5 | acceptableDiff: number = Number.EPSILON 6 | ) => { 7 | return Math.abs(value1 - value2) <= acceptableDiff 8 | } 9 | 10 | export const almostBigger = ( 11 | value1: number, 12 | value2: number, 13 | acceptableDiff: number = Number.EPSILON 14 | ) => { 15 | return value1 > value2 - acceptableDiff 16 | } 17 | 18 | export const definitelyBigger = ( 19 | value1: number, 20 | value2: number, 21 | acceptableDiff: number = Number.EPSILON 22 | ) => { 23 | return value1 - acceptableDiff > value2 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /popup/util/sliders.ts: -------------------------------------------------------------------------------- 1 | import { slider } from 'ojsama' 2 | import Mth from './mth' 3 | 4 | export namespace sliders { 5 | export const progressToDistance = ( 6 | slider: slider, 7 | progress: number 8 | ): number => { 9 | return Mth.clamp(progress, 0, 1) * slider.distance 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /popup/util/timingPoint.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Each timing point influences a specified portion of the 3 | * map, commonly called a "timing section". The .osu file 4 | * format requires these to be sorted in chronological order. 5 | */ 6 | export type TimingPoint = { 7 | /** 8 | * Start time of the timing section, in milliseconds from 9 | * the beginning of the beatmap's audio. The end of the 10 | * timing section is the next timing point's time (or 11 | * never, if this is the last timing point). 12 | */ 13 | time: number 14 | 15 | /** 16 | * For uninherited timing points, the duration of a beat, 17 | * in milliseconds. 18 | * 19 | * For inherited timing points, a negative inverse slider 20 | * velocity multiplier, as a percentage. For example, -50 21 | * would make all sliders in this timing section twice as 22 | * fast as SliderMultiplier. 23 | */ 24 | beatLength: number 25 | 26 | /** 27 | * Slider multiplier for this timing point. If the 28 | * beatLength is -50, this property returns 2 (means 2x 29 | * slider velocity for this timing point). 30 | * 31 | * This property will return 1 for uninherited timing points. 32 | */ 33 | sliderMultiplier: number 34 | 35 | /** 36 | * Amount of beats in a measure. Inherited timing points 37 | * ignore this property. 38 | */ 39 | meter: number 40 | 41 | /** 42 | * Default sample set for hit objects (0 = beatmap default, 43 | * 1 = normal, 2 = soft, 3 = drum). 44 | */ 45 | sampleSet: number 46 | 47 | /** 48 | * Custom sample index for hit objects. 0 indicates osu!'s 49 | * default hitsounds. 50 | */ 51 | sampleIndex: number 52 | 53 | /** 54 | * Volume percentage for hit objects. 55 | */ 56 | volume: number 57 | 58 | /** 59 | * Whether or not the timing point is uninherited. 60 | */ 61 | uninherited: boolean 62 | 63 | /** 64 | * Bit flags that give the timing point extra effects. 65 | * https://osu.ppy.sh/wiki/en/osu%21_File_Formats/Osu_%28file_format%29#effects 66 | */ 67 | effects: number 68 | } 69 | -------------------------------------------------------------------------------- /popup/util/timingsReader.ts: -------------------------------------------------------------------------------- 1 | import { TimingPoint } from './timingPoint' 2 | 3 | // this regex is hard to understand, so i put a comment below. 4 | export const REGEX = /^(\d+),(.*?),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)/ 5 | // ^ ^ ^ ^ ^ ^ ^ ^ 6 | // time[1] | meter[3] | | volume[6] | effects[8] 7 | // beatLength[2] | | uninherited[7] 8 | // sampleSet[4] | 9 | // sampleIndex[5] 10 | 11 | export const feed = (rawBeatmap: string): Array => { 12 | const timings = [] as Array 13 | let doRead = false 14 | rawBeatmap.split('\n').forEach((s) => { 15 | if (s.startsWith('[TimingPoints]')) { 16 | doRead = true 17 | return 18 | } 19 | if (doRead && s.startsWith('[')) doRead = false 20 | if (!doRead) return 21 | const match = s.match(REGEX) 22 | if (!match) return 23 | try { 24 | timings.push({ 25 | time: parseInt(match[1]), 26 | beatLength: parseFloat(match[1]), 27 | sliderMultiplier: parseInt(match[7]) ? 1 : -100 / parseFloat(match[1]), 28 | meter: parseInt(match[3]), 29 | sampleSet: parseInt(match[4]), 30 | sampleIndex: parseInt(match[5]), 31 | volume: parseInt(match[6]), 32 | uninherited: parseInt(match[7]) === 1, 33 | effects: parseInt(match[8]), 34 | }) 35 | } catch (e) { 36 | throw new Error('Error trying to read "' + s + '"') 37 | } 38 | }) 39 | return timings 40 | } 41 | -------------------------------------------------------------------------------- /static/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/static/icon128.png -------------------------------------------------------------------------------- /static/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oamaok/ezpp/4152de8b1a89b59afcb77e4c8854c8eda88a0f9a/static/icon48.png -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "ezpp!", 4 | "description": "Calculate pp for a beatmap directly in your browser.", 5 | "version": "1.10.2", 6 | "icons": { 7 | "48": "icon48.png", 8 | "128": "icon128.png" 9 | }, 10 | "applications": { 11 | "gecko": { 12 | "id": "ezpp@bittipiilo.com" 13 | } 14 | }, 15 | "background": { 16 | "service_worker": "background.js" 17 | }, 18 | "content_scripts": [{ 19 | "matches": ["*://osu.ppy.sh/*"], 20 | "js": ["content.js"] 21 | }], 22 | "action": { 23 | "default_icon": "icon48.png", 24 | "default_popup": "popup.html" 25 | }, 26 | "permissions": [ 27 | "tabs", "storage" 28 | ], 29 | "host_permissions": [ 30 | "*://*.ppy.sh/" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /static/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 |
11 |
:(
12 | 13 | 14 |
15 | 24 | 57 |
58 |
59 |
60 | 61 | 62 |
63 |
64 | 67 | 68 | 69 |
70 |
71 | 72 | 73 |
74 |
75 |
76 |
77 | 78 | 79 |
80 |
81 | 82 | 83 |
84 |
85 | 86 | 87 |
88 |
89 | 90 | 91 |
92 |
93 | 94 | 95 |
96 |
97 | 98 | 99 |
100 |
101 | 102 | 103 |
104 |
105 | 106 | 107 |
108 |
109 |
110 |
111 |

112 |
113 |
114 | 117 |

118 |
119 | 120 | 121 |
122 |
123 | 124 | 125 |
126 |
127 | 128 | 129 |
130 |
131 | 132 | 133 |
134 | 135 |
136 |
137 | 138 | 139 | -------------------------------------------------------------------------------- /translations/bg.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Грешка предотврати разширението да функционира: ", 3 | "version-update-message": "Вече ползвате ezpp! v{0}", 4 | "changelog": "Списък на промени", 5 | "accuracy": "Точност", 6 | "combo": "Комбо", 7 | "empty-fc": "(празно = FC)", 8 | "misses": "Пропуски", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Език", 18 | "settings": "Настройки", 19 | "analytics": "Отправяне на анонимни аналитични данни", 20 | "result": "Това са около {0}pp.", 21 | "darkmode": "Включване на тъмен режим", 22 | "metadata-in-original-language": "Показване на метаданни за бийтмапа в оригинален език" 23 | } -------------------------------------------------------------------------------- /translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Ein Fehler verhindert die Erweiterung ordnungsgemäß zu funtionieren: ", 3 | "version-update-message": "Sie benutzen jetzt ezpp! v{0}", 4 | "changelog": "Änderungsprotokoll", 5 | "accuracy": "Genauigkeit", 6 | "combo": "Combo", 7 | "empty-fc": "(leer = FC)", 8 | "misses": "Verfehlt", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Sprache", 18 | "settings": "Einstellungen", 19 | "analytics": "Sende anonyme analytische Daten", 20 | "result": "Das sind ungefähr {0}pp.", 21 | "darkmode": "Aktiviere Dunkelmodus", 22 | "metadata-in-original-language": "Zeige Beatmap-Metadaten in Originalsprache an" 23 | } -------------------------------------------------------------------------------- /translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "An error prevented the extension from working properly: ", 3 | "version-update-message": "You are now using ezpp! v{0}", 4 | "changelog": "changelog", 5 | "accuracy": "Accuracy", 6 | "combo": "Combo", 7 | "empty-fc": "(empty = FC)", 8 | "misses": "Misses", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Language", 18 | "settings": "Settings", 19 | "analytics": "Send anonymous analytics data", 20 | "result": "That's about {0}pp.", 21 | "darkmode": "Enable darkmode", 22 | "metadata-in-original-language": "Show beatmap metadata in original language" 23 | } -------------------------------------------------------------------------------- /translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Un error ha impedido que la extensión funcione apropiadamente: ", 3 | "version-update-message": "Ahora estás usando ezpp! v{0}", 4 | "changelog": "registro de cambios", 5 | "accuracy": "Precisión", 6 | "combo": "Combo", 7 | "empty-fc": "(vacío = FC)", 8 | "misses": "Fallos", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Idioma", 18 | "settings": "Ajustes", 19 | "analytics": "Envía datos anónimos de análisis", 20 | "result": "Eso es más o menos {0}pp.", 21 | "darkmode": "Habilitar modo oscuro" 22 | } 23 | -------------------------------------------------------------------------------- /translations/fi.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Virhe esti lisäosaa toimimasta: ", 3 | "version-update-message": "Käytät nyt ezpp! versiota v{0}", 4 | "changelog": "muutosloki", 5 | "accuracy": "Tarkkuus", 6 | "combo": "Combo", 7 | "empty-fc": "(tyhjä = FC)", 8 | "misses": "Hudit", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Kieli", 18 | "settings": "Asetukset", 19 | "analytics": "Lähetä anonyymiä analytiikkaa", 20 | "result": "Suunnilleen {0}pp.", 21 | "darkmode": "Käytä tummaa teemaa" 22 | } -------------------------------------------------------------------------------- /translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Une erreur empêche l'extension de fonctionner correctement: ", 3 | "version-update-message": "Vous utilisez ezpp! v{0}", 4 | "changelog": "journal de modifications", 5 | "accuracy": "Précision", 6 | "combo": "Combo", 7 | "empty-fc": "(vide = FC)", 8 | "misses": "Ratés", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Langage", 18 | "settings": "Paramètres", 19 | "analytics": "Envoyer les données d'analyse (anonyme)", 20 | "result": "C'est environ {0}pp." 21 | } 22 | -------------------------------------------------------------------------------- /translations/hr.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Greška je spriječila proširenje da se učita pravilno: ", 3 | "version-update-message": "Sada koristite ezpp! v{0}", 4 | "changelog": "zapisnik o promjenama", 5 | "accuracy": "Preciznost", 6 | "combo": "Kombo", 7 | "empty-fc": "(prazno = FC)", 8 | "misses": "Promašaji", 9 | "mod-hidden": "Skriveno", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Svjetiljka", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Lako", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Jezik", 18 | "settings": "Postavke", 19 | "analytics": "Pošalji anonimne analitičke podatke", 20 | "result": "Toje oko prilike {0}pp.", 21 | "darkmode": "Omogući Tamnu Temu", 22 | "metadata-in-original-language": "Pokaži beatmap metapodatke u originalnom jeziku" 23 | } 24 | -------------------------------------------------------------------------------- /translations/hu.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Egy hiba megakadályozta a bővítmény megfelelő működését: ", 3 | "version-update-message": "Mostmár ezpp! v{0}-t használod", 4 | "changelog": "változási napló", 5 | "accuracy": "Pontosság", 6 | "combo": "Kombó", 7 | "empty-fc": "(üres = FC)", 8 | "misses": "Mellé-k", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Nyelv", 18 | "settings": "Beállítások", 19 | "analytics": "Névtelen elemzési adatok küldése", 20 | "result": "Ez kb. {0}pp.", 21 | "darkmode": "Sötét mód engedélyezése" 22 | } 23 | -------------------------------------------------------------------------------- /translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Un errore ha impedito all'estensione di funzionare correttamente: ", 3 | "version-update-message": "Stai usando ezpp! v{0}", 4 | "changelog": "changelog", 5 | "accuracy": "Precisione", 6 | "combo": "Combo", 7 | "empty-fc": "(vuoto = FC)", 8 | "misses": "Errori", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Lingua", 18 | "settings": "Impostazioni", 19 | "analytics": "Invia dati statistici in modo anonimo", 20 | "result": "Sono circa {0}pp." 21 | } 22 | -------------------------------------------------------------------------------- /translations/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "エラーにより拡張機能が正常に動作しませんでした: ", 3 | "version-update-message": "現在 ezpp! v{0} を使用しています", 4 | "changelog": "更新履歴", 5 | "accuracy": "精度", 6 | "combo": "コンボ", 7 | "empty-fc": "(空 = FC)", 8 | "misses": "ミス", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "言語", 18 | "settings": "設定", 19 | "analytics": "匿名のアナリティクスデータを送信", 20 | "result": "およそ {0}pp.", 21 | "darkmode": "ダークモードを有効", 22 | "metadata-in-original-language": "ビートマップのメタデータを元の言語で表示" 23 | } -------------------------------------------------------------------------------- /translations/ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "오류가 발생하였습니다: ", 3 | "version-update-message": "ezpp가 업데이트 되었습니다! v{0} 실행 중", 4 | "changelog": "수정사항", 5 | "accuracy": "정확도", 6 | "combo": "콤보", 7 | "empty-fc": "(비어 있음 = FC)", 8 | "misses": "미스", 9 | "mod-hidden": "히든", 10 | "mod-hardrock": "하드락", 11 | "mod-doubletime": "더블타임", 12 | "mod-flashlight": "플래시라이트", 13 | "mod-nofail": "노페일", 14 | "mod-easy": "이지", 15 | "mod-halftime": "하프타임", 16 | "mod-spunout": "스펀아웃", 17 | "language": "언어", 18 | "settings": "설정", 19 | "analytics": "익명 사용 통계 보내기", 20 | "result": "약 {0}pp 입니다." 21 | } -------------------------------------------------------------------------------- /translations/languages.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "English", 4 | "code": "en" 5 | }, 6 | { 7 | "name": "Suomi", 8 | "code": "fi" 9 | }, 10 | { 11 | "name": "Deutsch", 12 | "code": "de" 13 | }, 14 | { 15 | "name": "Español", 16 | "code": "es" 17 | }, 18 | { 19 | "name": "Slovenčina", 20 | "code": "sk" 21 | }, 22 | { 23 | "name": "Русский", 24 | "code": "ru" 25 | }, 26 | { 27 | "name": "Română", 28 | "code": "ro" 29 | }, 30 | { 31 | "name": "Français", 32 | "code": "fr" 33 | }, 34 | { 35 | "name": "Polski", 36 | "code": "pl" 37 | }, 38 | { 39 | "name": "Magyar", 40 | "code": "hu" 41 | }, 42 | { 43 | "name": "Tiếng Việt", 44 | "code": "vi" 45 | }, 46 | { 47 | "name": "日本語", 48 | "code": "ja" 49 | }, 50 | { 51 | "name": "繁體中文", 52 | "code": "zh_TW" 53 | }, 54 | { 55 | "name": "简体中文", 56 | "code": "zh_CN" 57 | }, 58 | { 59 | "name": "Italiano", 60 | "code": "it" 61 | }, 62 | { 63 | "name": "Português Brasileiro", 64 | "code": "pt_BR" 65 | }, 66 | { 67 | "name": "한국어", 68 | "code": "ko" 69 | }, 70 | { 71 | "name": "Svenska", 72 | "code": "sv" 73 | }, 74 | { 75 | "name": "Български", 76 | "code": "bg" 77 | }, 78 | { 79 | "name": "Hrvatski", 80 | "code": "hr" 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Błąd uniemożliwił poprawne działanie rozszerzenia: ", 3 | "version-update-message": "Używasz teraz ezpp! w wersji v{0}", 4 | "changelog": "dziennik zmian", 5 | "accuracy": "Celność", 6 | "combo": "Combo", 7 | "empty-fc": "(puste = FC)", 8 | "misses": "Missy", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Język", 18 | "settings": "Ustawienia", 19 | "analytics": "Wyślij anonimowe dane analityczne", 20 | "result": "To około {0}pp." 21 | } -------------------------------------------------------------------------------- /translations/pt_BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Um erro impediu a extensão de funcionar corretamente: ", 3 | "version-update-message": "Agora você está usando ezpp! v{0}", 4 | "changelog": "changelog", 5 | "accuracy": "Precisão", 6 | "combo": "Combo", 7 | "empty-fc": "(vazio = FC)", 8 | "misses": "Erros", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Idioma", 18 | "settings": "Configurações", 19 | "analytics": "Enviar dados de análise anônimos", 20 | "result": "Isto dá mais ou menos {0}pp." 21 | } -------------------------------------------------------------------------------- /translations/ro.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "O eroare a împiedicat extensia din a functiona corect: ", 3 | "version-update-message": "Folosești ezpp! v{0}", 4 | "changelog": "changelog", 5 | "accuracy": "Precizie", 6 | "combo": "Combo", 7 | "empty-fc": "(gol = FC)", 8 | "misses": "Ratări", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Limbă", 18 | "settings": "Setări", 19 | "analytics": "Trimite date analytice anonime", 20 | "result": "Este cam {0}pp." 21 | } -------------------------------------------------------------------------------- /translations/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Ошибка помешала правильной работе расширения: ", 3 | "version-update-message": "Вы используете ezpp! v{0}", 4 | "changelog": "Список изменений", 5 | "accuracy": "Точность", 6 | "combo": "Комбо", 7 | "empty-fc": "(пусто = ФК)", 8 | "misses": "Промахи", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Язык", 18 | "settings": "Настройки", 19 | "analytics": "Отправлять анонимные данные", 20 | "result": "Выходит {0}пп." 21 | } 22 | -------------------------------------------------------------------------------- /translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Chyba zabránila správnemu fungovaniu rozšírenia: ", 3 | "version-update-message": "Teraz používaš ezpp! v{0}", 4 | "changelog": "zoznam zmien", 5 | "accuracy": "Presnosť", 6 | "combo": "Combo", 7 | "empty-fc": "(prázdne = FC)", 8 | "misses": "Netrafené", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Jazyk", 18 | "settings": "Nastavenia", 19 | "analytics": "Odosielať anonymné analytické údaje", 20 | "result": "To je asi {0}pp." 21 | } 22 | -------------------------------------------------------------------------------- /translations/sv.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Ett fel förhindrade tillägget att fungera: ", 3 | "version-update-message": "Du använder nu ezpp! v{0}", 4 | "changelog": "ändringshistorik", 5 | "accuracy": "Träffsäkerhet", 6 | "combo": "Kombo", 7 | "empty-fc": "(tom = FC)", 8 | "misses": "Missar", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Språk", 18 | "settings": "Inställningar", 19 | "analytics": "Skicka anonym data", 20 | "result": "Det blir ungefär {0}pp.", 21 | "darkmode": "Sätt på mörk tema" 22 | } 23 | -------------------------------------------------------------------------------- /translations/tr.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Bir hata uzantının düzgün çalışmasını engelledi: ", 3 | "version-update-message": "Artık ezpp kullanıyorsunuz! v{0}", 4 | "changelog": "changelog", 5 | "accuracy": "Doğruluk", 6 | "combo": "Combo", 7 | "empty-fc": "(boş = FC)", 8 | "misses": "Kaçırmalar", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Dil", 18 | "settings": "Ayarlar", 19 | "analytics": "Anonim analiz verilerini gönderin", 20 | "result": "Bu yaklaşık {0} pp.", 21 | "darkmode": "Karanlık Modu Aç" 22 | } 23 | -------------------------------------------------------------------------------- /translations/vi.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "Đã có lỗi xảy ra khiến cho phần mở rộng không hoạt động như mong muốn: ", 3 | "version-update-message": "Bạn đang sử dụng ezpp! v{0}", 4 | "changelog": "lịch sử cập nhật", 5 | "accuracy": "Độ chính xác", 6 | "combo": "Combo", 7 | "empty-fc": "(rỗng = Full Combo)", 8 | "misses": "Miss", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "Ngôn ngữ", 18 | "settings": "Thiết lập", 19 | "analytics": "Gửi thông tin thống kê ẩn danh", 20 | "result": "Đó là khoảng {0}pp." 21 | } 22 | -------------------------------------------------------------------------------- /translations/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "扩展程序因以下问题无法正常使用: ", 3 | "version-update-message": "目前使用 ezpp! v{0}", 4 | "changelog": "更新日志", 5 | "accuracy": "准确率", 6 | "combo": "最大连击", 7 | "empty-fc": "(留空 = FC)", 8 | "misses": "失误数", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "语言", 18 | "settings": "设定", 19 | "analytics": "发送匿名分析数据", 20 | "result": "约 {0}pp.", 21 | "darkmode": "打开深色模式", 22 | "metadata-in-original-language": "以原语言显示谱面信息" 23 | } 24 | -------------------------------------------------------------------------------- /translations/zh_TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "error-occured": "插件出了點問題無法使用: ", 3 | "version-update-message": "目前使用 ezpp! 版本v{0}", 4 | "changelog": "更新日誌", 5 | "accuracy": "準確度", 6 | "combo": "Combo 數", 7 | "empty-fc": "(留白 = Full Combo)", 8 | "misses": "Miss 數", 9 | "mod-hidden": "Hidden", 10 | "mod-hardrock": "HardRock", 11 | "mod-doubletime": "DoubleTime", 12 | "mod-flashlight": "Flashlight", 13 | "mod-nofail": "NoFail", 14 | "mod-easy": "Easy", 15 | "mod-halftime": "HalfTime", 16 | "mod-spunout": "SpunOut", 17 | "language": "選擇語言", 18 | "settings": "設定", 19 | "analytics": "發送匿名分析資料", 20 | "result": "約略 {0}pp.", 21 | "darkmode": "開啟深色模式", 22 | "metadata-in-original-language": "以原語言顯示圖譜資料" 23 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2019", "DOM"], 4 | "esModuleInterop": true, 5 | "module": "commonjs", 6 | "noImplicitAny": true, 7 | "noImplicitThis": true, 8 | "removeComments": true, 9 | "preserveConstEnums": true, 10 | "strictFunctionTypes": true, 11 | "strictNullChecks": true, 12 | "sourceMap": true, 13 | "jsx": "react", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "target": "ES2020" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | _gaq: Array 3 | } 4 | 5 | declare const _gaq: Array 6 | declare const __DEV__: boolean 7 | declare const __FIREFOX__: boolean 8 | declare const __CHROME__: boolean 9 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 4 | const CopyWebpackPlugin = require('copy-webpack-plugin') 5 | 6 | const isProduction = process.env.NODE_ENV === 'production' 7 | 8 | module.exports = { 9 | entry: { 10 | popup: [ 11 | 'regenerator-runtime/runtime', 12 | path.resolve(__dirname, 'popup/index.ts'), 13 | path.resolve(__dirname, 'popup/styles/main.sass'), 14 | ], 15 | background: path.resolve(__dirname, 'background/background.ts'), 16 | content: path.resolve(__dirname, 'background/content.ts'), 17 | }, 18 | 19 | stats: { 20 | children: true, 21 | }, 22 | 23 | mode: isProduction ? 'production' : 'development', 24 | 25 | output: { 26 | publicPath: '', 27 | path: path.resolve(__dirname, 'build'), 28 | filename: '[name].js', 29 | }, 30 | 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.(ts|js)x?$/, 35 | exclude: /(node_modules|bower_components)/, 36 | use: 'ts-loader', 37 | }, 38 | { 39 | test: /\.s[ac]ss$/, 40 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], 41 | }, 42 | { 43 | test: /\.(png|svg|woff2?|ttf|eot)$/, 44 | use: 'file-loader', 45 | }, 46 | ], 47 | }, 48 | 49 | devtool: false, 50 | 51 | plugins: [ 52 | new MiniCssExtractPlugin({ 53 | filename: '[name].css', 54 | }), 55 | 56 | new CopyWebpackPlugin({ 57 | patterns: [ 58 | { 59 | context: './static/', 60 | from: '**/*', 61 | to: './', 62 | }, 63 | { 64 | context: './assets/', 65 | from: '**/*', 66 | to: './assets', 67 | }, 68 | ], 69 | }), 70 | 71 | new webpack.DefinePlugin({ 72 | __DEV__: !isProduction, 73 | __CHROME__: JSON.stringify(JSON.parse(process.env.BUILD_CHROME || true)), 74 | __FIREFOX__: JSON.stringify(JSON.parse(process.env.BUILD_FF || false)), 75 | }), 76 | ], 77 | 78 | resolve: { 79 | extensions: ['.ts', '.tsx', '.js', '.json', '.sass', '.scss'], 80 | }, 81 | } 82 | --------------------------------------------------------------------------------