From de6fffb883cc1292cb1776b5b4c3ca470b5320a0 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 12:30:19 +0300 Subject: [PATCH 01/63] added translators' names in the Community block in the Settings --- _locales/bn/messages.json | 5 ++++- _locales/de/messages.json | 5 ++++- _locales/en/messages.json | 5 ++++- _locales/es/messages.json | 5 ++++- _locales/fr/messages.json | 5 ++++- _locales/hi/messages.json | 5 ++++- _locales/id/messages.json | 5 ++++- _locales/it/messages.json | 5 ++++- _locales/ja/messages.json | 5 ++++- _locales/ko/messages.json | 5 ++++- _locales/nl/messages.json | 3 +++ _locales/pt_PT/messages.json | 3 +++ _locales/ru/messages.json | 3 +++ _locales/ta/messages.json | 5 ++++- _locales/th/messages.json | 5 ++++- _locales/tr/messages.json | 3 +++ _locales/vi/messages.json | 5 ++++- _locales/zh_CN/messages.json | 5 ++++- popup.html | 9 +++++++-- scripts/localization.js | 1 + 20 files changed, 76 insertions(+), 16 deletions(-) diff --git a/_locales/bn/messages.json b/_locales/bn/messages.json index 9c40f6f..e14ca57 100644 --- a/_locales/bn/messages.json +++ b/_locales/bn/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "অনুবাদে সাহায্য করুন:" }, + "translators_label": { + "message": "অনুবাদকরা:" + }, "source_code_label": { "message": "সোর্স কোড:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "এই শর্টকাটটি ইতিমধ্যে অন্য কমান্ডে ব্যবহৃত হচ্ছে।" } -} \ No newline at end of file +} diff --git a/_locales/de/messages.json b/_locales/de/messages.json index db1dd4b..4347f34 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Bei der Übersetzung helfen:" }, + "translators_label": { + "message": "Übersetzer:" + }, "source_code_label": { "message": "Quellcode:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "Dieses Tastenkürzel wird bereits von einem anderen Befehl verwendet." } -} \ No newline at end of file +} diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c65264d..79de247 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Help with translation:" }, + "translators_label": { + "message": "Translators:" + }, "source_code_label": { "message": "Source code:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "This shortcut is already used by another command." } -} \ No newline at end of file +} diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 16384ff..4444ac2 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Ayuda con la traducción:" }, + "translators_label": { + "message": "Traductores:" + }, "source_code_label": { "message": "Código fuente:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "Este atajo ya lo usa otro comando." } -} \ No newline at end of file +} diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index bd7e979..7247d43 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Aider à la traduction :" }, + "translators_label": { + "message": "Traducteurs :" + }, "source_code_label": { "message": "Code source :" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "Ce raccourci est déjà utilisé par une autre commande." } -} \ No newline at end of file +} diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index 384b5f0..3995874 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "अनुवाद में मदद करें:" }, + "translators_label": { + "message": "अनुवादक:" + }, "source_code_label": { "message": "सोर्स कोड:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "यह शॉर्टकट पहले से ही किसी अन्य कमांड द्वारा उपयोग किया जा रहा है।" } -} \ No newline at end of file +} diff --git a/_locales/id/messages.json b/_locales/id/messages.json index 80947b3..9205b4c 100644 --- a/_locales/id/messages.json +++ b/_locales/id/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Bantu terjemahan:" }, + "translators_label": { + "message": "Penerjemah:" + }, "source_code_label": { "message": "Kode sumber:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "Pintasan ini sudah digunakan oleh perintah lain." } -} \ No newline at end of file +} diff --git a/_locales/it/messages.json b/_locales/it/messages.json index 1c02633..577487e 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Aiuta con la traduzione:" }, + "translators_label": { + "message": "Traduttori:" + }, "source_code_label": { "message": "Codice sorgente:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "Questa scorciatoia è già usata da un altro comando." } -} \ No newline at end of file +} diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index 6ca60e1..d57b418 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "翻訳に協力:" }, + "translators_label": { + "message": "翻訳者:" + }, "source_code_label": { "message": "ソースコード:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "このショートカットは別のコマンドですでに使用されています。" } -} \ No newline at end of file +} diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 1874585..c40917d 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "번역 돕기:" }, + "translators_label": { + "message": "번역가:" + }, "source_code_label": { "message": "소스 코드:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "이 단축키는 이미 다른 명령에서 사용 중입니다." } -} \ No newline at end of file +} diff --git a/_locales/nl/messages.json b/_locales/nl/messages.json index d78c395..9f64973 100644 --- a/_locales/nl/messages.json +++ b/_locales/nl/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Help met vertalen:" }, + "translators_label": { + "message": "Vertalers:" + }, "source_code_label": { "message": "Broncode:" }, diff --git a/_locales/pt_PT/messages.json b/_locales/pt_PT/messages.json index 91c2b1f..01368ed 100644 --- a/_locales/pt_PT/messages.json +++ b/_locales/pt_PT/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Ajude na tradução:" }, + "translators_label": { + "message": "Tradutores:" + }, "source_code_label": { "message": "Código-fonte:" }, diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index f83946c..c094ed9 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Помочь с переводом:" }, + "translators_label": { + "message": "Переводчики:" + }, "source_code_label": { "message": "Исходный код:" }, diff --git a/_locales/ta/messages.json b/_locales/ta/messages.json index e245280..5ef30cb 100644 --- a/_locales/ta/messages.json +++ b/_locales/ta/messages.json @@ -35,6 +35,9 @@ "help_with_translation_label": { "message": "மொழிபெயர்ப்பு உதவி:" }, + "translators_label": { + "message": "மொழிபெயர்ப்பாளர்கள்:" + }, "source_code_label": { "message": "மூல குறியீடு:" }, @@ -143,4 +146,4 @@ "extName": { "message": "EQ Toolkit - ஆடியோ சமநிலைப்படுத்தி" } -} +} \ No newline at end of file diff --git a/_locales/th/messages.json b/_locales/th/messages.json index dc603fc..fa5f363 100644 --- a/_locales/th/messages.json +++ b/_locales/th/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "ช่วยแปล:" }, + "translators_label": { + "message": "ผู้แปล:" + }, "source_code_label": { "message": "ซอร์สโค้ด:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "ปุ่มลัดนี้ถูกใช้โดยคำสั่งอื่นแล้ว" } -} \ No newline at end of file +} diff --git a/_locales/tr/messages.json b/_locales/tr/messages.json index 8cc8f37..ce5d81a 100644 --- a/_locales/tr/messages.json +++ b/_locales/tr/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Çeviriye yardım edin:" }, + "translators_label": { + "message": "Çevirmenler:" + }, "source_code_label": { "message": "Kaynak kodu:" }, diff --git a/_locales/vi/messages.json b/_locales/vi/messages.json index bb75b47..3892755 100644 --- a/_locales/vi/messages.json +++ b/_locales/vi/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "Hỗ trợ dịch thuật:" }, + "translators_label": { + "message": "Người dịch:" + }, "source_code_label": { "message": "Mã nguồn:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "Phím tắt này đã được lệnh khác sử dụng." } -} \ No newline at end of file +} diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 9fcbdb9..87cc945 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -38,6 +38,9 @@ "help_with_translation_label": { "message": "帮助翻译:" }, + "translators_label": { + "message": "译者:" + }, "source_code_label": { "message": "源代码:" }, @@ -143,4 +146,4 @@ "shortcut_duplicate_error": { "message": "此快捷键已被另一个命令使用。" } -} \ No newline at end of file +} diff --git a/popup.html b/popup.html index 3d31b5d..e7bf432 100644 --- a/popup.html +++ b/popup.html @@ -124,8 +124,10 @@

Presets

Community

-
- Feedback +
Help with translation: @@ -135,6 +137,9 @@

Community

Source code: Github
+
+ Feedback +
diff --git a/scripts/localization.js b/scripts/localization.js index 274af87..101b8bb 100644 --- a/scripts/localization.js +++ b/scripts/localization.js @@ -102,6 +102,7 @@ function applyLocalization() { "help-with-translation-label", "help_with_translation_label" ); + setFirstTextNodeContent("translators-label", "translators_label"); setFirstTextNodeContent("source-code-label", "source_code_label"); setTextContent("autostart-settings-title", "autostart_settings_title"); setTextContent( From eabca0e2acf29049b453f28af14c1f115219e6ff Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 13:46:07 +0300 Subject: [PATCH 02/63] build: add TypeScript Vite extension build --- package-lock.json | 1977 ++++++++++++++++++- package.json | 13 +- src/app/background/background.ts | 1 + src/app/content-isolated/contentIsolated.ts | 1 + src/app/content-main/contentMain.ts | 1 + src/app/popup/popup.ts | 1 + tsconfig.json | 20 + vite.config.ts | 44 + vitest.config.ts | 10 + 9 files changed, 2055 insertions(+), 13 deletions(-) create mode 100644 src/app/background/background.ts create mode 100644 src/app/content-isolated/contentIsolated.ts create mode 100644 src/app/content-main/contentMain.ts create mode 100644 src/app/popup/popup.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 2355a78..9aab750 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "volume-equalizer-extensions", + "name": "typescript-vite-domain-migration", "lockfileVersion": 2, "requires": true, "packages": { @@ -8,7 +8,529 @@ "chrome-types": "^0.1.349" }, "devDependencies": { - "bestzip": "^2.2.1" + "@types/node": "^26.1.0", + "bestzip": "^2.2.1", + "typescript": "^6.0.3", + "vite": "^8.1.3", + "vitest": "^4.1.9" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/ansi-regex": { @@ -104,6 +626,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -209,6 +741,16 @@ "node": "*" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chrome-types": { "version": "0.1.349", "resolved": "https://registry.npmjs.org/chrome-types/-/chrome-types-0.1.349.tgz", @@ -264,6 +806,13 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -295,6 +844,16 @@ "node": ">= 10" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -310,6 +869,13 @@ "once": "^1.4.0" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -319,6 +885,44 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -331,6 +935,21 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -467,6 +1086,279 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -497,6 +1389,16 @@ "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -509,6 +1411,25 @@ "node": "*" } }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -518,6 +1439,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -536,6 +1471,62 @@ "node": ">=0.10.0" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -595,6 +1586,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -615,6 +1640,37 @@ } ] }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -662,16 +1718,257 @@ "inherits": "^2.0.3", "readable-stream": "^3.1.1" }, - "engines": { - "node": ">=6" + "engines": { + "node": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -687,6 +1984,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -783,6 +2097,295 @@ } }, "dependencies": { + "@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "optional": true, + "requires": { + "@tybys/wasm-util": "^0.10.3" + } + }, + "@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true + }, + "@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "dev": true, + "optional": true + }, + "@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "dev": true, + "optional": true + }, + "@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "dev": true, + "optional": true + }, + "@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "dev": true, + "optional": true + }, + "@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "dev": true, + "optional": true + }, + "@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "dev": true, + "optional": true + }, + "@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "dev": true, + "optional": true, + "requires": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + } + }, + "@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "dev": true, + "optional": true + }, + "@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "dev": true, + "optional": true + }, + "@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true + }, + "@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "optional": true, + "requires": { + "tslib": "^2.4.0" + } + }, + "@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "requires": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "requires": { + "undici-types": "~8.3.0" + } + }, + "@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "requires": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + } + }, + "@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "requires": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + } + }, + "@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "requires": { + "tinyrainbow": "^3.1.0" + } + }, + "@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "requires": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + } + }, + "@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + } + }, + "@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true + }, + "@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "requires": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + } + }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -863,6 +2466,12 @@ } } }, + "assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true + }, "async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -931,6 +2540,12 @@ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "dev": true }, + "chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true + }, "chrome-types": { "version": "0.1.349", "resolved": "https://registry.npmjs.org/chrome-types/-/chrome-types-0.1.349.tgz", @@ -980,6 +2595,12 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -1002,6 +2623,12 @@ "readable-stream": "^3.4.0" } }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1017,12 +2644,40 @@ "once": "^1.4.0" } }, + "es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true + }, "escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true }, + "estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "requires": { + "@types/estree": "^1.0.0" + } + }, + "expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true + }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "requires": {} + }, "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1035,6 +2690,13 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1142,6 +2804,103 @@ } } }, + "lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "requires": { + "detect-libc": "^2.0.3", + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "dev": true, + "optional": true + }, + "lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "dev": true, + "optional": true + }, + "lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "dev": true, + "optional": true + }, + "lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "dev": true, + "optional": true + }, + "lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "dev": true, + "optional": true + }, + "lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "dev": true, + "optional": true + }, + "lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "dev": true, + "optional": true + }, "lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -1172,6 +2931,15 @@ "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true }, + "magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1181,12 +2949,24 @@ "brace-expansion": "^1.1.7" } }, + "nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, + "obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1202,6 +2982,35 @@ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true }, + "pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true + }, + "postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "requires": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -1254,12 +3063,61 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true }, + "rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "requires": { + "@oxc-project/types": "=0.138.0", + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4", + "@rolldown/pluginutils": "^1.0.0" + } + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true }, + "siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true + }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1302,12 +3160,101 @@ "readable-stream": "^3.1.1" } }, + "tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true + }, + "tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + } + }, + "tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "optional": true + }, + "typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true + }, + "undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "requires": { + "fsevents": "~2.3.3", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + } + }, + "vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "requires": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1317,6 +3264,16 @@ "isexe": "^2.0.0" } }, + "why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "requires": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + } + }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 7fd776e..9a1f17d 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,17 @@ }, "scripts": { "lint:locales": "node tools/validateLocalizationLengths.js", - "prezip": "@powershell if (-not (Test-Path -Path \"./build/Release\")) {New-Item -ItemType Directory -Path \"./build/Release\"}", - "zip": "bestzip ./build/Release/eq-toolkit_v1.8.0_unpackeged.zip resources/** scripts/** _locales/** manifest.json popup.html" + "typecheck": "tsc --noEmit", + "test": "vitest run", + "build": "vite build", + "prezip": "npm run build", + "zip": "bestzip ./build/Release/eq-toolkit_v1.8.0_unpacked.zip dist/**" }, "devDependencies": { - "bestzip": "^2.2.1" + "@types/node": "^26.1.0", + "bestzip": "^2.2.1", + "typescript": "^6.0.3", + "vite": "^8.1.3", + "vitest": "^4.1.9" } } \ No newline at end of file diff --git a/src/app/background/background.ts b/src/app/background/background.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/app/background/background.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/app/content-isolated/contentIsolated.ts b/src/app/content-isolated/contentIsolated.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/app/content-isolated/contentIsolated.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/app/content-main/contentMain.ts b/src/app/content-main/contentMain.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/app/content-main/contentMain.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/app/popup/popup.ts b/src/app/popup/popup.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/app/popup/popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..42c2417 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "checkJs": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "types": ["chrome-types", "vitest/globals"] + }, + "include": ["src", "vite.config.ts", "vitest.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ea13bf6 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,44 @@ +/// + +import { cp, mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { defineConfig, type Plugin } from "vite"; + +const rootDir = __dirname; +const distDir = resolve(rootDir, "dist"); + +const copyExtensionAssets = (): Plugin => ({ + name: "copy-extension-assets", + apply: "build", + async closeBundle() { + await mkdir(distDir, { recursive: true }); + + await Promise.all([ + cp(resolve(rootDir, "manifest.json"), resolve(distDir, "manifest.json")), + cp(resolve(rootDir, "popup.html"), resolve(distDir, "popup.html")), + cp(resolve(rootDir, "resources"), resolve(distDir, "resources"), { recursive: true }), + cp(resolve(rootDir, "_locales"), resolve(distDir, "_locales"), { recursive: true }), + ]); + }, +}); + +export default defineConfig({ + plugins: [copyExtensionAssets()], + build: { + emptyOutDir: true, + outDir: "dist", + rollupOptions: { + input: { + background: resolve(rootDir, "src/app/background/background.ts"), + popup: resolve(rootDir, "src/app/popup/popup.ts"), + "content-isolated": resolve(rootDir, "src/app/content-isolated/contentIsolated.ts"), + "content-main": resolve(rootDir, "src/app/content-main/contentMain.ts"), + }, + output: { + entryFileNames: "scripts/[name].js", + chunkFileNames: "scripts/[name].js", + assetFileNames: "assets/[name][extname]", + }, + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4a1f561 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + passWithNoTests: true, + }, +}); From c62b1fa32b3755d63bb6023d616b69e6f544686f Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 13:54:51 +0300 Subject: [PATCH 03/63] build: harden extension packaging --- package.json | 2 +- vite.config.ts | 44 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 9a1f17d..894ecde 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "build": "vite build", - "prezip": "npm run build", + "prezip": "npm run build && node -e \"require('node:fs').mkdirSync('build/Release',{recursive:true})\"", "zip": "bestzip ./build/Release/eq-toolkit_v1.8.0_unpacked.zip dist/**" }, "devDependencies": { diff --git a/vite.config.ts b/vite.config.ts index ea13bf6..0b81311 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,21 @@ /// -import { cp, mkdir } from "node:fs/promises"; +import { cp, mkdir, readdir } from "node:fs/promises"; import { resolve } from "node:path"; import { defineConfig, type Plugin } from "vite"; const rootDir = __dirname; const distDir = resolve(rootDir, "dist"); +const entries = { + background: resolve(rootDir, "src/app/background/background.ts"), + popup: resolve(rootDir, "src/app/popup/popup.ts"), + "content-isolated": resolve(rootDir, "src/app/content-isolated/contentIsolated.ts"), + "content-main": resolve(rootDir, "src/app/content-main/contentMain.ts"), +} as const; + +const expectedEntryFiles = new Set(Object.keys(entries).map((entryName) => `${entryName}.js`)); + const copyExtensionAssets = (): Plugin => ({ name: "copy-extension-assets", apply: "build", @@ -18,22 +27,41 @@ const copyExtensionAssets = (): Plugin => ({ cp(resolve(rootDir, "popup.html"), resolve(distDir, "popup.html")), cp(resolve(rootDir, "resources"), resolve(distDir, "resources"), { recursive: true }), cp(resolve(rootDir, "_locales"), resolve(distDir, "_locales"), { recursive: true }), + cp(resolve(rootDir, "scripts"), resolve(distDir, "scripts"), { recursive: true }), ]); + + const scriptEntries = await readdir(resolve(distDir, "scripts"), { withFileTypes: true }); + const unexpectedRootScripts = scriptEntries + .filter((entry) => entry.isFile() && entry.name.endsWith(".js") && !expectedEntryFiles.has(entry.name)) + .map((entry) => entry.name); + + if (unexpectedRootScripts.length > 0) { + throw new Error(`Unexpected top-level script bundles emitted: ${unexpectedRootScripts.join(", ")}`); + } + }, +}); + +const failOnUnexpectedJsBundles = (): Plugin => ({ + name: "fail-on-unexpected-js-bundles", + apply: "build", + generateBundle(_options, bundle) { + const unexpectedBundles = Object.entries(bundle) + .filter(([fileName, output]) => output.type === "chunk" && !expectedEntryFiles.has(fileName.replace(/^scripts\//, ""))) + .map(([fileName]) => fileName); + + if (unexpectedBundles.length > 0) { + throw new Error(`Unexpected script bundles emitted: ${unexpectedBundles.join(", ")}`); + } }, }); export default defineConfig({ - plugins: [copyExtensionAssets()], + plugins: [failOnUnexpectedJsBundles(), copyExtensionAssets()], build: { emptyOutDir: true, outDir: "dist", rollupOptions: { - input: { - background: resolve(rootDir, "src/app/background/background.ts"), - popup: resolve(rootDir, "src/app/popup/popup.ts"), - "content-isolated": resolve(rootDir, "src/app/content-isolated/contentIsolated.ts"), - "content-main": resolve(rootDir, "src/app/content-main/contentMain.ts"), - }, + input: entries, output: { entryFileNames: "scripts/[name].js", chunkFileNames: "scripts/[name].js", From 94645c1b21ad3b87097af78e4a61aabd67b5f2a9 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 14:03:21 +0300 Subject: [PATCH 04/63] feat: add typed equalizer domain logic --- src/domains/equalizer/defaultFilters.test.ts | 26 +++++++++ src/domains/equalizer/defaultFilters.ts | 31 ++++++++++ src/domains/equalizer/equalizerMath.test.ts | 42 ++++++++++++++ src/domains/equalizer/equalizerMath.ts | 59 ++++++++++++++++++++ src/domains/equalizer/types.ts | 17 ++++++ 5 files changed, 175 insertions(+) create mode 100644 src/domains/equalizer/defaultFilters.test.ts create mode 100644 src/domains/equalizer/defaultFilters.ts create mode 100644 src/domains/equalizer/equalizerMath.test.ts create mode 100644 src/domains/equalizer/equalizerMath.ts create mode 100644 src/domains/equalizer/types.ts diff --git a/src/domains/equalizer/defaultFilters.test.ts b/src/domains/equalizer/defaultFilters.test.ts new file mode 100644 index 0000000..e36528d --- /dev/null +++ b/src/domains/equalizer/defaultFilters.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; + +import { + createDefaultFilterSettings, + normalizeFilterSettings, +} from "./defaultFilters"; + +describe("defaultFilters", () => { + test("creates default filter settings in legacy order", () => { + expect(createDefaultFilterSettings()).toEqual([ + { freq: 20, gain: 0, q: 0.5, type: "highpass" }, + { freq: 5, gain: 0, q: 0.5, type: "peaking" }, + { freq: 30, gain: 0, q: 0.5, type: "peaking" }, + { freq: 180, gain: 0, q: 0.5, type: "peaking" }, + { freq: 800, gain: 0, q: 0.5, type: "peaking" }, + { freq: 5000, gain: 0, q: 0.5, type: "peaking" }, + { freq: 20000, gain: 0, q: 0.5, type: "lowpass" }, + ]); + }); + + test("normalizes missing filter q and type to legacy defaults", () => { + expect(normalizeFilterSettings([{ freq: 100, gain: 2 }])).toEqual([ + { freq: 100, gain: 2, q: 0.5, type: "peaking" }, + ]); + }); +}); diff --git a/src/domains/equalizer/defaultFilters.ts b/src/domains/equalizer/defaultFilters.ts new file mode 100644 index 0000000..1dcb90f --- /dev/null +++ b/src/domains/equalizer/defaultFilters.ts @@ -0,0 +1,31 @@ +import type { EqualizerFilter } from "./types"; + +import { DEFAULT_FILTER_Q } from "./equalizerMath"; + +const DEFAULT_PEAKING_FREQS = [5, 30, 180, 800, 5000]; + +type FilterSettingsInput = Pick & + Partial>; + +export const createDefaultFilterSettings = (): EqualizerFilter[] => { + return [ + { freq: 20, gain: 0, q: DEFAULT_FILTER_Q, type: "highpass" }, + ...DEFAULT_PEAKING_FREQS.map((freq): EqualizerFilter => { + return { freq, gain: 0, q: DEFAULT_FILTER_Q, type: "peaking" }; + }), + { freq: 20000, gain: 0, q: DEFAULT_FILTER_Q, type: "lowpass" }, + ]; +}; + +export const normalizeFilterSettings = ( + filters: FilterSettingsInput[] | null | undefined +): EqualizerFilter[] => { + return (filters ?? []).map((filter) => { + return { + freq: filter.freq, + gain: filter.gain, + q: filter.q ?? DEFAULT_FILTER_Q, + type: filter.type ?? "peaking", + }; + }); +}; diff --git a/src/domains/equalizer/equalizerMath.test.ts b/src/domains/equalizer/equalizerMath.test.ts new file mode 100644 index 0000000..2014481 --- /dev/null +++ b/src/domains/equalizer/equalizerMath.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; + +import { + clampPointCount, + dbToGain, + ensureQFactor, + frequencyToX, + xToFrequency, + yToDb, +} from "./equalizerMath"; + +describe("equalizerMath", () => { + test("clamps point counts to the supported range", () => { + expect(clampPointCount(Number.NaN)).toBe(5); + expect(clampPointCount(3)).toBe(5); + expect(clampPointCount(7)).toBe(7); + expect(clampPointCount(12)).toBe(9); + }); + + test("normalizes q factors to the supported range", () => { + expect(ensureQFactor("bad")).toBe(0.5); + expect(ensureQFactor(0.01)).toBe(0.1); + expect(ensureQFactor(2)).toBe(2); + expect(ensureQFactor(99)).toBe(10); + }); + + test("converts frequency and x positions back and forth", () => { + const x = frequencyToX(1000, 500); + + expect(xToFrequency(x, 500)).toBeCloseTo(1000, 10); + }); + + test("converts y positions to decibels", () => { + expect(yToDb(100, 200)).toBe(0); + expect(yToDb(20, 200)).toBe(25); + }); + + test("converts decibels to gain", () => { + expect(dbToGain(6)).toBe(3); + expect(dbToGain(-3)).toBe(0.5); + }); +}); diff --git a/src/domains/equalizer/equalizerMath.ts b/src/domains/equalizer/equalizerMath.ts new file mode 100644 index 0000000..70fc879 --- /dev/null +++ b/src/domains/equalizer/equalizerMath.ts @@ -0,0 +1,59 @@ +export const MIN_POINT_COUNT = 5; +export const MAX_POINT_COUNT = 9; +export const MIN_FILTER_Q = 0.1; +export const MAX_FILTER_Q = 10; +export const DEFAULT_FILTER_Q = 0.5; +export const LOG_MIN = Math.log10(1); +export const LOG_MAX = Math.log10(22000); + +export const clampPointCount = (value: number): number => { + if (Number.isNaN(value)) { + return MIN_POINT_COUNT; + } + + return Math.max(MIN_POINT_COUNT, Math.min(MAX_POINT_COUNT, value)); +}; + +export const ensureQFactor = (value: unknown): number => { + const q = Number(value); + + if (!Number.isFinite(q)) { + return DEFAULT_FILTER_Q; + } + + return Math.max(MIN_FILTER_Q, Math.min(MAX_FILTER_Q, q)); +}; + +export const xToFrequency = (x: number, canvasWidth: number): number => { + const freq = Math.pow( + 10, + Math.sqrt(x / canvasWidth) * (LOG_MAX - LOG_MIN) + LOG_MIN + ); + + return freq > 24000 ? 24000 : freq; +}; + +export const frequencyToX = (freq: number, canvasWidth: number): number => { + if (freq === 0) { + return 0; + } + + const normalizedFreq = freq > 24000 ? 24000 : freq; + + return ( + Math.pow((Math.log10(normalizedFreq) - LOG_MIN) / (LOG_MAX - LOG_MIN), 2) * + canvasWidth + ); +}; + +export const yToDb = (y: number, canvasHeight: number): number => { + return ((canvasHeight / 2 - y) / (canvasHeight / 2 - 20)) * 25; +}; + +export const dbToGain = (db: number): number => { + if (db >= 0) { + return 1 + db / 3; + } + + return 1.5 / Math.abs(db); +}; diff --git a/src/domains/equalizer/types.ts b/src/domains/equalizer/types.ts new file mode 100644 index 0000000..9ae947a --- /dev/null +++ b/src/domains/equalizer/types.ts @@ -0,0 +1,17 @@ +export type EqualizerFilterType = "peaking" | "highpass" | "lowpass"; + +export interface EqualizerFilter { + freq: number; + gain: number; + q: number; + type: EqualizerFilterType; +} + +export interface EqualizerPoint { + x: number; + y: number; + freq: number; + gain: number; + q: number; + type: EqualizerFilterType; +} From a5ff5ce5ee3c8a7061177100d8e83892c3f967d0 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 14:13:26 +0300 Subject: [PATCH 05/63] feat: add typed shortcuts and autostart domains --- src/domains/autostart/autostartRules.test.ts | 72 ++++++++ src/domains/autostart/autostartRules.ts | 95 ++++++++++ src/domains/shortcuts/shortcuts.test.ts | 56 ++++++ src/domains/shortcuts/shortcuts.ts | 184 +++++++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 src/domains/autostart/autostartRules.test.ts create mode 100644 src/domains/autostart/autostartRules.ts create mode 100644 src/domains/shortcuts/shortcuts.test.ts create mode 100644 src/domains/shortcuts/shortcuts.ts diff --git a/src/domains/autostart/autostartRules.test.ts b/src/domains/autostart/autostartRules.test.ts new file mode 100644 index 0000000..a4ebba5 --- /dev/null +++ b/src/domains/autostart/autostartRules.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; + +import { + createWhitelistEntry, + findWhitelistMatch, + getWhitelistDomain, + normalizeAutostartSettingsUrlValue, + normalizeWhitelistUrl, +} from "./autostartRules"; + +describe("autostartRules", () => { + test("normalizes whitelist urls by removing fragments", () => { + expect(normalizeWhitelistUrl("https://example.com/path#section")).toBe( + "https://example.com/path" + ); + }); + + test("gets whitelist domains without a leading www prefix", () => { + expect(getWhitelistDomain("https://www.example.com/path")).toBe( + "example.com" + ); + }); + + test("creates normalized domain whitelist entries", () => { + expect( + createWhitelistEntry("domain", "https://www.example.com/page", "Rock") + ).toEqual({ + id: "domain:example.com", + type: "domain", + value: "example.com", + presetName: "Rock", + }); + }); + + test("finds exact url matches before domain matches", () => { + const urlEntry = { + id: "url:https://example.com/path", + type: "url" as const, + value: "https://example.com/path", + presetName: "Exact", + }; + const domainEntry = { + id: "domain:example.com", + type: "domain" as const, + value: "example.com", + presetName: "Domain", + }; + + expect( + findWhitelistMatch([domainEntry, urlEntry], "https://example.com/path#top") + ).toBe(urlEntry); + }); + + test("returns null when no whitelist entries exist", () => { + expect(findWhitelistMatch([], "https://example.com/path")).toBeNull(); + }); + + test("normalizes autostart setting values by type", () => { + expect( + normalizeAutostartSettingsUrlValue( + "domain", + " https://www.example.com/path#section " + ) + ).toBe("example.com"); + expect( + normalizeAutostartSettingsUrlValue( + "url", + " https://www.example.com/path#section " + ) + ).toBe("https://www.example.com/path"); + }); +}); diff --git a/src/domains/autostart/autostartRules.ts b/src/domains/autostart/autostartRules.ts new file mode 100644 index 0000000..05c6253 --- /dev/null +++ b/src/domains/autostart/autostartRules.ts @@ -0,0 +1,95 @@ +export type AutostartEntryType = "domain" | "url"; + +export interface AutostartWhitelistEntry { + id: string; + type: AutostartEntryType; + value: string; + presetName: string; +} + +export const normalizeWhitelistUrl = (url: string): string => { + try { + const normalizedUrl = new URL(url); + normalizedUrl.hash = ""; + return normalizedUrl.href; + } catch (e) { + return url; + } +}; + +export const getWhitelistDomain = (url: string): string => { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch (e) { + return url.split("/")[0].replace(/^www\./, ""); + } +}; + +export const normalizeAutostartSettingsUrlValue = ( + type: AutostartEntryType, + value: string +): string => { + if (!value) return ""; + const rawValue = value.trim(); + + if (type === "domain") { + return getWhitelistDomain(rawValue); + } + + return normalizeWhitelistUrl(rawValue); +}; + +export const createWhitelistEntry = ( + type: AutostartEntryType, + value: string, + presetName: string +): AutostartWhitelistEntry | null => { + if (!value || !presetName) return null; + + const normalizedValue = normalizeAutostartSettingsUrlValue(type, value); + const normalizedPresetName = presetName.trim(); + + return { + id: `${type}:${normalizedValue}`, + type, + value: normalizedValue, + presetName: normalizedPresetName, + }; +}; + +export const findWhitelistMatch = ( + entries: AutostartWhitelistEntry[] | null | undefined, + url: string +): AutostartWhitelistEntry | undefined | null => { + const normalizedUrl = normalizeWhitelistUrl(url); + const domain = getWhitelistDomain(url); + if (!normalizedUrl || !domain || !entries || entries.length === 0) { + return null; + } + + let urlRule: AutostartWhitelistEntry | undefined; + let domainRule: AutostartWhitelistEntry | undefined; + entries.forEach((entry) => { + if (entry.type === "url" && entry.value === normalizedUrl) { + urlRule = entry; + } + + if ( + entry.type === "domain" && + (domain === entry.value || domain.endsWith("." + entry.value)) + ) { + domainRule = entry; + } + }); + + if (urlRule) return urlRule; + + return domainRule; +}; + +export const isPresetUsedInWhitelist = ( + entries: AutostartWhitelistEntry[] | null | undefined, + presetName: string +): boolean => { + return (entries ?? []).some((entry) => entry.presetName === presetName); +}; diff --git a/src/domains/shortcuts/shortcuts.test.ts b/src/domains/shortcuts/shortcuts.test.ts new file mode 100644 index 0000000..39e665e --- /dev/null +++ b/src/domains/shortcuts/shortcuts.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; + +import { + DEFAULT_SHORTCUTS, + formatShortcut, + normalizeShortcut, + resolveShortcuts, + validateShortcutConfig, +} from "./shortcuts"; + +describe("shortcuts", () => { + test("resolves null shortcuts to defaults", () => { + expect(resolveShortcuts(null)).toEqual(DEFAULT_SHORTCUTS); + }); + + test("formats shortcuts with modifiers in legacy order", () => { + expect( + formatShortcut({ + ctrl: true, + alt: true, + shift: false, + meta: false, + key: "K", + }) + ).toBe("Ctrl+Alt+K"); + }); + + test("normalizes shortcuts without modifiers to null", () => { + expect( + normalizeShortcut({ + ctrl: false, + alt: false, + shift: false, + meta: false, + key: "K", + }) + ).toBeNull(); + }); + + test("validates duplicate mute and toggle equalizer shortcuts", () => { + const duplicateShortcut = { + ctrl: true, + alt: false, + shift: false, + meta: false, + key: "M", + }; + + expect( + validateShortcutConfig({ + mute: duplicateShortcut, + toggleEq: duplicateShortcut, + }) + ).toBe("duplicate"); + }); +}); diff --git a/src/domains/shortcuts/shortcuts.ts b/src/domains/shortcuts/shortcuts.ts new file mode 100644 index 0000000..5b51fda --- /dev/null +++ b/src/domains/shortcuts/shortcuts.ts @@ -0,0 +1,184 @@ +export const SHORTCUT_ACTION_MUTE_NAME = "mute"; +export const SHORTCUT_ACTION_TOGGLE_EQ_NAME = "toggleEq"; + +export interface Shortcut { + alt: boolean; + ctrl: boolean; + shift: boolean; + meta: boolean; + key: string; +} + +export type ShortcutActionName = + | typeof SHORTCUT_ACTION_MUTE_NAME + | typeof SHORTCUT_ACTION_TOGGLE_EQ_NAME; +export type ShortcutMap = Record; +export type ShortcutValidationError = "invalid" | "duplicate"; + +export const DEFAULT_SHORTCUTS = Object.freeze({ + [SHORTCUT_ACTION_MUTE_NAME]: Object.freeze({ + alt: true, + ctrl: false, + shift: false, + meta: false, + key: "M", + }), + [SHORTCUT_ACTION_TOGGLE_EQ_NAME]: Object.freeze({ + alt: true, + ctrl: false, + shift: false, + meta: false, + key: "K", + }), +}) as ShortcutMap; + +interface ShortcutLike { + alt?: unknown; + ctrl?: unknown; + shift?: unknown; + meta?: unknown; + key?: unknown; +} + +export const normalizeShortcutKey = (key: unknown): string | null => { + if (typeof key !== "string") return null; + + const value = key.trim(); + if (/^[a-z0-9]$/i.test(value)) return value.toUpperCase(); + + return null; +}; + +export const normalizeShortcutCode = (code: unknown): string | null => { + if (typeof code !== "string") return null; + + if (/^Key[A-Z]$/.test(code)) return code.slice(3); + if (/^Digit[0-9]$/.test(code)) return code.slice(5); + + return null; +}; + +export const isModifierShortcutKey = (key: string): boolean => { + return ["Alt", "Control", "Shift", "Meta"].includes(key); +}; + +export const isEditableShortcutTarget = (target: EventTarget | null): boolean => { + if (!target) return false; + + const element = + typeof Element !== "undefined" && target instanceof Element + ? target + : typeof Element !== "undefined" && + "parentElement" in target && + target.parentElement instanceof Element + ? target.parentElement + : null; + if (!element) return false; + + return Boolean( + element.closest( + "input, textarea, select, [contenteditable=''], [contenteditable='true']" + ) + ); +}; + +export const formatShortcut = (shortcut: Shortcut | null): string => { + if (!shortcut) return ""; + + const parts = []; + if (shortcut.ctrl) parts.push("Ctrl"); + if (shortcut.alt) parts.push("Alt"); + if (shortcut.shift) parts.push("Shift"); + if (shortcut.meta) parts.push("Meta"); + parts.push(shortcut.key); + return parts.join("+"); +}; + +export const areShortcutsEqual = ( + first: Shortcut | null | undefined, + second: Shortcut | null | undefined +): boolean => { + return ( + first?.ctrl === second?.ctrl && + first?.alt === second?.alt && + first?.shift === second?.shift && + first?.meta === second?.meta && + first?.key === second?.key + ); +}; + +export const normalizeShortcut = (value: unknown): Shortcut | null => { + if (!value || typeof value !== "object") return null; + + const shortcutValue = value as ShortcutLike; + const shortcut = { + ctrl: shortcutValue.ctrl === true, + alt: shortcutValue.alt === true, + shift: shortcutValue.shift === true, + meta: shortcutValue.meta === true, + key: typeof shortcutValue.key === "string" ? shortcutValue.key : "", + }; + + if (!shortcut.key) return null; + if (!shortcut.ctrl && !shortcut.alt && !shortcut.shift && !shortcut.meta) { + return null; + } + + return shortcut; +}; + +export const normalizeShortcutFromKeyboardEvent = ( + event: KeyboardEvent | null | undefined +): Shortcut | null => { + if (!event || event.isComposing) return null; + const key = + normalizeShortcutCode(event.code) ?? normalizeShortcutKey(event.key); + + return normalizeShortcut({ + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + key, + }); +}; + +export const resolveShortcuts = ( + storedShortcuts: Partial | null | undefined +): ShortcutMap => { + const shortcuts = storedShortcuts ?? DEFAULT_SHORTCUTS; + return { + [SHORTCUT_ACTION_MUTE_NAME]: normalizeShortcut( + shortcuts[SHORTCUT_ACTION_MUTE_NAME] + ), + [SHORTCUT_ACTION_TOGGLE_EQ_NAME]: normalizeShortcut( + shortcuts[SHORTCUT_ACTION_TOGGLE_EQ_NAME] + ), + }; +}; + +export const validateShortcutConfig = ( + shortcuts: Partial | null | undefined +): ShortcutValidationError | null => { + const muteShortcut = normalizeShortcut( + shortcuts?.[SHORTCUT_ACTION_MUTE_NAME] + ); + const toggleEqShortcut = normalizeShortcut( + shortcuts?.[SHORTCUT_ACTION_TOGGLE_EQ_NAME] + ); + + if (!muteShortcut || !toggleEqShortcut) return "invalid"; + if (areShortcutsEqual(muteShortcut, toggleEqShortcut)) return "duplicate"; + + return null; +}; + +export const matchesShortcut = ( + event: KeyboardEvent, + shortcut: Shortcut | null +): boolean => { + const normalized = normalizeShortcutFromKeyboardEvent(event); + if (!normalized) return false; + + return areShortcutsEqual(normalized, shortcut); +}; From c34810f2b350a42c6d9b95f464325cb8637b5851 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 14:23:32 +0300 Subject: [PATCH 06/63] feat: add typed extension boundaries --- src/infrastructure/chrome/chromeStorage.ts | 18 +++++++++ src/infrastructure/chrome/runtimeMessages.ts | 19 +++++++++ src/infrastructure/chrome/runtimeMessaging.ts | 27 +++++++++++++ src/infrastructure/chrome/storageKeys.ts | 30 ++++++++++++++ src/infrastructure/dom/getRequiredElement.ts | 21 ++++++++++ src/infrastructure/dom/popupElements.ts | 39 +++++++++++++++++++ .../storage/storageNormalizers.test.ts | 19 +++++++++ .../storage/storageNormalizers.ts | 18 +++++++++ 8 files changed, 191 insertions(+) create mode 100644 src/infrastructure/chrome/chromeStorage.ts create mode 100644 src/infrastructure/chrome/runtimeMessages.ts create mode 100644 src/infrastructure/chrome/runtimeMessaging.ts create mode 100644 src/infrastructure/chrome/storageKeys.ts create mode 100644 src/infrastructure/dom/getRequiredElement.ts create mode 100644 src/infrastructure/dom/popupElements.ts create mode 100644 src/infrastructure/storage/storageNormalizers.test.ts create mode 100644 src/infrastructure/storage/storageNormalizers.ts diff --git a/src/infrastructure/chrome/chromeStorage.ts b/src/infrastructure/chrome/chromeStorage.ts new file mode 100644 index 0000000..583534a --- /dev/null +++ b/src/infrastructure/chrome/chromeStorage.ts @@ -0,0 +1,18 @@ +export interface ChromeStorageArea { + get( + keys?: string | string[] | Record | null, + ): Promise>; + set(values: Record): Promise; + remove(keys: string | string[]): Promise; +} + +const createChromeStorageArea = ( + area: chrome.storage.StorageArea, +): ChromeStorageArea => ({ + get: (keys) => area.get(keys ?? undefined) as Promise>, + set: (values) => area.set(values), + remove: (keys) => area.remove(keys), +}); + +export const localStorageArea = createChromeStorageArea(chrome.storage.local); +export const sessionStorageArea = createChromeStorageArea(chrome.storage.session); diff --git a/src/infrastructure/chrome/runtimeMessages.ts b/src/infrastructure/chrome/runtimeMessages.ts new file mode 100644 index 0000000..699439a --- /dev/null +++ b/src/infrastructure/chrome/runtimeMessages.ts @@ -0,0 +1,19 @@ +export const RUNTIME_MESSAGES = { + LOG: "log", + ENABLE_WINDOW_MODE: "enableWindowMode", + GET_CAPTURED_TABS: "getCapturedTabs", + SPECTRUM_FRAME: "spectrum-frame", + GET_TAB_ID: "getTabId", + PAGE_STARTED: "pageStarted", + CONNECTED: "connected", + DISCONNECTED: "disconnected", + CLEAR_STORAGE: "clearStorage", +} as const; + +export type RuntimeMessageMethod = + (typeof RUNTIME_MESSAGES)[keyof typeof RUNTIME_MESSAGES]; + +export interface RuntimeMessage { + method: RuntimeMessageMethod; + payload?: unknown; +} diff --git a/src/infrastructure/chrome/runtimeMessaging.ts b/src/infrastructure/chrome/runtimeMessaging.ts new file mode 100644 index 0000000..18ef769 --- /dev/null +++ b/src/infrastructure/chrome/runtimeMessaging.ts @@ -0,0 +1,27 @@ +import type { RuntimeMessage } from "./runtimeMessages"; + +export type RuntimeMessageHandler = ( + message: TMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void, +) => boolean | void; + +export const sendRuntimeMessage = ( + message: RuntimeMessage, +): Promise => chrome.runtime.sendMessage(message) as Promise; + +export const addRuntimeMessageListener = ( + handler: RuntimeMessageHandler, +): (() => void) => { + const listener = ( + message: unknown, + sender: chrome.runtime.MessageSender, + sendResponse: () => void, + ): boolean | undefined => handler(message as TMessage, sender, sendResponse) ?? undefined; + + chrome.runtime.onMessage.addListener(listener); + + return () => { + chrome.runtime.onMessage.removeListener(listener); + }; +}; diff --git a/src/infrastructure/chrome/storageKeys.ts b/src/infrastructure/chrome/storageKeys.ts new file mode 100644 index 0000000..58e4517 --- /dev/null +++ b/src/infrastructure/chrome/storageKeys.ts @@ -0,0 +1,30 @@ +export type StorageKey = string; + +type DynamicStorageKeyPart = number | string | null | undefined; + +export const STORAGE_KEYS = { + PRESETS: "presets", + PRESET_NAMES: "presetNames", + AUTOSTART_RULES: "whitelist", + FILTERS: "filters", + ENABLE_SPECTRUM: "enableSpectrum", + THEME: "theme", + UI_LANGUAGE: "uiLanguage", + SHORTCUTS: "shortcuts", + INSTALL_UPDATE_NOTICE: "installUpdateNotice", + TOOLKIT_WINDOW_ID: "toolkitWindowId", + TOOLKIT_WINDOW_TAB_IDS: "toolkitWindowTabIds", + TOOLKIT_WINDOW_ACTIVE_TAB_ID: "toolkitWindowActiveTabId", + TOOLKIT_WINDOW_CAPTURE_STREAM_IDS: "toolkitWindowCaptureStreamIds", + REGISTERED_TAB_IDS: "tabs", + POINT_COUNT: "pointCount", + SKIP_POINTS_CONFIRM: "skipPointsResetConfirm", + tabFilters: (tabId: DynamicStorageKeyPart) => `filters.${tabId}`, + tabEnabled: (tabId: DynamicStorageKeyPart) => `enabled.${tabId}`, + tabMute: (tabId: DynamicStorageKeyPart) => `mute.${tabId}`, + tabGain: (tabId: DynamicStorageKeyPart) => `gain.${tabId}`, + tabVolume: (tabId: DynamicStorageKeyPart) => `volume.${tabId}`, + tabPan: (tabId: DynamicStorageKeyPart) => `pan.${tabId}`, + tabCaptureError: (tabId: DynamicStorageKeyPart) => `captureError.${tabId}`, + tabSpectrum: (tabId: DynamicStorageKeyPart) => `spectrum.${tabId}`, +} as const; diff --git a/src/infrastructure/dom/getRequiredElement.ts b/src/infrastructure/dom/getRequiredElement.ts new file mode 100644 index 0000000..8e69796 --- /dev/null +++ b/src/infrastructure/dom/getRequiredElement.ts @@ -0,0 +1,21 @@ +type ElementConstructor = { + new (): TElement; +}; + +export const getRequiredElement = ( + root: ParentNode, + id: string, + expectedConstructor: ElementConstructor, +): TElement => { + const element = root.querySelector(`#${CSS.escape(id)}`); + + if (!element) { + throw new Error(`Missing required element #${id}`); + } + + if (!(element instanceof expectedConstructor)) { + throw new Error(`Element #${id} has an unexpected type`); + } + + return element; +}; diff --git a/src/infrastructure/dom/popupElements.ts b/src/infrastructure/dom/popupElements.ts new file mode 100644 index 0000000..f7079c5 --- /dev/null +++ b/src/infrastructure/dom/popupElements.ts @@ -0,0 +1,39 @@ +import { getRequiredElement } from "./getRequiredElement"; + +export interface PopupElements { + eqCanvas: HTMLCanvasElement; + spectrumCanvas: HTMLCanvasElement; + masterVolume: HTMLInputElement; + resetButton: HTMLButtonElement; + changeEqButton: HTMLButtonElement; + settingsButton: HTMLImageElement; + settingsModal: HTMLDivElement; + closeSettingsButton: HTMLSpanElement; + captureError: HTMLDivElement; + capturedTabs: HTMLDivElement; +} + +export const getPopupElements = (document: Document): PopupElements => ({ + eqCanvas: getRequiredElement(document, "eq-canvas", HTMLCanvasElement), + spectrumCanvas: getRequiredElement( + document, + "spectrum-canvas", + HTMLCanvasElement, + ), + masterVolume: getRequiredElement(document, "master-volume", HTMLInputElement), + resetButton: getRequiredElement(document, "reset", HTMLButtonElement), + changeEqButton: getRequiredElement( + document, + "change-eq", + HTMLButtonElement, + ), + settingsButton: getRequiredElement(document, "settings-btn", HTMLImageElement), + settingsModal: getRequiredElement(document, "settings-modal", HTMLDivElement), + closeSettingsButton: getRequiredElement( + document, + "close-settings", + HTMLSpanElement, + ), + captureError: getRequiredElement(document, "capture-error", HTMLDivElement), + capturedTabs: getRequiredElement(document, "captured-tabs", HTMLDivElement), +}); diff --git a/src/infrastructure/storage/storageNormalizers.test.ts b/src/infrastructure/storage/storageNormalizers.test.ts new file mode 100644 index 0000000..2be1673 --- /dev/null +++ b/src/infrastructure/storage/storageNormalizers.test.ts @@ -0,0 +1,19 @@ +import { readBoolean, readNumber, readStringArray } from "./storageNormalizers"; + +describe("storageNormalizers", () => { + test("reads booleans only when the stored value is boolean", () => { + expect(readBoolean(true, false)).toBe(true); + expect(readBoolean("true", false)).toBe(false); + }); + + test("reads finite numbers only when the stored value is number", () => { + expect(readNumber(3, 0)).toBe(3); + expect(readNumber(Number.NaN, 0)).toBe(0); + expect(readNumber("3", 0)).toBe(0); + }); + + test("reads string arrays only when every item is a string", () => { + expect(readStringArray(["a", "b"])).toEqual(["a", "b"]); + expect(readStringArray(["a", 1])).toEqual([]); + }); +}); diff --git a/src/infrastructure/storage/storageNormalizers.ts b/src/infrastructure/storage/storageNormalizers.ts new file mode 100644 index 0000000..ff1ee33 --- /dev/null +++ b/src/infrastructure/storage/storageNormalizers.ts @@ -0,0 +1,18 @@ +export const readBoolean = (value: unknown, fallback: boolean): boolean => { + if (typeof value === "boolean") return value; + + return fallback; +}; + +export const readNumber = (value: unknown, fallback: number): number => { + if (typeof value === "number" && Number.isFinite(value)) return value; + + return fallback; +}; + +export const readStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + if (!value.every((item) => typeof item === "string")) return []; + + return value; +}; From caddf5666356802c39db2547eb985f5b60384abc Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 17:22:22 +0300 Subject: [PATCH 07/63] fix: harden typed extension boundaries --- .../chrome/chromeStorage.test.ts | 19 ++++++++ src/infrastructure/chrome/chromeStorage.ts | 9 ++-- src/infrastructure/chrome/storageKeys.test.ts | 16 +++++++ src/infrastructure/chrome/storageKeys.ts | 2 +- .../dom/getRequiredElement.test.ts | 45 +++++++++++++++++++ src/infrastructure/dom/getRequiredElement.ts | 19 +++++++- 6 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 src/infrastructure/chrome/chromeStorage.test.ts create mode 100644 src/infrastructure/chrome/storageKeys.test.ts create mode 100644 src/infrastructure/dom/getRequiredElement.test.ts diff --git a/src/infrastructure/chrome/chromeStorage.test.ts b/src/infrastructure/chrome/chromeStorage.test.ts new file mode 100644 index 0000000..ae85471 --- /dev/null +++ b/src/infrastructure/chrome/chromeStorage.test.ts @@ -0,0 +1,19 @@ +describe("chromeStorage", () => { + test("can be imported without a chrome global", async () => { + const originalChrome = globalThis.chrome; + + try { + delete (globalThis as { chrome?: typeof chrome }).chrome; + + const module = await import("./chromeStorage"); + + expect(typeof module.createChromeStorageArea).toBe("function"); + expect(typeof module.getLocalStorageArea).toBe("function"); + expect(typeof module.getSessionStorageArea).toBe("function"); + } finally { + if (originalChrome) { + (globalThis as { chrome?: typeof chrome }).chrome = originalChrome; + } + } + }); +}); diff --git a/src/infrastructure/chrome/chromeStorage.ts b/src/infrastructure/chrome/chromeStorage.ts index 583534a..1c24c79 100644 --- a/src/infrastructure/chrome/chromeStorage.ts +++ b/src/infrastructure/chrome/chromeStorage.ts @@ -6,7 +6,7 @@ export interface ChromeStorageArea { remove(keys: string | string[]): Promise; } -const createChromeStorageArea = ( +export const createChromeStorageArea = ( area: chrome.storage.StorageArea, ): ChromeStorageArea => ({ get: (keys) => area.get(keys ?? undefined) as Promise>, @@ -14,5 +14,8 @@ const createChromeStorageArea = ( remove: (keys) => area.remove(keys), }); -export const localStorageArea = createChromeStorageArea(chrome.storage.local); -export const sessionStorageArea = createChromeStorageArea(chrome.storage.session); +export const getLocalStorageArea = (): ChromeStorageArea => + createChromeStorageArea(chrome.storage.local); + +export const getSessionStorageArea = (): ChromeStorageArea => + createChromeStorageArea(chrome.storage.session); diff --git a/src/infrastructure/chrome/storageKeys.test.ts b/src/infrastructure/chrome/storageKeys.test.ts new file mode 100644 index 0000000..fcd0ab0 --- /dev/null +++ b/src/infrastructure/chrome/storageKeys.test.ts @@ -0,0 +1,16 @@ +import { STORAGE_KEYS } from "./storageKeys"; + +describe("storageKeys", () => { + test("keeps legacy dynamic storage key strings", () => { + expect(STORAGE_KEYS.tabFilters(12)).toBe("filters.12"); + expect(STORAGE_KEYS.tabEnabled("12")).toBe("enabled.12"); + }); + + test("rejects missing tab ids at compile time", () => { + // @ts-expect-error typed storage boundaries require a resolved tab id. + STORAGE_KEYS.tabFilters(null); + + // @ts-expect-error typed storage boundaries require a resolved tab id. + STORAGE_KEYS.tabFilters(undefined); + }); +}); diff --git a/src/infrastructure/chrome/storageKeys.ts b/src/infrastructure/chrome/storageKeys.ts index 58e4517..abb505f 100644 --- a/src/infrastructure/chrome/storageKeys.ts +++ b/src/infrastructure/chrome/storageKeys.ts @@ -1,6 +1,6 @@ export type StorageKey = string; -type DynamicStorageKeyPart = number | string | null | undefined; +type DynamicStorageKeyPart = number | string; export const STORAGE_KEYS = { PRESETS: "presets", diff --git a/src/infrastructure/dom/getRequiredElement.test.ts b/src/infrastructure/dom/getRequiredElement.test.ts new file mode 100644 index 0000000..e8bb2ee --- /dev/null +++ b/src/infrastructure/dom/getRequiredElement.test.ts @@ -0,0 +1,45 @@ +import { getRequiredElement } from "./getRequiredElement"; + +class ExpectedElement {} +class ActualElement { + tagName = "DIV"; +} + +const createRoot = (element: unknown | null): ParentNode => + ({ + querySelector: vi.fn(() => element), + }) as unknown as ParentNode; + +const expectedConstructor = ExpectedElement as unknown as { + new (): Element; +}; + +describe("getRequiredElement", () => { + test("does not require CSS.escape when looking up missing elements", () => { + const originalCss = globalThis.CSS; + + try { + delete (globalThis as { CSS?: typeof CSS }).CSS; + + expect(() => + getRequiredElement(createRoot(null), "missing.id", expectedConstructor), + ).toThrow("Missing required element #missing.id"); + } finally { + if (originalCss) { + (globalThis as { CSS?: typeof CSS }).CSS = originalCss; + } + } + }); + + test("reports expected and actual element types", () => { + expect(() => + getRequiredElement( + createRoot(new ActualElement()), + "settings-modal", + expectedConstructor, + ), + ).toThrow( + "Element #settings-modal has an unexpected type. Expected ExpectedElement, received ActualElement/div.", + ); + }); +}); diff --git a/src/infrastructure/dom/getRequiredElement.ts b/src/infrastructure/dom/getRequiredElement.ts index 8e69796..64f5efc 100644 --- a/src/infrastructure/dom/getRequiredElement.ts +++ b/src/infrastructure/dom/getRequiredElement.ts @@ -2,19 +2,34 @@ type ElementConstructor = { new (): TElement; }; +const escapeSelectorId = (id: string): string => { + if (globalThis.CSS?.escape) return globalThis.CSS.escape(id); + + return id.replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`); +}; + +const getElementDescription = (element: Element): string => { + const constructorName = element.constructor?.name ?? "unknown constructor"; + const tagName = element.tagName ? `/${element.tagName.toLowerCase()}` : ""; + + return `${constructorName}${tagName}`; +}; + export const getRequiredElement = ( root: ParentNode, id: string, expectedConstructor: ElementConstructor, ): TElement => { - const element = root.querySelector(`#${CSS.escape(id)}`); + const element = root.querySelector(`#${escapeSelectorId(id)}`); if (!element) { throw new Error(`Missing required element #${id}`); } if (!(element instanceof expectedConstructor)) { - throw new Error(`Element #${id} has an unexpected type`); + throw new Error( + `Element #${id} has an unexpected type. Expected ${expectedConstructor.name}, received ${getElementDescription(element)}.`, + ); } return element; From a1c4495257299bc157398457bd6c26a726f5ca19 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 17:31:22 +0300 Subject: [PATCH 08/63] feat: migrate background service worker to TypeScript --- src/app/background/autostartOnTab.ts | 44 +++++ src/app/background/background.ts | 50 ++++- src/app/background/installUpdateNotice.ts | 18 ++ src/app/background/messageRouter.test.ts | 142 +++++++++++++ src/app/background/messageRouter.ts | 87 ++++++++ src/app/background/registerContentScripts.ts | 28 +++ src/app/background/storageCleanup.ts | 41 ++++ src/app/background/windowModeCoordinator.ts | 198 +++++++++++++++++++ 8 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 src/app/background/autostartOnTab.ts create mode 100644 src/app/background/installUpdateNotice.ts create mode 100644 src/app/background/messageRouter.test.ts create mode 100644 src/app/background/messageRouter.ts create mode 100644 src/app/background/registerContentScripts.ts create mode 100644 src/app/background/storageCleanup.ts create mode 100644 src/app/background/windowModeCoordinator.ts diff --git a/src/app/background/autostartOnTab.ts b/src/app/background/autostartOnTab.ts new file mode 100644 index 0000000..53bbe13 --- /dev/null +++ b/src/app/background/autostartOnTab.ts @@ -0,0 +1,44 @@ +import { findWhitelistMatch } from "../../domains/autostart/autostartRules"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +export interface ApplyAutostartOptions { + resetWhenNoMatch?: boolean; +} + +type PresetStore = Record; + +export const applyAutostartForTab = async ( + tabId: number | undefined, + url: string | undefined, + options: ApplyAutostartOptions = {} +): Promise => { + if (tabId == null || !url || !url.startsWith("http")) return; + + const stored = await chrome.storage.local.get([ + STORAGE_KEYS.AUTOSTART_RULES, + STORAGE_KEYS.PRESETS, + ]); + const entry = findWhitelistMatch( + stored[STORAGE_KEYS.AUTOSTART_RULES], + url + ); + if (!entry) { + if (options.resetWhenNoMatch) { + await chrome.storage.local.set({ [STORAGE_KEYS.tabEnabled(tabId)]: false }); + } + return; + } + + const presets = stored[STORAGE_KEYS.PRESETS] as PresetStore | undefined; + const preset = presets?.[entry.presetName]; + if (!Array.isArray(preset) || preset.length === 0) { + await chrome.storage.local.set({ [STORAGE_KEYS.tabEnabled(tabId)]: false }); + return; + } + + await chrome.storage.local.set({ + [STORAGE_KEYS.tabFilters(tabId)]: preset, + [STORAGE_KEYS.FILTERS]: preset, + [STORAGE_KEYS.tabEnabled(tabId)]: true, + }); +}; diff --git a/src/app/background/background.ts b/src/app/background/background.ts index cb0ff5c..3f84605 100644 --- a/src/app/background/background.ts +++ b/src/app/background/background.ts @@ -1 +1,49 @@ -export {}; +import { applyAutostartForTab } from "./autostartOnTab"; +import { prepareInstallUpdateNotice } from "./installUpdateNotice"; +import { createRuntimeMessageHandler } from "./messageRouter"; +import { registerContentScripts } from "./registerContentScripts"; +import { clearUnusedStorage } from "./storageCleanup"; +import { + clearToolkitWindowState, + getCapturedTabs, + getToolkitWindowId, + toggleWindowMode, +} from "./windowModeCoordinator"; + +chrome.runtime.onStartup.addListener(registerContentScripts); +chrome.runtime.onInstalled.addListener(async (details) => { + await registerContentScripts(); + await prepareInstallUpdateNotice(details); +}); + +const runtimeMessageHandler = createRuntimeMessageHandler({ + applyAutostartForTab, + clearUnusedStorage, + getCapturedTabs, + toggleWindowMode, +}); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => + runtimeMessageHandler( + message, + sender, + sendResponse as (response?: unknown) => void + ) ?? undefined +); + +chrome.tabs.onActivated.addListener(async ({ tabId }) => { + try { + const tab = await chrome.tabs.get(tabId); + await applyAutostartForTab(tabId, tab.url); + } catch (e) {} +}); + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (!changeInfo.url) return; + void applyAutostartForTab(tabId, tab.url); +}); + +chrome.windows.onRemoved.addListener(async (windowId) => { + const id = await getToolkitWindowId(); + if (id === windowId) await clearToolkitWindowState(); +}); diff --git a/src/app/background/installUpdateNotice.ts b/src/app/background/installUpdateNotice.ts new file mode 100644 index 0000000..4b583e5 --- /dev/null +++ b/src/app/background/installUpdateNotice.ts @@ -0,0 +1,18 @@ +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +export interface InstallUpdateDetails { + reason: chrome.runtime.OnInstalledReason; +} + +export const prepareInstallUpdateNotice = async ( + details: InstallUpdateDetails +): Promise => { + if (!["install", "update"].includes(details.reason)) return; + + await chrome.storage.local.set({ + [STORAGE_KEYS.INSTALL_UPDATE_NOTICE]: { + reason: details.reason, + version: chrome.runtime.getManifest().version, + }, + }); +}; diff --git a/src/app/background/messageRouter.test.ts b/src/app/background/messageRouter.test.ts new file mode 100644 index 0000000..e6b2ba4 --- /dev/null +++ b/src/app/background/messageRouter.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { RUNTIME_MESSAGES } from "../../infrastructure/chrome/runtimeMessages"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; +import { createRuntimeMessageHandler } from "./messageRouter"; + +const createChromeMock = () => { + const localSet = vi.fn(); + const sessionGet = vi.fn(); + const sessionSet = vi.fn(); + const setBadgeText = vi.fn(); + + vi.stubGlobal("chrome", { + action: { + setBadgeText, + }, + storage: { + local: { + set: localSet, + }, + session: { + get: sessionGet, + set: sessionSet, + }, + }, + }); + + return { + localSet, + sessionGet, + sessionSet, + setBadgeText, + }; +}; + +const flushPromises = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +describe("createRuntimeMessageHandler", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + test("returns captured tabs asynchronously", async () => { + createChromeMock(); + const capturedTabs = { + tabs: [{ id: 12, title: "Example", url: "https://example.com" }], + activeTabId: 12, + }; + const response = vi.fn(); + const handler = createRuntimeMessageHandler({ + applyAutostartForTab: vi.fn(), + clearUnusedStorage: vi.fn(), + getCapturedTabs: vi.fn().mockResolvedValue(capturedTabs), + toggleWindowMode: vi.fn(), + }); + + const result = handler( + { method: RUNTIME_MESSAGES.GET_CAPTURED_TABS }, + {}, + response + ); + await flushPromises(); + + expect(result).toBe(true); + expect(response).toHaveBeenCalledWith(capturedTabs); + }); + + test("falls back to empty captured tabs when retrieval fails", async () => { + createChromeMock(); + const response = vi.fn(); + const handler = createRuntimeMessageHandler({ + applyAutostartForTab: vi.fn(), + clearUnusedStorage: vi.fn(), + getCapturedTabs: vi.fn().mockRejectedValue(new Error("gone")), + toggleWindowMode: vi.fn(), + }); + + const result = handler( + { method: RUNTIME_MESSAGES.GET_CAPTURED_TABS }, + {}, + response + ); + await flushPromises(); + + expect(result).toBe(true); + expect(response).toHaveBeenCalledWith({ tabs: [], activeTabId: null }); + }); + + test("registers sender tab id before responding", () => { + const chromeMock = createChromeMock(); + chromeMock.sessionGet.mockImplementation((_keys, callback) => { + callback({ [STORAGE_KEYS.REGISTERED_TAB_IDS]: [3] }); + }); + const response = vi.fn(); + const handler = createRuntimeMessageHandler({ + applyAutostartForTab: vi.fn(), + clearUnusedStorage: vi.fn(), + getCapturedTabs: vi.fn(), + toggleWindowMode: vi.fn(), + }); + + const result = handler( + { method: RUNTIME_MESSAGES.GET_TAB_ID }, + { tab: { id: 7 } as chrome.tabs.Tab }, + response + ); + + expect(result).toBe(true); + expect(chromeMock.sessionSet).toHaveBeenCalledWith({ + [STORAGE_KEYS.REGISTERED_TAB_IDS]: [3, 7], + }); + expect(response).toHaveBeenCalledWith(7); + }); + + test("applies autostart with reset when page starts", () => { + createChromeMock(); + const applyAutostartForTab = vi.fn(); + const handler = createRuntimeMessageHandler({ + applyAutostartForTab, + clearUnusedStorage: vi.fn(), + getCapturedTabs: vi.fn(), + toggleWindowMode: vi.fn(), + }); + + const result = handler( + { method: RUNTIME_MESSAGES.PAGE_STARTED }, + { tab: { id: 9, url: "https://example.com" } as chrome.tabs.Tab }, + vi.fn() + ); + + expect(result).toBe(true); + expect(applyAutostartForTab).toHaveBeenCalledWith( + 9, + "https://example.com", + { resetWhenNoMatch: true } + ); + }); +}); diff --git a/src/app/background/messageRouter.ts b/src/app/background/messageRouter.ts new file mode 100644 index 0000000..89cf605 --- /dev/null +++ b/src/app/background/messageRouter.ts @@ -0,0 +1,87 @@ +import { RUNTIME_MESSAGES } from "../../infrastructure/chrome/runtimeMessages"; +import type { RuntimeMessage } from "../../infrastructure/chrome/runtimeMessages"; +import type { RuntimeMessageHandler } from "../../infrastructure/chrome/runtimeMessaging"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; +import type { ApplyAutostartOptions } from "./autostartOnTab"; +import type { CapturedTabsResult } from "./windowModeCoordinator"; + +interface BackgroundRuntimeMessage extends RuntimeMessage { + message?: unknown; + tabId?: number; +} + +export interface RuntimeMessageHandlerDependencies { + applyAutostartForTab: ( + tabId: number | undefined, + url: string | undefined, + options?: ApplyAutostartOptions + ) => Promise | void; + clearUnusedStorage: () => Promise | void; + getCapturedTabs: () => Promise; + toggleWindowMode: (tabId?: number) => Promise | void; +} + +export const createRuntimeMessageHandler = ({ + applyAutostartForTab, + clearUnusedStorage, + getCapturedTabs, + toggleWindowMode, +}: RuntimeMessageHandlerDependencies): RuntimeMessageHandler => { + return (request, sender, response) => { + if (request.method === RUNTIME_MESSAGES.LOG) { + console.log(request.message); + return; + } + + if (request.method === RUNTIME_MESSAGES.ENABLE_WINDOW_MODE) { + toggleWindowMode(request.tabId); + return; + } + + if (request.method === RUNTIME_MESSAGES.GET_CAPTURED_TABS) { + getCapturedTabs() + .then(response) + .catch(() => { + response({ tabs: [], activeTabId: null }); + }); + return true; + } + + const tabId = sender.tab?.id; + if (tabId == null) return; + + if (request.method === RUNTIME_MESSAGES.SPECTRUM_FRAME) { + chrome.storage.local.set({ + [STORAGE_KEYS.tabSpectrum(tabId)]: request.payload, + }); + } else if (request.method === RUNTIME_MESSAGES.GET_TAB_ID) { + chrome.storage.session.get([STORAGE_KEYS.REGISTERED_TAB_IDS], (prefs) => { + const tabs = Array.isArray(prefs?.[STORAGE_KEYS.REGISTERED_TAB_IDS]) + ? prefs[STORAGE_KEYS.REGISTERED_TAB_IDS] + : []; + if (!tabs.includes(tabId)) { + tabs.push(tabId); + chrome.storage.session.set({ + [STORAGE_KEYS.REGISTERED_TAB_IDS]: tabs, + }); + } + }); + response(tabId); + } else if (request.method === RUNTIME_MESSAGES.PAGE_STARTED) { + applyAutostartForTab(tabId, sender.tab?.url, { resetWhenNoMatch: true }); + } else if (request.method === RUNTIME_MESSAGES.CONNECTED) { + chrome.action.setBadgeText({ + text: "ON", + tabId, + }); + } else if (request.method === RUNTIME_MESSAGES.DISCONNECTED) { + chrome.action.setBadgeText({ + text: "OFF", + tabId, + }); + } else if (request.method === RUNTIME_MESSAGES.CLEAR_STORAGE) { + clearUnusedStorage(); + } + return true; + }; +}; diff --git a/src/app/background/registerContentScripts.ts b/src/app/background/registerContentScripts.ts new file mode 100644 index 0000000..c128659 --- /dev/null +++ b/src/app/background/registerContentScripts.ts @@ -0,0 +1,28 @@ +const CONTENT_SCRIPT_PROPS: Pick< + chrome.scripting.RegisteredContentScript, + "matches" | "allFrames" | "matchOriginAsFallback" | "runAt" +> = { + matches: ["*://*/*"], + allFrames: true, + matchOriginAsFallback: true, + runAt: "document_start", +}; + +export const registerContentScripts = async (): Promise => { + await chrome.scripting.unregisterContentScripts(); + + await chrome.scripting.registerContentScripts([ + { + id: "events", + js: ["scripts/content-isolated.js"], + world: "ISOLATED", + ...CONTENT_SCRIPT_PROPS, + }, + { + id: "filter", + js: ["scripts/content-main.js"], + world: "MAIN", + ...CONTENT_SCRIPT_PROPS, + }, + ]); +}; diff --git a/src/app/background/storageCleanup.ts b/src/app/background/storageCleanup.ts new file mode 100644 index 0000000..5e869c4 --- /dev/null +++ b/src/app/background/storageCleanup.ts @@ -0,0 +1,41 @@ +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +const CONSTANT_STORAGE_KEYS = [ + STORAGE_KEYS.PRESETS, + STORAGE_KEYS.PRESET_NAMES, + STORAGE_KEYS.AUTOSTART_RULES, + "gain", + STORAGE_KEYS.FILTERS, + STORAGE_KEYS.ENABLE_SPECTRUM, + STORAGE_KEYS.THEME, + STORAGE_KEYS.UI_LANGUAGE, + STORAGE_KEYS.SHORTCUTS, + STORAGE_KEYS.INSTALL_UPDATE_NOTICE, +] as const; + +export const clearUnusedStorage = async (): Promise => { + const stored = await chrome.storage.session.get([ + STORAGE_KEYS.REGISTERED_TAB_IDS, + ]); + const tabIds = Array.isArray(stored[STORAGE_KEYS.REGISTERED_TAB_IDS]) + ? stored[STORAGE_KEYS.REGISTERED_TAB_IDS] + : []; + + chrome.storage.local.getKeys((keys) => + keys.forEach((key) => { + const keyParts = key.split("."); + const isRegisteredTabValue = + keyParts.length === 2 && + tabIds.includes(Number.parseInt(keyParts[1], 10)); + + if ( + !CONSTANT_STORAGE_KEYS.includes( + key as (typeof CONSTANT_STORAGE_KEYS)[number] + ) && + !isRegisteredTabValue + ) { + chrome.storage.local.remove(key); + } + }) + ); +}; diff --git a/src/app/background/windowModeCoordinator.ts b/src/app/background/windowModeCoordinator.ts new file mode 100644 index 0000000..4a7462a --- /dev/null +++ b/src/app/background/windowModeCoordinator.ts @@ -0,0 +1,198 @@ +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +export interface CapturedTab { + id: number | undefined; + title: string | undefined; + url: string | undefined; + favIconUrl: string | undefined; + active: boolean; +} + +export interface CapturedTabsResult { + tabs: CapturedTab[]; + activeTabId: number | null; +} + +type CaptureStreamIds = Record; + +interface WindowUpdateInfo { + focused?: boolean; + state?: chrome.windows.WindowState; +} + +export const getCurrentTabId = async (): Promise => { + const queryOptions = { active: true, lastFocusedWindow: true }; + const [tab] = await chrome.tabs.query(queryOptions); + return tab?.id; +}; + +export const toggleWindowMode = async (tabId?: number): Promise => { + const targetTabId = tabId ?? (await getCurrentTabId()); + let windowId = await getToolkitWindowId(); + await prepareTabCapture(targetTabId); + + if (await isToolkitWindowExist()) { + windowId = await getToolkitWindowId(); + await addTabIdToToolkitWindowStore(targetTabId); + await focusToolkitWindow(windowId); + return; + } + + await createToolkitWindow(targetTabId); +}; + +export const getToolkitWindowId = async (): Promise => { + const obj = await chrome.storage.session.get(STORAGE_KEYS.TOOLKIT_WINDOW_ID); + return (obj[STORAGE_KEYS.TOOLKIT_WINDOW_ID] as number | undefined) ?? null; +}; + +export const isToolkitWindowExist = async (): Promise => { + const windowId = await getToolkitWindowId(); + if (!windowId) return false; + + try { + await chrome.windows.get(windowId); + return true; + } catch (e) { + await clearToolkitWindowState(); + return false; + } +}; + +export const addTabIdToToolkitWindowStore = async ( + tabId: number | undefined +): Promise => { + if (tabId == null) return; + + const stored = await chrome.storage.session.get( + STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS + ); + const tabIds = Array.isArray(stored[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS]) + ? stored[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS] + : []; + + if (!tabIds.includes(tabId)) { + tabIds.push(tabId); + await chrome.storage.session.set({ + [STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS]: tabIds, + }); + } +}; + +export const getCapturedTabs = async (): Promise => { + const stored = await chrome.storage.session.get([ + STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS, + STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID, + ]); + const tabIds = Array.isArray(stored[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS]) + ? stored[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS] + : []; + const activeTabId = + (stored[STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID] as number | undefined) ?? + null; + const tabs: CapturedTab[] = []; + + for (const tabId of tabIds) { + try { + const tab = await chrome.tabs.get(tabId); + tabs.push({ + id: tab.id, + title: tab.title, + url: tab.url, + favIconUrl: tab.favIconUrl, + active: tab.id === activeTabId, + }); + } catch (e) {} + } + + return { tabs, activeTabId }; +}; + +export const focusToolkitWindow = async ( + windowId: number | null +): Promise => { + if (windowId == null) return; + + try { + const window = await chrome.windows.get(windowId); + const updateInfo: WindowUpdateInfo = { focused: true }; + if (window.state === "minimized") updateInfo.state = "normal"; + await chrome.windows.update(windowId, updateInfo); + } catch (e) { + await clearToolkitWindowState(); + } +}; + +export const clearToolkitWindowState = async (): Promise => { + await chrome.storage.session.remove([ + STORAGE_KEYS.TOOLKIT_WINDOW_ID, + STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS, + STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID, + STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS, + ]); +}; + +export const prepareTabCapture = async ( + tabId: number | undefined +): Promise => { + if (tabId == null) return; + + await chrome.storage.local.set({ + [STORAGE_KEYS.tabEnabled(tabId)]: false, + }); + + try { + const streamId = await chrome.tabCapture.getMediaStreamId({ + targetTabId: tabId, + }); + const stored = await chrome.storage.session.get( + STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS + ); + const streamIds = + (stored[STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS] as + | CaptureStreamIds + | undefined) ?? {}; + streamIds[tabId] = streamId; + await chrome.storage.session.set({ + [STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]: tabId, + [STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS]: streamIds, + }); + } catch (e) { + const stored = await chrome.storage.session.get( + STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS + ); + const streamIds = + (stored[STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS] as + | CaptureStreamIds + | undefined) ?? {}; + delete streamIds[tabId]; + await chrome.storage.session.set({ + [STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]: tabId, + [STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS]: streamIds, + }); + await chrome.storage.local.set({ + [STORAGE_KEYS.tabCaptureError(tabId)]: + e instanceof Error ? e.message : "Tab audio capture failed", + }); + } +}; + +export const createToolkitWindow = async ( + tabId: number | undefined +): Promise => { + const window = await chrome.windows.create({ + url: chrome.runtime.getURL("popup.html?mode=window"), + type: "popup", + width: 560, + height: 586, + focused: true, + }); + const storageValues: Record = { + [STORAGE_KEYS.TOOLKIT_WINDOW_ID]: window!.id, + }; + if (tabId != null) { + storageValues[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS] = [tabId]; + } + await chrome.storage.session.set(storageValues); + return window!.id; +}; From a32f1995242bd7d5a7c7ed7fb659724226ca6751 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 17:40:22 +0300 Subject: [PATCH 09/63] fix: close fire-and-forget background message channels --- src/app/background/messageRouter.test.ts | 44 ++++++++++++++++++++++-- src/app/background/messageRouter.ts | 2 +- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/app/background/messageRouter.test.ts b/src/app/background/messageRouter.test.ts index e6b2ba4..7f05e96 100644 --- a/src/app/background/messageRouter.test.ts +++ b/src/app/background/messageRouter.test.ts @@ -109,7 +109,7 @@ describe("createRuntimeMessageHandler", () => { response ); - expect(result).toBe(true); + expect(result).toBeUndefined(); expect(chromeMock.sessionSet).toHaveBeenCalledWith({ [STORAGE_KEYS.REGISTERED_TAB_IDS]: [3, 7], }); @@ -132,11 +132,51 @@ describe("createRuntimeMessageHandler", () => { vi.fn() ); - expect(result).toBe(true); + expect(result).toBeUndefined(); expect(applyAutostartForTab).toHaveBeenCalledWith( 9, "https://example.com", { resetWhenNoMatch: true } ); }); + + test("does not keep channel open for fire-and-forget tab messages", () => { + const chromeMock = createChromeMock(); + const handler = createRuntimeMessageHandler({ + applyAutostartForTab: vi.fn(), + clearUnusedStorage: vi.fn(), + getCapturedTabs: vi.fn(), + toggleWindowMode: vi.fn(), + }); + + const result = handler( + { method: RUNTIME_MESSAGES.CONNECTED }, + { tab: { id: 11 } as chrome.tabs.Tab }, + vi.fn() + ); + + expect(result).toBeUndefined(); + expect(chromeMock.setBadgeText).toHaveBeenCalledWith({ + text: "ON", + tabId: 11, + }); + }); + + test("does not keep channel open for unknown tab messages", () => { + createChromeMock(); + const handler = createRuntimeMessageHandler({ + applyAutostartForTab: vi.fn(), + clearUnusedStorage: vi.fn(), + getCapturedTabs: vi.fn(), + toggleWindowMode: vi.fn(), + }); + + const result = handler( + { method: "unknown" as typeof RUNTIME_MESSAGES.GET_TAB_ID }, + { tab: { id: 13 } as chrome.tabs.Tab }, + vi.fn() + ); + + expect(result).toBeUndefined(); + }); }); diff --git a/src/app/background/messageRouter.ts b/src/app/background/messageRouter.ts index 89cf605..b50bdb6 100644 --- a/src/app/background/messageRouter.ts +++ b/src/app/background/messageRouter.ts @@ -82,6 +82,6 @@ export const createRuntimeMessageHandler = ({ } else if (request.method === RUNTIME_MESSAGES.CLEAR_STORAGE) { clearUnusedStorage(); } - return true; + return; }; }; From de518af387dd310cd3fe9f3eb35e12937a108bd1 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 17:55:13 +0300 Subject: [PATCH 10/63] feat: migrate content scripts to bundled TypeScript --- src/app/content-isolated/contentIsolated.ts | 270 ++++++++++++- src/app/content-main/contentMain.ts | 400 +++++++++++++++++++- src/domains/audio/biquadChain.test.ts | 64 ++++ src/domains/audio/biquadChain.ts | 38 ++ 4 files changed, 770 insertions(+), 2 deletions(-) create mode 100644 src/domains/audio/biquadChain.test.ts create mode 100644 src/domains/audio/biquadChain.ts diff --git a/src/app/content-isolated/contentIsolated.ts b/src/app/content-isolated/contentIsolated.ts index cb0ff5c..995867e 100644 --- a/src/app/content-isolated/contentIsolated.ts +++ b/src/app/content-isolated/contentIsolated.ts @@ -1 +1,269 @@ -export {}; +import { + SHORTCUT_ACTION_MUTE_NAME, + SHORTCUT_ACTION_TOGGLE_EQ_NAME, + isEditableShortcutTarget, + matchesShortcut, + resolveShortcuts, + type ShortcutMap, +} from "../../domains/shortcuts/shortcuts"; +import { + createDefaultFilterSettings, + normalizeFilterSettings, +} from "../../domains/equalizer/defaultFilters"; + +import type { RuntimeMessageMethod } from "../../infrastructure/chrome/runtimeMessages"; +import type { STORAGE_KEYS as INFRASTRUCTURE_STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +type InfrastructureStorageKeys = typeof INFRASTRUCTURE_STORAGE_KEYS; +type RuntimeMessagePayload = { + method: RuntimeMessageMethod; + payload?: unknown; +}; +type SendRuntimeMessageWithCallback = ( + message: RuntimeMessagePayload, + callback: (response: unknown) => void, +) => void; + +const CONTENT_RUNTIME_MESSAGES = { + GET_TAB_ID: "getTabId", + CONNECTED: "connected", + DISCONNECTED: "disconnected", + SPECTRUM_FRAME: "spectrum-frame", + PAGE_STARTED: "pageStarted", + CLEAR_STORAGE: "clearStorage", +} as const satisfies Record; + +const STORAGE_KEYS = { + ENABLE_SPECTRUM: "enableSpectrum", + SHORTCUTS: "shortcuts", + tabFilters: (tabId: number | string) => `filters.${tabId}`, + tabEnabled: (tabId: number | string) => `enabled.${tabId}`, + tabMute: (tabId: number | string) => `mute.${tabId}`, + tabVolume: (tabId: number | string) => `volume.${tabId}`, + tabPan: (tabId: number | string) => `pan.${tabId}`, + tabCaptureError: (tabId: number | string) => `captureError.${tabId}`, +} as const satisfies Pick< + InfrastructureStorageKeys, + | "ENABLE_SPECTRUM" + | "SHORTCUTS" + | "tabFilters" + | "tabEnabled" + | "tabMute" + | "tabVolume" + | "tabPan" + | "tabCaptureError" +>; + +const sendRuntimeMessageWithCallback = + chrome.runtime.sendMessage as unknown as SendRuntimeMessageWithCallback; + +const port = document.createElement("span"); +port.id = "eq-tools-port"; +document.documentElement.append(port); + +let currentTabId: number | null = null; +let shortcuts = resolveShortcuts(null); + +const getTabId = (callback: (tabId: number) => void): void => { + sendRuntimeMessageWithCallback( + { method: CONTENT_RUNTIME_MESSAGES.GET_TAB_ID }, + (tabId) => { + if (typeof tabId !== "number") return; + + currentTabId = tabId; + callback(tabId); + }, + ); +}; + +const withTabId = (callback: (tabId: number) => void): void => { + if (currentTabId !== null) { + callback(currentTabId); + return; + } + + getTabId(callback); +}; + +const setCaptureError = (message: string): void => { + withTabId((tabId) => { + chrome.storage.local.set({ + [STORAGE_KEYS.tabCaptureError(tabId)]: message, + }); + }); +}; + +const clearCaptureError = (): void => { + withTabId((tabId) => { + chrome.storage.local.remove(STORAGE_KEYS.tabCaptureError(tabId)); + }); +}; + +const getCaptureErrorMessage = (event: Event): string => { + const detail = (event as CustomEvent<{ message?: unknown }>).detail; + return typeof detail?.message === "string" + ? detail.message + : "Audio capture failed"; +}; + +port.addEventListener("connected", () => { + clearCaptureError(); + chrome.runtime.sendMessage({ + method: CONTENT_RUNTIME_MESSAGES.CONNECTED, + }); +}); + +port.addEventListener("disconnected", () => { + chrome.runtime.sendMessage({ + method: CONTENT_RUNTIME_MESSAGES.DISCONNECTED, + }); +}); + +port.addEventListener("capture-error", (event) => { + setCaptureError(getCaptureErrorMessage(event)); +}); + +getTabId((tabId) => { + const defaultFilters = createDefaultFilterSettings(); + chrome.storage.local.get( + { + [STORAGE_KEYS.tabVolume(tabId)]: 1, + [STORAGE_KEYS.tabPan(tabId)]: 0, + [STORAGE_KEYS.tabFilters(tabId)]: defaultFilters, + [STORAGE_KEYS.ENABLE_SPECTRUM]: false, + [STORAGE_KEYS.tabEnabled(tabId)]: false, + [STORAGE_KEYS.tabMute(tabId)]: false, + }, + (prefs) => { + const filters = prefs[STORAGE_KEYS.tabFilters(tabId)] ?? defaultFilters; + const freqsMapped = normalizeFilterSettings(filters); + port.dataset.freqs = JSON.stringify(freqsMapped); + port.dataset.pan = String(prefs[STORAGE_KEYS.tabPan(tabId)]); + port.dataset.preamp = String(prefs[STORAGE_KEYS.tabVolume(tabId)]); + port.dataset.enabled = String(prefs[STORAGE_KEYS.tabEnabled(tabId)]); + port.dataset.mute = String(prefs[STORAGE_KEYS.tabMute(tabId)]); + port.dataset.enableSpectrum = String(prefs[STORAGE_KEYS.ENABLE_SPECTRUM]); + + if (prefs[STORAGE_KEYS.tabMute(tabId)]) { + port.dispatchEvent(new Event("mute-enabled")); + } + if (prefs[STORAGE_KEYS.tabEnabled(tabId)]) { + port.dispatchEvent(new Event("enabled-changed")); + } + }, + ); +}); + +chrome.storage.onChanged.addListener((changes) => { + if (changes[STORAGE_KEYS.SHORTCUTS]) { + shortcuts = resolveShortcuts( + changes[STORAGE_KEYS.SHORTCUTS].newValue as Partial | null, + ); + } + + withTabId((tabId) => { + const tabFiltersKey = STORAGE_KEYS.tabFilters(tabId); + if (changes[tabFiltersKey]) { + const newFilters = normalizeFilterSettings(changes[tabFiltersKey].newValue); + port.dataset.freqs = JSON.stringify(newFilters); + port.dispatchEvent(new Event("filters-changed")); + } + + const tabVolumeKey = STORAGE_KEYS.tabVolume(tabId); + if (changes[tabVolumeKey]) { + port.dataset.preamp = String(changes[tabVolumeKey].newValue); + if (port.dataset.mute !== "true") { + port.dispatchEvent(new Event("preamp-changed")); + } + } + + const tabEnabledKey = STORAGE_KEYS.tabEnabled(tabId); + if (changes[tabEnabledKey]) { + port.dataset.enabled = String(changes[tabEnabledKey].newValue); + port.dispatchEvent(new Event("enabled-changed")); + } + + const tabMuteKey = STORAGE_KEYS.tabMute(tabId); + if (changes[tabMuteKey]) { + port.dataset.mute = String(changes[tabMuteKey].newValue); + if (changes[tabMuteKey].newValue) { + port.dispatchEvent(new Event("mute-enabled")); + } else { + port.dispatchEvent(new Event("mute-disabled")); + } + } + }); +}); + +chrome.storage.local.get([STORAGE_KEYS.SHORTCUTS], (prefs) => { + shortcuts = resolveShortcuts( + prefs[STORAGE_KEYS.SHORTCUTS] as Partial | null, + ); +}); + +const toggleTabStorageValue = ( + tabId: number, + key: string, + options: { enableTab?: boolean } = {}, +): void => { + chrome.storage.local.get([key]).then((prefs) => { + const values: Record = { + [key]: !prefs[key], + }; + if (options.enableTab) { + values[STORAGE_KEYS.tabEnabled(tabId)] = true; + } + chrome.storage.local.set(values); + }); +}; + +document.addEventListener( + "keydown", + (event) => { + if (event.repeat || isEditableShortcutTarget(event.target)) return; + + if (matchesShortcut(event, shortcuts[SHORTCUT_ACTION_MUTE_NAME])) { + event.preventDefault(); + event.stopPropagation(); + withTabId((tabId) => { + toggleTabStorageValue(tabId, STORAGE_KEYS.tabMute(tabId), { + enableTab: true, + }); + }); + return; + } + + if (matchesShortcut(event, shortcuts[SHORTCUT_ACTION_TOGGLE_EQ_NAME])) { + event.preventDefault(); + event.stopPropagation(); + withTabId((tabId) => { + toggleTabStorageValue(tabId, STORAGE_KEYS.tabEnabled(tabId)); + }); + } + }, + true, +); + +port.addEventListener("spectrum-frame", (event) => { + chrome.runtime.sendMessage({ + method: CONTENT_RUNTIME_MESSAGES.SPECTRUM_FRAME, + payload: (event as CustomEvent).detail, + }); +}); + +const start = (): void => { + if (window.top !== window) return; + + sendRuntimeMessageWithCallback( + { method: CONTENT_RUNTIME_MESSAGES.GET_TAB_ID }, + () => { + chrome.runtime.sendMessage({ method: CONTENT_RUNTIME_MESSAGES.PAGE_STARTED }); + }, + ); + + setTimeout(() => { + chrome.runtime.sendMessage({ method: CONTENT_RUNTIME_MESSAGES.CLEAR_STORAGE }); + }, 1000); +}; + +start(); diff --git a/src/app/content-main/contentMain.ts b/src/app/content-main/contentMain.ts index cb0ff5c..851deb8 100644 --- a/src/app/content-main/contentMain.ts +++ b/src/app/content-main/contentMain.ts @@ -1 +1,399 @@ -export {}; +import { + applyBiquadSettings, + createBiquadFilter, + getBiquadFilterCount, + getLastBiquadFilter, +} from "../../domains/audio/biquadChain"; + +import type { EqualizerFilter } from "../../domains/equalizer/types"; + +interface EqualizerNodeChain extends Record { + preamp: GainNode; + balance: StereoPannerNode; +} + +interface CapturedMediaSource { + context: AudioContext; + source: MediaElementAudioSourceNode; +} + +type AnalyserWithLegacySampleRate = AnalyserNode & { sampleRate?: number }; +type CapturableMediaElement = HTMLMediaElement & { + captureStream?: () => MediaStream; +}; + +const port = document.getElementById("eq-tools-port") as HTMLSpanElement; +port.remove(); + +const equalizerGraphs = new Map(); +let currentAudioCtx: AudioContext | null = null; +let currentSourceNode: AudioNode | null = null; +let analyser: AnalyserWithLegacySampleRate | null = null; +let spectrumTimer: ReturnType | null = null; +const mediaSources = new WeakMap(); +const pendingMedia = new WeakSet(); +const cachedMedia = new Set(); + +const nativeConnect = AudioNode.prototype.connect; + +const getAudioContext = (source: AudioNode): AudioContext => + source.context as AudioContext; + +const connectToDestination = ( + source: AudioNode, + destination: AudioDestinationNode, +): AudioNode => Reflect.apply(nativeConnect, source, [destination]) as AudioNode; + +const readFilterSettings = (): EqualizerFilter[] => + JSON.parse(port.dataset.freqs as string) as EqualizerFilter[]; + +const getErrorMessage = (error: unknown, fallback: string): string => + error instanceof Error && error.message ? error.message : fallback; + +const rebuildBiquadChain = ( + source: AudioNode, + filters: EqualizerNodeChain, + filterSettings: EqualizerFilter[], +): void => { + filters.balance.disconnect(); + + const oldCount = getBiquadFilterCount(filters); + for (let i = 0; i < oldCount; i++) { + filters[i]?.disconnect(); + delete filters[i]; + } + + const context = getAudioContext(source); + let previousNode: AudioNode = filters.balance; + filterSettings.forEach((filter, i) => { + const biquadFilter = createBiquadFilter(context, filter); + previousNode.connect(biquadFilter); + filters[i] = biquadFilter; + previousNode = biquadFilter; + }); + + connectToDestination(previousNode, context.destination); +}; + +const startSpectrum = (): void => { + const audioCtx = currentAudioCtx; + const sourceNode = currentSourceNode; + const currentAnalyser = ensureAnalyser(audioCtx, sourceNode); + + if (!audioCtx || !sourceNode || !currentAnalyser) { + return; + } + + let payload: Record = { + type: "meta", + sampleRate: audioCtx.sampleRate, + fftSize: currentAnalyser.fftSize, + minDb: currentAnalyser.minDecibels, + maxDb: currentAnalyser.maxDecibels, + frequencyBinCount: currentAnalyser.frequencyBinCount, + }; + port.dispatchEvent( + new CustomEvent("spectrum-frame", { + detail: { ...payload }, + bubbles: true, + composed: true, + }), + ); + + if (spectrumTimer) clearInterval(spectrumTimer); + spectrumTimer = setInterval(() => { + if (!currentAnalyser) return; + + const buffer = new Float32Array(currentAnalyser.frequencyBinCount); + currentAnalyser.getFloatFrequencyData(buffer); + + payload = { + type: "spectrum", + buffer, + }; + port.dispatchEvent( + new CustomEvent("spectrum-frame", { + detail: { ...payload }, + bubbles: true, + composed: true, + }), + ); + }, 50); +}; + +const attach = (source: AudioNode): AudioNode => { + const context = getAudioContext(source); + + if (port.dataset.enabled === "false") { + return connectToDestination(source, context.destination); + } + + if (equalizerGraphs.has(source)) { + port.dispatchEvent(new Event("connected")); + return context.destination; + } + + const filters: EqualizerNodeChain = { + preamp: context.createGain(), + balance: context.createStereoPanner(), + }; + const preampValue = isNaN(Number(port.dataset.preamp)) + ? 1 + : Number(port.dataset.preamp); + filters.preamp.gain.value = port.dataset.mute === "true" ? 0 : preampValue; + source.connect(filters.preamp); + filters.balance.pan.value = 0; + filters.preamp.connect(filters.balance); + equalizerGraphs.set(source, filters); + + const filterSettings = readFilterSettings(); + rebuildBiquadChain(source, filters, filterSettings); + + if (port.dataset.enableSpectrum === "true") { + currentSourceNode = getLastBiquadFilter(filters, filters.balance); + startSpectrum(); + } + + port.dispatchEvent(new Event("connected")); + return context.destination; +}; + +const setCurrentAudioGraph = ( + audioCtx: AudioContext, + sourceNode: AudioNode, +): void => { + currentAudioCtx = audioCtx; + currentSourceNode = sourceNode; + analyser = null; +}; + +const createMediaSource = ( + target: HTMLMediaElement, +): Promise => + new Promise((resolve, reject) => { + const existing = mediaSources.get(target); + if (existing) { + setCurrentAudioGraph(existing.context, existing.source); + resolve(existing.source); + return; + } + + const context = new AudioContext(); + + const next = (): void => { + try { + const source = context.createMediaElementSource(target); + mediaSources.set(target, { context, source }); + setCurrentAudioGraph(context, source); + resolve(source); + } catch (error) { + reject(error); + } + }; + + setTimeout(() => { + try { + target.setAttribute("crossOrigin", "anonymous"); + (target as CapturableMediaElement).captureStream?.(); + next(); + } catch (error) { + if (getErrorMessage(error, "").includes("cross-origin")) { + reject(error); + } else { + next(); + } + } + }); + }); + +const detach = (): void => { + equalizerGraphs.forEach((filters, source) => { + const context = getAudioContext(source); + source.disconnect(); + getLastBiquadFilter(filters, filters.balance).disconnect(); + connectToDestination(source, context.destination); + port.dispatchEvent(new Event("disconnected")); + }); +}; + +const reattach = (): void => { + const filterSettings = readFilterSettings(); + equalizerGraphs.forEach((filters, source) => { + source.disconnect(); + source.connect(filters.preamp); + if (getBiquadFilterCount(filters) !== filterSettings.length) { + rebuildBiquadChain(source, filters, filterSettings); + } else { + filterSettings.forEach((filter, i) => { + applyBiquadSettings(filters[i] as BiquadFilterNode, filter); + }); + const lastFilter = getLastBiquadFilter(filters, filters.balance); + lastFilter.disconnect(); + connectToDestination(lastFilter, getAudioContext(source).destination); + } + + if (port.dataset.enableSpectrum === "true") { + currentSourceNode = getLastBiquadFilter(filters, filters.balance); + analyser = null; + startSpectrum(); + } + port.dispatchEvent(new Event("connected")); + }); + if (equalizerGraphs.size) { + port.dispatchEvent(new Event("connected")); + } +}; + +AudioNode.prototype.connect = new Proxy(nativeConnect, { + apply(target, self, args) { + const [node] = args; + + if (node && node instanceof AudioDestinationNode) { + try { + return attach(self as AudioNode); + } catch (error) { + console.warn("cannot equalize;", getErrorMessage(error, "")); + port.dispatchEvent(new Event("cannot-attach")); + } + } + + return Reflect.apply(target, self, args); + }, +}); + +const convert = async (target: EventTarget | null): Promise => { + if (!(target instanceof HTMLMediaElement)) return; + + if (port.dataset.enabled === "false") { + cachedMedia.add(target); + return; + } + + if (pendingMedia.has(target)) return; + + try { + pendingMedia.add(target); + const sourceNode = await createMediaSource(target); + if (!equalizerGraphs.has(sourceNode)) { + attach(sourceNode); + } else { + port.dispatchEvent(new Event("connected")); + } + } catch (error) { + port.dispatchEvent( + new CustomEvent("capture-error", { + detail: { message: getErrorMessage(error, "Unknown error") }, + }), + ); + } finally { + pendingMedia.delete(target); + } +}; + +window.addEventListener("playing", (event) => void convert(event.target), true); + +window.Audio = new Proxy(window.Audio, { + construct(target, args, newTarget) { + const result = Reflect.construct(target, args, newTarget) as HTMLAudioElement; + try { + void convert(result); + } catch (error) { + console.error(error); + } + return result; + }, +}); + +HTMLMediaElement.prototype.play = new Proxy(HTMLMediaElement.prototype.play, { + apply(target, self, args) { + const mediaElement = self as HTMLMediaElement; + if (mediaElement.isConnected === false) { + try { + void convert(mediaElement); + } catch (error) { + console.error(error); + } + } + + return Reflect.apply(target, self, args) as Promise; + }, +}); + +port.addEventListener("filters-changed", () => { + equalizerGraphs.forEach((filters, source) => { + const filterSettings = readFilterSettings(); + if (getBiquadFilterCount(filters) !== filterSettings.length) { + rebuildBiquadChain(source, filters, filterSettings); + if (port.dataset.enableSpectrum === "true") { + currentSourceNode = + filterSettings.length > 0 ? filters[filterSettings.length - 1] ?? null : null; + analyser = null; + startSpectrum(); + } + port.dispatchEvent(new Event("connected")); + return; + } + + filterSettings.forEach((filter, i) => { + applyBiquadSettings(filters[i] as BiquadFilterNode, filter); + }); + }); +}); + +port.addEventListener("preamp-changed", () => { + equalizerGraphs.forEach((filters) => { + filters.preamp.gain.value = Number(port.dataset.preamp); + }); +}); + +port.addEventListener("mute-enabled", () => { + equalizerGraphs.forEach((filters) => { + filters.preamp.gain.value = Number(0); + }); +}); + +port.addEventListener("mute-disabled", () => { + equalizerGraphs.forEach((filters) => { + filters.preamp.gain.value = Number(port.dataset.preamp); + }); +}); + +port.addEventListener("enabled-changed", () => { + if (port.dataset.enabled === "false") { + detach(); + } else { + reattach(); + + if (cachedMedia.size) { + for (const target of cachedMedia) { + void convert(target); + } + cachedMedia.clear(); + } + } +}); + +function ensureAnalyser( + audioCtx: AudioContext | null, + sourceNode: AudioNode | null, +): AnalyserWithLegacySampleRate | null { + if (!audioCtx || !sourceNode) return null; + if (analyser) return analyser; + + analyser = audioCtx.createAnalyser(); + analyser.sampleRate = 48000; + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.5; + + try { + sourceNode.connect(analyser); + } catch (error) {} + return analyser; +} + +function stopSpectrum(): void { + if (spectrumTimer) { + clearInterval(spectrumTimer); + spectrumTimer = null; + } +} diff --git a/src/domains/audio/biquadChain.test.ts b/src/domains/audio/biquadChain.test.ts new file mode 100644 index 0000000..758fba2 --- /dev/null +++ b/src/domains/audio/biquadChain.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; + +import { + applyBiquadSettings, + createBiquadFilter, + getBiquadFilterCount, + getLastBiquadFilter, +} from "./biquadChain"; + +import type { EqualizerFilter } from "../equalizer/types"; + +const createFakeBiquadFilter = (): BiquadFilterNode => + ({ + type: "lowpass", + gain: { value: 99 }, + frequency: { value: 99 }, + Q: { value: 99 }, + }) as BiquadFilterNode; + +describe("biquadChain", () => { + test("counts contiguous numeric filter slots", () => { + expect(getBiquadFilterCount({ 0: createFakeBiquadFilter(), 2: createFakeBiquadFilter() })).toBe(1); + }); + + test("applies legacy biquad defaults and numeric coercion", () => { + const biquadFilter = createFakeBiquadFilter(); + + applyBiquadSettings(biquadFilter, { freq: 125, gain: 3 } as EqualizerFilter); + + expect(biquadFilter.type).toBe("peaking"); + expect(biquadFilter.gain.value).toBe(3); + expect(biquadFilter.frequency.value).toBe(125); + expect(biquadFilter.Q.value).toBe(0.5); + }); + + test("creates a biquad filter from an audio context and applies settings", () => { + const biquadFilter = createFakeBiquadFilter(); + const context = { + createBiquadFilter: () => biquadFilter, + } as BaseAudioContext; + + expect( + createBiquadFilter(context, { + freq: 800, + gain: -4, + q: 1.5, + type: "highpass", + }), + ).toBe(biquadFilter); + expect(biquadFilter.type).toBe("highpass"); + expect(biquadFilter.gain.value).toBe(-4); + expect(biquadFilter.frequency.value).toBe(800); + expect(biquadFilter.Q.value).toBe(1.5); + }); + + test("returns the last contiguous biquad filter or the fallback node", () => { + const fallbackNode = { label: "fallback" } as unknown as AudioNode; + const firstFilter = createFakeBiquadFilter(); + const secondFilter = createFakeBiquadFilter(); + + expect(getLastBiquadFilter({}, fallbackNode)).toBe(fallbackNode); + expect(getLastBiquadFilter({ 0: firstFilter, 1: secondFilter }, fallbackNode)).toBe(secondFilter); + }); +}); diff --git a/src/domains/audio/biquadChain.ts b/src/domains/audio/biquadChain.ts new file mode 100644 index 0000000..5c8710d --- /dev/null +++ b/src/domains/audio/biquadChain.ts @@ -0,0 +1,38 @@ +import type { EqualizerFilter } from "../equalizer/types"; + +type BiquadFilterMap = Record; +type BiquadFilterInput = Pick & + Partial>; + +export const getBiquadFilterCount = (filters: BiquadFilterMap): number => { + let count = 0; + while (filters[count]) count++; + return count; +}; + +export const applyBiquadSettings = ( + biquadFilter: BiquadFilterNode, + filter: BiquadFilterInput, +): void => { + biquadFilter.type = filter.type ?? "peaking"; + biquadFilter.gain.value = Number(filter.gain ?? 0); + biquadFilter.frequency.value = Number(filter.freq); + biquadFilter.Q.value = Number(filter.q ?? 0.5); +}; + +export const createBiquadFilter = ( + context: BaseAudioContext, + filter: BiquadFilterInput, +): BiquadFilterNode => { + const biquadFilter = context.createBiquadFilter(); + applyBiquadSettings(biquadFilter, filter); + return biquadFilter; +}; + +export const getLastBiquadFilter = ( + filters: BiquadFilterMap, + fallbackNode: TFallbackNode, +): BiquadFilterNode | TFallbackNode => { + const count = getBiquadFilterCount(filters); + return count > 0 ? filters[count - 1] as BiquadFilterNode : fallbackNode; +}; From a73fba21df51c7e5919af9d2150b32cfac1aad3c Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 3 Jul 2026 18:05:28 +0300 Subject: [PATCH 11/63] fix: stop content spectrum when disabled --- src/app/content-isolated/contentIsolated.ts | 7 +++++++ src/app/content-main/contentMain.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/app/content-isolated/contentIsolated.ts b/src/app/content-isolated/contentIsolated.ts index 995867e..839cc70 100644 --- a/src/app/content-isolated/contentIsolated.ts +++ b/src/app/content-isolated/contentIsolated.ts @@ -161,6 +161,13 @@ chrome.storage.onChanged.addListener((changes) => { ); } + if (changes[STORAGE_KEYS.ENABLE_SPECTRUM]) { + port.dataset.enableSpectrum = String( + changes[STORAGE_KEYS.ENABLE_SPECTRUM].newValue, + ); + port.dispatchEvent(new Event("spectrum-state-changed")); + } + withTabId((tabId) => { const tabFiltersKey = STORAGE_KEYS.tabFilters(tabId); if (changes[tabFiltersKey]) { diff --git a/src/app/content-main/contentMain.ts b/src/app/content-main/contentMain.ts index 851deb8..6f2d0bf 100644 --- a/src/app/content-main/contentMain.ts +++ b/src/app/content-main/contentMain.ts @@ -207,6 +207,7 @@ const createMediaSource = ( }); const detach = (): void => { + stopSpectrum(); equalizerGraphs.forEach((filters, source) => { const context = getAudioContext(source); source.disconnect(); @@ -244,6 +245,23 @@ const reattach = (): void => { } }; +const updateSpectrumState = (): void => { + if (port.dataset.enableSpectrum !== "true") { + stopSpectrum(); + return; + } + + for (const [source, filters] of equalizerGraphs) { + currentAudioCtx = getAudioContext(source); + currentSourceNode = getLastBiquadFilter(filters, filters.balance); + analyser = null; + startSpectrum(); + return; + } +}; + +port.addEventListener("spectrum-state-changed", updateSpectrumState); + AudioNode.prototype.connect = new Proxy(nativeConnect, { apply(target, self, args) { const [node] = args; From 3295e3b412fef1dff0d5076d7a96fc24d18f0ab4 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 4 Jul 2026 10:29:24 +0300 Subject: [PATCH 12/63] fix: gate content spectrum on enabled state --- src/app/content-main/contentMain.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/content-main/contentMain.ts b/src/app/content-main/contentMain.ts index 6f2d0bf..6488a4e 100644 --- a/src/app/content-main/contentMain.ts +++ b/src/app/content-main/contentMain.ts @@ -246,7 +246,10 @@ const reattach = (): void => { }; const updateSpectrumState = (): void => { - if (port.dataset.enableSpectrum !== "true") { + if ( + port.dataset.enableSpectrum !== "true" || + port.dataset.enabled !== "true" + ) { stopSpectrum(); return; } From 9b46a1c17ac79461bf8c35c83446509a46c108fb Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 4 Jul 2026 10:34:46 +0300 Subject: [PATCH 13/63] fix: gate spectrum restart after filter changes --- src/app/content-main/contentMain.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/app/content-main/contentMain.ts b/src/app/content-main/contentMain.ts index 6488a4e..aab784e 100644 --- a/src/app/content-main/contentMain.ts +++ b/src/app/content-main/contentMain.ts @@ -345,12 +345,7 @@ port.addEventListener("filters-changed", () => { const filterSettings = readFilterSettings(); if (getBiquadFilterCount(filters) !== filterSettings.length) { rebuildBiquadChain(source, filters, filterSettings); - if (port.dataset.enableSpectrum === "true") { - currentSourceNode = - filterSettings.length > 0 ? filters[filterSettings.length - 1] ?? null : null; - analyser = null; - startSpectrum(); - } + updateSpectrumState(); port.dispatchEvent(new Event("connected")); return; } From 9ecaf9147cacd296dc9143ea4f2a63ca7931c5f4 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 4 Jul 2026 10:46:51 +0300 Subject: [PATCH 14/63] feat: migrate localization and theme services --- src/domains/localization/languages.test.ts | 41 +++ src/domains/localization/languages.ts | 90 +++++ .../localization/localizationService.test.ts | 64 ++++ .../localization/localizationService.ts | 196 +++++++++++ src/domains/theme/themeColors.test.ts | 37 +++ src/domains/theme/themeColors.ts | 25 ++ src/domains/theme/themes.ts | 113 +++++++ src/infrastructure/dom/popupElements.ts | 310 +++++++++++++++++- 8 files changed, 870 insertions(+), 6 deletions(-) create mode 100644 src/domains/localization/languages.test.ts create mode 100644 src/domains/localization/languages.ts create mode 100644 src/domains/localization/localizationService.test.ts create mode 100644 src/domains/localization/localizationService.ts create mode 100644 src/domains/theme/themeColors.test.ts create mode 100644 src/domains/theme/themeColors.ts create mode 100644 src/domains/theme/themes.ts diff --git a/src/domains/localization/languages.test.ts b/src/domains/localization/languages.test.ts new file mode 100644 index 0000000..9838c90 --- /dev/null +++ b/src/domains/localization/languages.test.ts @@ -0,0 +1,41 @@ +import { + AVAILABLE_LANGUAGE_CODES, + DEFAULT_LANGUAGE, + getBrowserLanguage, +} from "./languages"; + +const setNavigatorLanguage = (language: string): void => { + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: language, + }); +}; + +describe("getBrowserLanguage", () => { + beforeEach(() => { + vi.stubGlobal("chrome", { + i18n: { + getMessage: vi.fn(() => ""), + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("uses exact Chrome UI locale matches", () => { + vi.mocked(chrome.i18n.getMessage).mockReturnValue("pt_PT"); + setNavigatorLanguage("en"); + + expect(getBrowserLanguage()).toBe("pt_PT"); + }); + + test("does not use partial language matches", () => { + vi.mocked(chrome.i18n.getMessage).mockReturnValue("pt"); + setNavigatorLanguage("en-US"); + + expect(AVAILABLE_LANGUAGE_CODES).not.toContain("pt"); + expect(getBrowserLanguage()).toBe(DEFAULT_LANGUAGE); + }); +}); diff --git a/src/domains/localization/languages.ts b/src/domains/localization/languages.ts new file mode 100644 index 0000000..8ff797b --- /dev/null +++ b/src/domains/localization/languages.ts @@ -0,0 +1,90 @@ +export const LANGUAGE_KEY = "uiLanguage"; +export const DEFAULT_LANGUAGE = "en"; + +export const CHROME_I18N_LOCALES = new Map([ + ["ar", "Arabic"], + ["am", "Amharic"], + ["bg", "Bulgarian"], + ["bn", "Bengali"], + ["ca", "Catalan"], + ["cs", "Czech"], + ["da", "Danish"], + ["de", "German"], + ["el", "Greek"], + ["en", "English"], + ["en_AU", "English (Australia)"], + ["en_GB", "English (Great Britain)"], + ["en_US", "English (USA)"], + ["es", "Spanish"], + ["es_419", "Spanish (Latin America and Caribbean)"], + ["et", "Estonian"], + ["fa", "Persian"], + ["fi", "Finnish"], + ["fil", "Filipino"], + ["fr", "French"], + ["gu", "Gujarati"], + ["he", "Hebrew"], + ["hi", "Hindi"], + ["hr", "Croatian"], + ["hu", "Hungarian"], + ["id", "Indonesian"], + ["it", "Italian"], + ["ja", "Japanese"], + ["kn", "Kannada"], + ["ko", "Korean"], + ["lt", "Lithuanian"], + ["lv", "Latvian"], + ["ml", "Malayalam"], + ["mr", "Marathi"], + ["ms", "Malay"], + ["nl", "Dutch"], + ["no", "Norwegian"], + ["pl", "Polish"], + ["pt_BR", "Portuguese (Brazil)"], + ["pt_PT", "Portuguese (Portugal)"], + ["ro", "Romanian"], + ["ru", "Russian"], + ["sk", "Slovak"], + ["sl", "Slovenian"], + ["sr", "Serbian"], + ["sv", "Swedish"], + ["sw", "Swahili"], + ["ta", "Tamil"], + ["te", "Telugu"], + ["th", "Thai"], + ["tr", "Turkish"], + ["uk", "Ukrainian"], + ["vi", "Vietnamese"], + ["zh_CN", "Chinese (China)"], + ["zh_TW", "Chinese (Taiwan)"], +]); + +export const AVAILABLE_LANGUAGE_CODES = [ + "bn", + "de", + "en", + "es", + "fr", + "hi", + "id", + "it", + "ja", + "ko", + "nl", + "pt_PT", + "ru", + "th", + "tr", + "vi", + "zh_CN", +] as const; + +export type AvailableLanguageCode = (typeof AVAILABLE_LANGUAGE_CODES)[number]; + +export const getBrowserLanguage = (): AvailableLanguageCode => { + const locale = chrome.i18n.getMessage("@@ui_locale") || navigator.language; + const exactMatch = AVAILABLE_LANGUAGE_CODES.find((code) => code === locale); + if (exactMatch) return exactMatch; + + return DEFAULT_LANGUAGE; +}; diff --git a/src/domains/localization/localizationService.test.ts b/src/domains/localization/localizationService.test.ts new file mode 100644 index 0000000..1c9f6c7 --- /dev/null +++ b/src/domains/localization/localizationService.test.ts @@ -0,0 +1,64 @@ +import { createLocalizationService } from "./localizationService"; + +type MessageMap = Record; + +const createResponse = (messages: MessageMap, ok = true): Response => + ({ + ok, + json: vi.fn(async () => messages), + }) as unknown as Response; + +describe("createLocalizationService", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + vi.stubGlobal("chrome", { + i18n: { + getMessage: vi.fn((messageName: string) => + messageName === "@@ui_locale" ? "en" : "", + ), + }, + runtime: { + getURL: vi.fn((path: string) => `chrome-extension://id/${path}`), + }, + storage: { + local: { + get: vi.fn(async () => ({})), + set: vi.fn(async () => undefined), + }, + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("loads default and selected messages with instance-local fallback", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + createResponse({ reset_button_label: { message: "Reset" } }), + ) + .mockResolvedValueOnce(createResponse({})); + + const service = createLocalizationService(); + await service.ready; + + expect(service.getMessage("reset_button_label")).toBe("Reset"); + expect(service.getMessage("missing_key")).toBe("missing_key"); + }); + + test("saves selected language and runs injected dynamic refresh", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce(createResponse({})) + .mockResolvedValueOnce(createResponse({})) + .mockResolvedValueOnce(createResponse({})); + const refreshDynamicContent = vi.fn(async () => undefined); + + const service = createLocalizationService(); + await service.ready; + await service.setLanguage("ru", { save: true, refreshDynamicContent }); + + expect(chrome.storage.local.set).toHaveBeenCalledWith({ uiLanguage: "ru" }); + expect(refreshDynamicContent).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/domains/localization/localizationService.ts b/src/domains/localization/localizationService.ts new file mode 100644 index 0000000..24da180 --- /dev/null +++ b/src/domains/localization/localizationService.ts @@ -0,0 +1,196 @@ +import { + AVAILABLE_LANGUAGE_CODES, + CHROME_I18N_LOCALES, + DEFAULT_LANGUAGE, + LANGUAGE_KEY, + getBrowserLanguage, +} from "./languages"; + +type LocaleMessages = Record; + +export interface LocalizationService { + ready: Promise; + getMessage(messageName: string): string; + applyLocalization(root?: Document): void; + populateLanguageSelect(root?: Document): void; + setLanguage( + language: string, + options?: { + save?: boolean; + refreshDynamicContent?: () => Promise; + }, + ): Promise; +} + +const setElementTextContent = ( + root: Document, + id: string, + messageName: string, + getMessage: (messageName: string) => string, +): void => { + const element = root.getElementById(id); + if (element) element.textContent = getMessage(messageName); +}; + +const setElementPlaceholder = ( + root: Document, + id: string, + messageName: string, + getMessage: (messageName: string) => string, +): void => { + const element = root.getElementById(id); + if (element) { + element.setAttribute("placeholder", getMessage(messageName)); + } +}; + +const setElementTooltip = ( + root: Document, + id: string, + messageName: string, + getMessage: (messageName: string) => string, +): void => { + const element = root.getElementById(id); + if (!element) return; + + const message = getMessage(messageName); + element.setAttribute("title", message); + element.setAttribute("alt", message); + element.setAttribute("aria-label", message); +}; + +const setFirstTextNodeContent = ( + root: Document, + id: string, + messageName: string, + getMessage: (messageName: string) => string, +): void => { + const element = root.getElementById(id); + if (!element) return; + + const textNode = Array.from(element.childNodes).find( + (node) => node.nodeType === 3, + ); + if (textNode) textNode.textContent = getMessage(messageName); +}; + +const loadLocaleMessages = async (locale: string): Promise => { + const response = await fetch( + chrome.runtime.getURL(`_locales/${locale}/messages.json`), + ); + if (!response.ok) return {}; + return response.json() as Promise; +}; + +export const createLocalizationService = (): LocalizationService => { + let currentLanguage = DEFAULT_LANGUAGE; + let currentMessages: LocaleMessages = {}; + let defaultMessages: LocaleMessages = {}; + + const getMessage = (messageName: string): string => + currentMessages[messageName]?.message || + defaultMessages[messageName]?.message || + chrome.i18n.getMessage(messageName) || + messageName; + + const applyLocalization = (root: Document = document): void => { + setElementTextContent(root, "master-volume-label", "gain_slider_label", getMessage); + setElementTextContent(root, "reset", "reset_button_label", getMessage); + setElementTextContent(root, "change-eq", "enable_eq_button_label", getMessage); + setElementTextContent(root, "presets-toggle", "empty_preset_name", getMessage); + setElementTextContent(root, "none-item", "empty_preset_name", getMessage); + setElementTextContent(root, "save-preset", "save_preset_button_label", getMessage); + setElementTextContent(root, "import-presets", "import_presets_button_label", getMessage); + setElementTextContent(root, "export-presets", "export_presets_button_label", getMessage); + setElementTextContent(root, "enable-spectrum-label", "enable_spectrum_setting_option", getMessage); + setElementTextContent(root, "settings-header", "settings_title", getMessage); + setElementTextContent(root, "points-count-label", "points_count_setting_option", getMessage); + setElementTextContent(root, "language-label", "language_setting_option", getMessage); + setElementTextContent(root, "points-reset-title", "points_reset_title", getMessage); + setElementTextContent(root, "points-reset-message", "points_reset_message", getMessage); + setElementTextContent(root, "skip-reset-label", "points_reset_skip", getMessage); + setElementTextContent(root, "points-reset-cancel", "cancel", getMessage); + setElementTextContent(root, "points-reset-confirm", "ok", getMessage); + setElementTextContent(root, "support-me", "support_me", getMessage); + setElementTextContent(root, "theme-label", "theme_setting_option", getMessage); + setElementTextContent(root, "shortcuts-settings-title", "shortcuts_settings_title", getMessage); + setElementTextContent(root, "shortcut-mute-label", "shortcut_mute_label", getMessage); + setElementTextContent(root, "shortcut-toggle-eq-label", "shortcut_toggle_eq_label", getMessage); + setElementTextContent(root, "presets-settings-title", "presets_settings_title", getMessage); + setElementTextContent(root, "community-settings-title", "community_settings_title", getMessage); + setFirstTextNodeContent(root, "help-with-translation-label", "help_with_translation_label", getMessage); + setFirstTextNodeContent(root, "source-code-label", "source_code_label", getMessage); + setElementTextContent(root, "autostart-settings-title", "autostart_settings_title", getMessage); + setElementTextContent(root, "autostart-settings-type-domain", "autostart_rule_type_domain_label", getMessage); + setElementTextContent(root, "autostart-settings-type-url", "autostart_rule_type_url_label", getMessage); + setElementTextContent(root, "autostart-settings-add-btn", "add", getMessage); + setElementTextContent(root, "autostart-modal-title", "autostart_modal_title", getMessage); + setElementTextContent(root, "autostart-modal-domain-label", "autostart_rule_type_domain_label", getMessage); + setElementTextContent(root, "autostart-modal-url-label", "autostart_rule_type_url_label", getMessage); + setElementTextContent(root, "autostart-modal-preset-label", "autostart_modal_preset_label", getMessage); + setElementTextContent(root, "autostart-modal-cancel", "cancel", getMessage); + setElementTextContent(root, "autostart-modal-confirm", "add", getMessage); + setElementTextContent(root, "whitelist-empty", "autostart_whitelist_empty", getMessage); + + setElementPlaceholder(root, "preset-name", "enter_preset_name", getMessage); + + setElementTooltip(root, "settings-btn", "settings_button_tooltip", getMessage); + setElementTooltip(root, "volume-mute", "volume_mute_button_tooltip", getMessage); + setElementTooltip(root, "add-to-autostart-whitelist-btn", "add_to_autostart_tooltip", getMessage); + setElementTooltip(root, "window-mod", "window_mode_button_tooltip", getMessage); + }; + + const populateLanguageSelect = (root: Document = document): void => { + const select = root.getElementById("language-select"); + if (!(select instanceof HTMLSelectElement)) return; + + select.textContent = ""; + AVAILABLE_LANGUAGE_CODES.forEach((code) => { + const option = root.createElement("option"); + option.value = code; + option.textContent = CHROME_I18N_LOCALES.get(code) ?? code; + select.appendChild(option); + }); + select.value = currentLanguage; + }; + + const setLanguage = async ( + language: string, + options: { + save?: boolean; + refreshDynamicContent?: () => Promise; + } = {}, + ): Promise => { + currentLanguage = language; + currentMessages = await loadLocaleMessages(currentLanguage); + + if (globalThis.document) { + document.documentElement.lang = currentLanguage.replace("_", "-"); + applyLocalization(document); + populateLanguageSelect(document); + } + + if (options.save) { + await chrome.storage.local.set({ [LANGUAGE_KEY]: currentLanguage }); + await options.refreshDynamicContent?.(); + } + }; + + const initLocalization = async (): Promise => { + defaultMessages = await loadLocaleMessages(DEFAULT_LANGUAGE); + const stored = await chrome.storage.local.get([LANGUAGE_KEY]); + const language = + typeof stored[LANGUAGE_KEY] === "string" + ? stored[LANGUAGE_KEY] + : getBrowserLanguage(); + await setLanguage(language); + }; + + return { + ready: initLocalization(), + getMessage, + applyLocalization, + populateLanguageSelect, + setLanguage, + }; +}; diff --git a/src/domains/theme/themeColors.test.ts b/src/domains/theme/themeColors.test.ts new file mode 100644 index 0000000..1e8c69b --- /dev/null +++ b/src/domains/theme/themeColors.test.ts @@ -0,0 +1,37 @@ +import { readThemeColors } from "./themeColors"; + +describe("readThemeColors", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("reads trimmed CSS custom properties from the provided element", () => { + const values = new Map([ + ["--accent-start", " #111111 "], + ["--accent-mid", " #222222 "], + ["--accent-end", " #333333 "], + ["--highpass-filter", " #444444 "], + ["--lowpass-filter", " #555555 "], + ["--panel-bg", " #666666 "], + ["--axis", " #777777 "], + ]); + + vi.stubGlobal( + "getComputedStyle", + vi.fn(() => ({ + getPropertyValue: (propertyName: string) => + values.get(propertyName) ?? "", + })), + ); + + expect(readThemeColors({} as Element)).toEqual({ + accentStart: "#111111", + accentMid: "#222222", + accentEnd: "#333333", + highpassFilterColor: "#444444", + lowpassFilterColor: "#555555", + panelBg: "#666666", + axis: "#777777", + }); + }); +}); diff --git a/src/domains/theme/themeColors.ts b/src/domains/theme/themeColors.ts new file mode 100644 index 0000000..6fbb7d5 --- /dev/null +++ b/src/domains/theme/themeColors.ts @@ -0,0 +1,25 @@ +export interface ThemeColors { + accentStart: string; + accentMid: string; + accentEnd: string; + highpassFilterColor: string; + lowpassFilterColor: string; + panelBg: string; + axis: string; +} + +export const readThemeColors = ( + element: Element = document.documentElement, +): ThemeColors => { + const css = getComputedStyle(element); + + return { + accentStart: css.getPropertyValue("--accent-start").trim(), + accentMid: css.getPropertyValue("--accent-mid").trim(), + accentEnd: css.getPropertyValue("--accent-end").trim(), + highpassFilterColor: css.getPropertyValue("--highpass-filter").trim(), + lowpassFilterColor: css.getPropertyValue("--lowpass-filter").trim(), + panelBg: css.getPropertyValue("--panel-bg").trim(), + axis: css.getPropertyValue("--axis").trim(), + }; +}; diff --git a/src/domains/theme/themes.ts b/src/domains/theme/themes.ts new file mode 100644 index 0000000..68943eb --- /dev/null +++ b/src/domains/theme/themes.ts @@ -0,0 +1,113 @@ +export const THEME_NAMES = ["dark", "light", "ny"] as const; + +export type ThemeName = (typeof THEME_NAMES)[number]; + +export type ThemeTokens = Record; + +export const themes: Record = { + dark: { + bg: "var(--dark-bg)", + "panel-bg": "var(--dark-panel-bg)", + "settings-bg": "var(--dark-settings-bg)", + "accent-start": "var(--dark-accent-start)", + "accent-mid": "var(--dark-accent-mid)", + "accent-end": "var(--dark-accent-end)", + "highpass-filter": "var(--dark-highpass-filter)", + "lowpass-filter": "var(--dark-lowpass-filter)", + text: "var(--dark-text)", + "text-header": "var(--dark-text-header)", + "slider-track-bg": "var(--dark-slider-track-bg)", + "slider-thumb": "var(--dark-slider-thumb)", + axis: "var(--dark-axis)", + border: "var(--dark-border)", + "surface-alt": "var(--dark-surface-alt)", + "text-placeholder": "var(--dark-text-placeholder)", + "text-volume-scale": "var(--dark-text-volume-scale)", + "hover-bg": "var(--dark-hover-bg)", + "active-bg": "var(--dark-active-bg)", + "menu-hover": "var(--dark-menu-hover)", + overlay: "var(--dark-overlay)", + "shadow-soft": "var(--dark-shadow-soft)", + "shadow-strong": "var(--dark-shadow-strong)", + donation: "var(--dark-donation)", + "slider-glow": "var(--dark-slider-glow)", + "reset-btn": "var(--dark-reset-btn)", + "reset-btn-hover": "var(--dark-reset-btn-hover)", + "reset-btn-active": "var(--dark-reset-btn-active)", + "btn-bg": "var(--dark-btn-bg)", + "svg-btn-filter": "var(--dark-svg-btn-filter)", + "info-btn-display": "var(--dark-info-btn-display)", + "notice-link": "var(--dark-notice-link)", + "notice-link-hover": "var(--dark-notice-link-hover)", + }, + light: { + bg: "var(--light-bg)", + "panel-bg": "var(--light-panel-bg)", + "settings-bg": "var(--light-settings-bg)", + "accent-start": "var(--light-accent-start)", + "accent-mid": "var(--light-accent-mid)", + "accent-end": "var(--light-accent-end)", + "highpass-filter": "var(--light-highpass-filter)", + "lowpass-filter": "var(--light-lowpass-filter)", + text: "var(--light-text)", + "text-header": "var(--light-text-header)", + "slider-track-bg": "var(--light-slider-track-bg)", + "slider-thumb": "var(--light-slider-thumb)", + axis: "var(--light-axis)", + border: "var(--light-border)", + "surface-alt": "var(--light-surface-alt)", + "text-placeholder": "var(--light-text-placeholder)", + "text-volume-scale": "var(--light-text-volume-scale)", + "hover-bg": "var(--light-hover-bg)", + "active-bg": "var(--light-active-bg)", + "menu-hover": "var(--light-menu-hover)", + overlay: "var(--light-overlay)", + "shadow-soft": "var(--light-shadow-soft)", + "shadow-strong": "var(--light-shadow-strong)", + donation: "var(--light-donation)", + "slider-glow": "var(--light-slider-glow)", + "reset-btn": "var(--light-reset-btn)", + "reset-btn-hover": "var(--light-reset-btn-hover)", + "reset-btn-active": "var(--light-reset-btn-active)", + "btn-bg": "var(--light-btn-bg)", + "svg-btn-filter": "var(--light-svg-btn-filter)", + "info-btn-display": "var(--light-info-btn-display)", + "notice-link": "var(--light-notice-link)", + "notice-link-hover": "var(--light-notice-link-hover)", + }, + ny: { + bg: "var(--ny-bg)", + "panel-bg": "var(--ny-panel-bg)", + "settings-bg": "var(--ny-settings-bg)", + "accent-start": "var(--ny-accent-start)", + "accent-mid": "var(--ny-accent-mid)", + "accent-end": "var(--ny-accent-end)", + "highpass-filter": "var(--ny-highpass-filter)", + "lowpass-filter": "var(--ny-lowpass-filter)", + text: "var(--ny-text)", + "text-header": "var(--ny-text-header)", + "slider-track-bg": "var(--ny-slider-track-bg)", + "slider-thumb": "var(--ny-slider-thumb)", + axis: "var(--ny-axis)", + border: "var(--ny-border)", + "surface-alt": "var(--ny-surface-alt)", + "text-placeholder": "var(--ny-text-placeholder)", + "text-volume-scale": "var(--ny-text-volume-scale)", + "hover-bg": "var(--ny-hover-bg)", + "active-bg": "var(--ny-active-bg)", + "menu-hover": "var(--ny-menu-hover)", + overlay: "var(--ny-overlay)", + "shadow-soft": "var(--ny-shadow-soft)", + "shadow-strong": "var(--ny-shadow-strong)", + donation: "var(--ny-donation)", + "slider-glow": "var(--ny-slider-glow)", + "reset-btn": "var(--ny-reset-btn)", + "reset-btn-hover": "var(--ny-reset-btn-hover)", + "reset-btn-active": "var(--ny-reset-btn-active)", + "btn-bg": "var(--ny-btn-bg)", + "svg-btn-filter": "var(--ny-svg-btn-filter)", + "info-btn-display": "var(--ny-info-btn-display)", + "notice-link": "var(--ny-notice-link)", + "notice-link-hover": "var(--ny-notice-link-hover)", + }, +}; diff --git a/src/infrastructure/dom/popupElements.ts b/src/infrastructure/dom/popupElements.ts index f7079c5..0d992e8 100644 --- a/src/infrastructure/dom/popupElements.ts +++ b/src/infrastructure/dom/popupElements.ts @@ -1,25 +1,114 @@ import { getRequiredElement } from "./getRequiredElement"; export interface PopupElements { + settingsButton: HTMLImageElement; + volumeMuteButton: HTMLImageElement; + addToAutostartWhitelistButton: HTMLImageElement; + windowModeButton: HTMLImageElement; + supportMe: HTMLSpanElement; + captureError: HTMLDivElement; + capturedTabs: HTMLDivElement; + infoButton: HTMLImageElement; eqCanvas: HTMLCanvasElement; spectrumCanvas: HTMLCanvasElement; + infoTooltip: HTMLDivElement; + masterVolumeLabel: HTMLLabelElement; masterVolume: HTMLInputElement; resetButton: HTMLButtonElement; changeEqButton: HTMLButtonElement; - settingsButton: HTMLImageElement; + presets: HTMLDivElement; + presetsToggle: HTMLDivElement; + presetsMenu: HTMLDivElement; + noneItem: HTMLDivElement; + presetName: HTMLInputElement; + savePresetButton: HTMLButtonElement; settingsModal: HTMLDivElement; closeSettingsButton: HTMLSpanElement; - captureError: HTMLDivElement; - capturedTabs: HTMLDivElement; + settingsHeader: HTMLHeadingElement; + enableSpectrumLabel: HTMLElement; + enableSpectrum: HTMLInputElement; + pointsCountLabel: HTMLSpanElement; + pointsCount: HTMLSelectElement; + themeLabel: HTMLSpanElement; + themeSelect: HTMLSelectElement; + themeDarkOption: HTMLOptionElement; + themeLightOption: HTMLOptionElement; + themeNyOption: HTMLOptionElement; + languageLabel: HTMLSpanElement; + languageSelect: HTMLSelectElement; + shortcutsSettingsTitle: HTMLHeadingElement; + shortcutMuteLabel: HTMLSpanElement; + shortcutMute: HTMLInputElement; + shortcutToggleEqLabel: HTMLSpanElement; + shortcutToggleEq: HTMLInputElement; + shortcutsSettingsError: HTMLDivElement; + autostartSettingsTitle: HTMLHeadingElement; + autostartSettingsList: HTMLDivElement; + autostartSettingsType: HTMLSelectElement; + autostartSettingsTypeDomain: HTMLOptionElement; + autostartSettingsTypeUrl: HTMLOptionElement; + autostartSettingsAddValue: HTMLInputElement; + autostartSettingsAddPreset: HTMLSelectElement; + autostartSettingsAddButton: HTMLButtonElement; + autostartSettingsError: HTMLDivElement; + presetsSettingsTitle: HTMLHeadingElement; + importInput: HTMLInputElement; + importPresetsButton: HTMLButtonElement; + exportPresetsButton: HTMLButtonElement; + communitySettingsTitle: HTMLHeadingElement; + helpWithTranslationLabel: HTMLDivElement; + sourceCodeLabel: HTMLDivElement; + autostartModal: HTMLDivElement; + autostartModalClose: HTMLSpanElement; + autostartModalTitle: HTMLHeadingElement; + autostartModalDomainLabel: HTMLSpanElement; + autostartModalDomainValue: HTMLElement; + autostartModalUrlLabel: HTMLSpanElement; + autostartModalUrlValue: HTMLElement; + autostartModalPresetLabel: HTMLSpanElement; + autostartModalPreset: HTMLSelectElement; + autostartModalError: HTMLDivElement; + autostartModalCancel: HTMLButtonElement; + autostartModalConfirm: HTMLButtonElement; + pointsResetModal: HTMLDivElement; + pointsResetTitle: HTMLHeadingElement; + pointsResetMessage: HTMLParagraphElement; + skipResetConfirm: HTMLInputElement; + skipResetLabel: HTMLSpanElement; + pointsResetCancel: HTMLButtonElement; + pointsResetConfirm: HTMLButtonElement; + installUpdateNoticeModal: HTMLDivElement; + installUpdateNoticeClose: HTMLButtonElement; + infoModal: HTMLDivElement; + closeInfoModal: HTMLSpanElement; + infoModalMessage: HTMLParagraphElement; } export const getPopupElements = (document: Document): PopupElements => ({ + settingsButton: getRequiredElement(document, "settings-btn", HTMLImageElement), + volumeMuteButton: getRequiredElement(document, "volume-mute", HTMLImageElement), + addToAutostartWhitelistButton: getRequiredElement( + document, + "add-to-autostart-whitelist-btn", + HTMLImageElement, + ), + windowModeButton: getRequiredElement(document, "window-mod", HTMLImageElement), + supportMe: getRequiredElement(document, "support-me", HTMLSpanElement), + captureError: getRequiredElement(document, "capture-error", HTMLDivElement), + capturedTabs: getRequiredElement(document, "captured-tabs", HTMLDivElement), + infoButton: getRequiredElement(document, "info-btn", HTMLImageElement), eqCanvas: getRequiredElement(document, "eq-canvas", HTMLCanvasElement), spectrumCanvas: getRequiredElement( document, "spectrum-canvas", HTMLCanvasElement, ), + infoTooltip: getRequiredElement(document, "info-tooltip", HTMLDivElement), + masterVolumeLabel: getRequiredElement( + document, + "master-volume-label", + HTMLLabelElement, + ), masterVolume: getRequiredElement(document, "master-volume", HTMLInputElement), resetButton: getRequiredElement(document, "reset", HTMLButtonElement), changeEqButton: getRequiredElement( @@ -27,13 +116,222 @@ export const getPopupElements = (document: Document): PopupElements => ({ "change-eq", HTMLButtonElement, ), - settingsButton: getRequiredElement(document, "settings-btn", HTMLImageElement), + presets: getRequiredElement(document, "presets", HTMLDivElement), + presetsToggle: getRequiredElement(document, "presets-toggle", HTMLDivElement), + presetsMenu: getRequiredElement(document, "presets-menu", HTMLDivElement), + noneItem: getRequiredElement(document, "none-item", HTMLDivElement), + presetName: getRequiredElement(document, "preset-name", HTMLInputElement), + savePresetButton: getRequiredElement(document, "save-preset", HTMLButtonElement), settingsModal: getRequiredElement(document, "settings-modal", HTMLDivElement), closeSettingsButton: getRequiredElement( document, "close-settings", HTMLSpanElement, ), - captureError: getRequiredElement(document, "capture-error", HTMLDivElement), - capturedTabs: getRequiredElement(document, "captured-tabs", HTMLDivElement), + settingsHeader: getRequiredElement(document, "settings-header", HTMLHeadingElement), + enableSpectrumLabel: getRequiredElement( + document, + "enable-spectrum-label", + HTMLElement, + ), + enableSpectrum: getRequiredElement(document, "enable-spectrum", HTMLInputElement), + pointsCountLabel: getRequiredElement(document, "points-count-label", HTMLSpanElement), + pointsCount: getRequiredElement(document, "points-count", HTMLSelectElement), + themeLabel: getRequiredElement(document, "theme-label", HTMLSpanElement), + themeSelect: getRequiredElement(document, "theme-select", HTMLSelectElement), + themeDarkOption: getRequiredElement(document, "theme-dark-option", HTMLOptionElement), + themeLightOption: getRequiredElement(document, "theme-light-option", HTMLOptionElement), + themeNyOption: getRequiredElement(document, "theme-ny-option", HTMLOptionElement), + languageLabel: getRequiredElement(document, "language-label", HTMLSpanElement), + languageSelect: getRequiredElement(document, "language-select", HTMLSelectElement), + shortcutsSettingsTitle: getRequiredElement( + document, + "shortcuts-settings-title", + HTMLHeadingElement, + ), + shortcutMuteLabel: getRequiredElement(document, "shortcut-mute-label", HTMLSpanElement), + shortcutMute: getRequiredElement(document, "shortcut-mute", HTMLInputElement), + shortcutToggleEqLabel: getRequiredElement( + document, + "shortcut-toggle-eq-label", + HTMLSpanElement, + ), + shortcutToggleEq: getRequiredElement(document, "shortcut-toggle-eq", HTMLInputElement), + shortcutsSettingsError: getRequiredElement( + document, + "shortcuts-settings-error", + HTMLDivElement, + ), + autostartSettingsTitle: getRequiredElement( + document, + "autostart-settings-title", + HTMLHeadingElement, + ), + autostartSettingsList: getRequiredElement( + document, + "autostart-settings-list", + HTMLDivElement, + ), + autostartSettingsType: getRequiredElement( + document, + "autostart-settings-type", + HTMLSelectElement, + ), + autostartSettingsTypeDomain: getRequiredElement( + document, + "autostart-settings-type-domain", + HTMLOptionElement, + ), + autostartSettingsTypeUrl: getRequiredElement( + document, + "autostart-settings-type-url", + HTMLOptionElement, + ), + autostartSettingsAddValue: getRequiredElement( + document, + "autostart-settings-add-value", + HTMLInputElement, + ), + autostartSettingsAddPreset: getRequiredElement( + document, + "autostart-settings-add-preset", + HTMLSelectElement, + ), + autostartSettingsAddButton: getRequiredElement( + document, + "autostart-settings-add-btn", + HTMLButtonElement, + ), + autostartSettingsError: getRequiredElement( + document, + "autostart-settings-error", + HTMLDivElement, + ), + presetsSettingsTitle: getRequiredElement( + document, + "presets-settings-title", + HTMLHeadingElement, + ), + importInput: getRequiredElement(document, "import-input", HTMLInputElement), + importPresetsButton: getRequiredElement( + document, + "import-presets", + HTMLButtonElement, + ), + exportPresetsButton: getRequiredElement( + document, + "export-presets", + HTMLButtonElement, + ), + communitySettingsTitle: getRequiredElement( + document, + "community-settings-title", + HTMLHeadingElement, + ), + helpWithTranslationLabel: getRequiredElement( + document, + "help-with-translation-label", + HTMLDivElement, + ), + sourceCodeLabel: getRequiredElement(document, "source-code-label", HTMLDivElement), + autostartModal: getRequiredElement(document, "autostart-modal", HTMLDivElement), + autostartModalClose: getRequiredElement( + document, + "autostart-modal-close", + HTMLSpanElement, + ), + autostartModalTitle: getRequiredElement( + document, + "autostart-modal-title", + HTMLHeadingElement, + ), + autostartModalDomainLabel: getRequiredElement( + document, + "autostart-modal-domain-label", + HTMLSpanElement, + ), + autostartModalDomainValue: getRequiredElement( + document, + "autostart-modal-domain-value", + HTMLElement, + ), + autostartModalUrlLabel: getRequiredElement( + document, + "autostart-modal-url-label", + HTMLSpanElement, + ), + autostartModalUrlValue: getRequiredElement( + document, + "autostart-modal-url-value", + HTMLElement, + ), + autostartModalPresetLabel: getRequiredElement( + document, + "autostart-modal-preset-label", + HTMLSpanElement, + ), + autostartModalPreset: getRequiredElement( + document, + "autostart-modal-preset", + HTMLSelectElement, + ), + autostartModalError: getRequiredElement( + document, + "autostart-modal-error", + HTMLDivElement, + ), + autostartModalCancel: getRequiredElement( + document, + "autostart-modal-cancel", + HTMLButtonElement, + ), + autostartModalConfirm: getRequiredElement( + document, + "autostart-modal-confirm", + HTMLButtonElement, + ), + pointsResetModal: getRequiredElement(document, "points-reset-modal", HTMLDivElement), + pointsResetTitle: getRequiredElement( + document, + "points-reset-title", + HTMLHeadingElement, + ), + pointsResetMessage: getRequiredElement( + document, + "points-reset-message", + HTMLParagraphElement, + ), + skipResetConfirm: getRequiredElement( + document, + "skip-reset-confirm", + HTMLInputElement, + ), + skipResetLabel: getRequiredElement(document, "skip-reset-label", HTMLSpanElement), + pointsResetCancel: getRequiredElement( + document, + "points-reset-cancel", + HTMLButtonElement, + ), + pointsResetConfirm: getRequiredElement( + document, + "points-reset-confirm", + HTMLButtonElement, + ), + installUpdateNoticeModal: getRequiredElement( + document, + "install-update-notice-modal", + HTMLDivElement, + ), + installUpdateNoticeClose: getRequiredElement( + document, + "install-update-notice-close", + HTMLButtonElement, + ), + infoModal: getRequiredElement(document, "info-modal", HTMLDivElement), + closeInfoModal: getRequiredElement(document, "close-info-modal", HTMLSpanElement), + infoModalMessage: getRequiredElement( + document, + "info-modal-message", + HTMLParagraphElement, + ), }); From 9eeb344ae5963dd1f3f1612860f1b7cdcb0d24f6 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 4 Jul 2026 11:02:54 +0300 Subject: [PATCH 15/63] feat: migrate equalizer canvas state --- src/domains/equalizer/equalizerState.test.ts | 143 +++++++ src/domains/equalizer/equalizerState.ts | 357 ++++++++++++++++++ .../equalizerCanvas/createEqualizerCanvas.ts | 59 +++ src/ui/equalizerCanvas/draw/drawEqualizer.ts | 21 ++ src/ui/equalizerCanvas/draw/drawFilters.ts | 110 ++++++ .../equalizerCanvas/draw/drawFrequencyAxis.ts | 76 ++++ .../draw/drawHighpassFilter.ts | 51 +++ .../equalizerCanvas/draw/drawLowpassFilter.ts | 51 +++ .../equalizerCanvas/draw/drawPeakingFilter.ts | 61 +++ src/ui/equalizerCanvas/draw/drawSpectrum.ts | 210 +++++++++++ src/ui/equalizerCanvas/equalizerGestures.ts | 160 ++++++++ src/ui/equalizerCanvas/equalizerTooltips.ts | 100 +++++ src/ui/equalizerCanvas/types.ts | 25 ++ 13 files changed, 1424 insertions(+) create mode 100644 src/domains/equalizer/equalizerState.test.ts create mode 100644 src/domains/equalizer/equalizerState.ts create mode 100644 src/ui/equalizerCanvas/createEqualizerCanvas.ts create mode 100644 src/ui/equalizerCanvas/draw/drawEqualizer.ts create mode 100644 src/ui/equalizerCanvas/draw/drawFilters.ts create mode 100644 src/ui/equalizerCanvas/draw/drawFrequencyAxis.ts create mode 100644 src/ui/equalizerCanvas/draw/drawHighpassFilter.ts create mode 100644 src/ui/equalizerCanvas/draw/drawLowpassFilter.ts create mode 100644 src/ui/equalizerCanvas/draw/drawPeakingFilter.ts create mode 100644 src/ui/equalizerCanvas/draw/drawSpectrum.ts create mode 100644 src/ui/equalizerCanvas/equalizerGestures.ts create mode 100644 src/ui/equalizerCanvas/equalizerTooltips.ts create mode 100644 src/ui/equalizerCanvas/types.ts diff --git a/src/domains/equalizer/equalizerState.test.ts b/src/domains/equalizer/equalizerState.test.ts new file mode 100644 index 0000000..9dab32c --- /dev/null +++ b/src/domains/equalizer/equalizerState.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test, vi } from "vitest"; + +import { frequencyToX } from "./equalizerMath"; +import { createEqualizerState } from "./equalizerState"; + +const dimensions = { + canvasWidth: 500, + canvasHeight: 200, +}; + +describe("createEqualizerState", () => { + test("initializes legacy peaking and crossover points", () => { + const state = createEqualizerState(); + + state.initPoints(3, dimensions); + + const points = state.getPoints(); + expect(points).toHaveLength(5); + points.forEach((point, index) => { + expect(point.x).toBeCloseTo((500 / 6) * (index + 1), 10); + expect(point.y).toBe(100); + expect(point.q).toBe(0.5); + }); + expect(state.getHighpassPoint()).toEqual({ + x: frequencyToX(20, 490), + y: 100, + q: 0.5, + }); + expect(state.getLowpassPoint()).toEqual({ + x: frequencyToX(20000, 490), + y: 100, + q: 0.5, + }); + }); + + test("sets points from persisted filters and reports clamped point count", () => { + const state = createEqualizerState(); + const onPointCountChange = vi.fn(); + + state.setPoints( + [ + { type: "highpass", freq: 40, gain: 0, q: 0.05 }, + { type: "peaking", freq: 1000, gain: 12.5, q: 2 }, + { type: "peaking", x: 123, y: 45, q: 20 }, + { type: "lowpass", x: 456, q: "bad" }, + ], + dimensions, + { onPointCountChange }, + ); + + expect(onPointCountChange).toHaveBeenCalledWith(5); + expect(state.getHighpassPoint()).toEqual({ + x: frequencyToX(40, 490), + y: 100, + q: 0.1, + }); + expect(state.getPoints()).toEqual([ + { x: frequencyToX(1000, 490), y: 60, q: 2 }, + { x: 123, y: 45, q: 10 }, + ]); + expect(state.getLowpassPoint()).toEqual({ x: 456, y: 100, q: 0.5 }); + }); + + test("serializes points back to filters in legacy order", () => { + const state = createEqualizerState(); + + state.setPoints( + [ + { type: "highpass", freq: 20, gain: 0, q: 0.5 }, + { type: "peaking", freq: 1000, gain: 0, q: 1 }, + { type: "lowpass", freq: 20000, gain: 0, q: 0.5 }, + ], + dimensions, + ); + + const filters = state.getFilters(dimensions); + expect(filters).toHaveLength(3); + expect(filters[0]).toEqual({ + type: "highpass", + freq: expect.closeTo(20, 10), + gain: 0, + q: 0.5, + x: frequencyToX(20, 490), + y: 100, + }); + expect(filters[1]).toEqual({ + type: "peaking", + freq: expect.closeTo(1000, 10), + gain: 0, + q: 1, + x: frequencyToX(1000, 490), + y: 100, + }); + expect(filters[2]).toEqual({ + type: "lowpass", + freq: expect.closeTo(20000, 10), + gain: 0, + q: 0.5, + x: frequencyToX(20000, 490), + y: 100, + }); + }); + + test("tracks drag target, drag mode, and reset behavior", () => { + const state = createEqualizerState(); + + state.initPoints(5, dimensions); + const target = state.getPointIndexAtPosition(500 / 6, 100); + + expect(target).toEqual({ type: "peaking", index: 0 }); + + state.setDragTarget(target, "q"); + expect(state.getDragMode()).toBe("q"); + expect(state.getDraggedPoint()).toEqual({ x: 500 / 6, y: 100, q: 0.5 }); + + state.setDraggedPoint({ x: 111, y: 44, q: 3 }); + expect(state.getDraggedPoint()).toEqual({ x: 111, y: 44, q: 3 }); + + state.resetPoint(target, dimensions); + expect(state.getDraggedPoint()).toEqual({ x: 500 / 6, y: 100, q: 0.5 }); + + state.clearDrag(); + expect(state.getDragMode()).toBeNull(); + expect(state.getDraggedPoint()).toBeNull(); + }); + + test("detects crossover filters only when both crossover types exist", () => { + const state = createEqualizerState(); + + expect( + state.hasCrossoverFilters([ + { type: "highpass", freq: 20, gain: 0, q: 0.5 }, + { type: "peaking", freq: 100, gain: 0, q: 0.5 }, + ]), + ).toBe(false); + expect( + state.hasCrossoverFilters([ + { type: "highpass", freq: 20, gain: 0, q: 0.5 }, + { type: "lowpass", freq: 20000, gain: 0, q: 0.5 }, + ]), + ).toBe(true); + }); +}); diff --git a/src/domains/equalizer/equalizerState.ts b/src/domains/equalizer/equalizerState.ts new file mode 100644 index 0000000..e8d0a53 --- /dev/null +++ b/src/domains/equalizer/equalizerState.ts @@ -0,0 +1,357 @@ +import type { EqualizerFilter, EqualizerFilterType } from "./types"; + +import { + clampPointCount, + DEFAULT_FILTER_Q, + ensureQFactor, + frequencyToX, + xToFrequency, + yToDb, +} from "./equalizerMath"; + +export interface EqualizerCanvasDimensions { + canvasWidth: number; + canvasHeight: number; +} + +export interface EqualizerCanvasPoint { + x: number; + y: number; + q: number; +} + +export type EqualizerDragMode = "point" | "q"; + +export type EqualizerDragTarget = + | { type: "peaking"; index: number } + | { type: "highpass" } + | { type: "lowpass" }; + +export type EqualizerPersistedFilter = Partial< + Omit +> & { + type?: EqualizerFilterType; + freq?: unknown; + gain?: unknown; + q?: unknown; + x?: unknown; + y?: unknown; +}; + +export type EqualizerCanvasFilter = EqualizerFilter & { + x: number; + y: number; +}; + +export interface SetEqualizerPointsOptions { + onPointCountChange?: (pointCount: number) => void; +} + +export interface EqualizerState { + getPoints: () => EqualizerCanvasPoint[]; + getHighpassPoint: () => EqualizerCanvasPoint | null; + getLowpassPoint: () => EqualizerCanvasPoint | null; + initPoints: (count: number, dimensions: EqualizerCanvasDimensions) => void; + setPoints: ( + filters: EqualizerPersistedFilter[], + dimensions: EqualizerCanvasDimensions, + options?: SetEqualizerPointsOptions, + ) => void; + getFilters: (dimensions: EqualizerCanvasDimensions) => EqualizerCanvasFilter[]; + hasCrossoverFilters: (filters: EqualizerPersistedFilter[]) => boolean; + getPointIndexAtPosition: (x: number, y: number) => EqualizerDragTarget | null; + getDraggedPoint: () => EqualizerCanvasPoint | null; + setDraggedPoint: (point: EqualizerCanvasPoint) => void; + setDragTarget: ( + target: EqualizerDragTarget | null, + mode: EqualizerDragMode | null, + ) => void; + clearDrag: () => void; + getDragMode: () => EqualizerDragMode | null; + resetPoint: ( + target: EqualizerDragTarget | null, + dimensions: EqualizerCanvasDimensions, + ) => void; +} + +export const POINT_RADIUS = 3; + +const DEFAULT_HIGHPASS_FREQ = 20; +const DEFAULT_LOWPASS_FREQ = 20000; + +const clonePoint = (point: EqualizerCanvasPoint): EqualizerCanvasPoint => { + return { ...point }; +}; + +const getFrequencyCanvasWidth = (dimensions: EqualizerCanvasDimensions): number => { + return dimensions.canvasWidth - 10; +}; + +const finiteNumberOrNull = (value: unknown): number | null => { + const numberValue = Number(value); + + return Number.isFinite(numberValue) ? numberValue : null; +}; + +const createPeakingFilterPoint = ( + index: number, + count: number, + dimensions: EqualizerCanvasDimensions, +): EqualizerCanvasPoint => { + const centerY = dimensions.canvasHeight / 2; + const pointStep = dimensions.canvasWidth / (count + 1); + + return { + x: pointStep * (index + 1), + y: centerY, + q: DEFAULT_FILTER_Q, + }; +}; + +const createCrossoverPoint = ( + freq: number, + dimensions: EqualizerCanvasDimensions, + filter: EqualizerPersistedFilter = {}, +): EqualizerCanvasPoint => { + const x = finiteNumberOrNull(filter.x); + const filterFreq = finiteNumberOrNull(filter.freq ?? freq); + + return { + x: x ?? frequencyToX(filterFreq ?? freq, getFrequencyCanvasWidth(dimensions)), + y: dimensions.canvasHeight / 2, + q: ensureQFactor(filter.q), + }; +}; + +const createDefaultHighpassPoint = ( + dimensions: EqualizerCanvasDimensions, + filter: EqualizerPersistedFilter = {}, +): EqualizerCanvasPoint => { + return createCrossoverPoint(DEFAULT_HIGHPASS_FREQ, dimensions, filter); +}; + +const createDefaultLowpassPoint = ( + dimensions: EqualizerCanvasDimensions, + filter: EqualizerPersistedFilter = {}, +): EqualizerCanvasPoint => { + return createCrossoverPoint(DEFAULT_LOWPASS_FREQ, dimensions, filter); +}; + +const createPeakingFilterPoints = ( + count: number, + dimensions: EqualizerCanvasDimensions, +): EqualizerCanvasPoint[] => { + return Array.from({ length: count }, (_, index) => { + return createPeakingFilterPoint(index, count, dimensions); + }); +}; + +const pointMatchesPosition = ( + point: EqualizerCanvasPoint, + x: number, + y: number, +): boolean => { + return Math.hypot(point.x - x, point.y - y) < POINT_RADIUS + 2; +}; + +export const createEqualizerState = (): EqualizerState => { + let points: EqualizerCanvasPoint[] = []; + let highpassPoint: EqualizerCanvasPoint | null = null; + let lowpassPoint: EqualizerCanvasPoint | null = null; + let dragTarget: EqualizerDragTarget | null = null; + let dragMode: EqualizerDragMode | null = null; + + const getDraggedPoint = (): EqualizerCanvasPoint | null => { + if (!dragTarget) { + return null; + } + + if (dragTarget.type === "highpass") { + return highpassPoint ? clonePoint(highpassPoint) : null; + } + + if (dragTarget.type === "lowpass") { + return lowpassPoint ? clonePoint(lowpassPoint) : null; + } + + const point = points[dragTarget.index]; + + return point ? clonePoint(point) : null; + }; + + return { + getPoints: () => { + return points.map(clonePoint); + }, + + getHighpassPoint: () => { + return highpassPoint ? clonePoint(highpassPoint) : null; + }, + + getLowpassPoint: () => { + return lowpassPoint ? clonePoint(lowpassPoint) : null; + }, + + initPoints: (count, dimensions) => { + const pointCount = clampPointCount(count); + points = createPeakingFilterPoints(pointCount, dimensions); + highpassPoint = createDefaultHighpassPoint(dimensions); + lowpassPoint = createDefaultLowpassPoint(dimensions); + }, + + setPoints: (filters, dimensions, options = {}) => { + const peakingFilters = filters.filter((filter) => { + return (filter.type ?? "peaking") === "peaking"; + }); + const highpassFilter = filters.find((filter) => filter.type === "highpass"); + const lowpassFilter = filters.find((filter) => filter.type === "lowpass"); + + const pointCount = clampPointCount(peakingFilters.length); + options.onPointCountChange?.(pointCount); + highpassPoint = createDefaultHighpassPoint(dimensions, highpassFilter); + lowpassPoint = createDefaultLowpassPoint(dimensions, lowpassFilter); + points = peakingFilters.map((filter, index) => { + const centeredPoint = createPeakingFilterPoint(index, pointCount, dimensions); + const freq = finiteNumberOrNull(filter.freq); + const gain = finiteNumberOrNull(filter.gain); + const x = finiteNumberOrNull(filter.x); + const y = finiteNumberOrNull(filter.y); + + return { + x: x ?? (freq == null ? centeredPoint.x : frequencyToX(freq, getFrequencyCanvasWidth(dimensions))), + y: y ?? (gain == null + ? centeredPoint.y + : dimensions.canvasHeight / 2 - + (gain / 25) * (dimensions.canvasHeight / 2 - 20)), + q: ensureQFactor(filter.q), + }; + }); + }, + + getFilters: (dimensions) => { + const filters: EqualizerCanvasFilter[] = []; + const canvasWidth = getFrequencyCanvasWidth(dimensions); + + if (highpassPoint) { + filters.push({ + type: "highpass", + freq: xToFrequency(highpassPoint.x, canvasWidth), + gain: 0, + q: ensureQFactor(highpassPoint.q), + x: highpassPoint.x, + y: highpassPoint.y, + }); + } + + points.forEach((point) => { + filters.push({ + type: "peaking", + freq: xToFrequency(point.x, canvasWidth), + gain: yToDb(point.y, dimensions.canvasHeight), + q: ensureQFactor(point.q), + x: point.x, + y: point.y, + }); + }); + + if (lowpassPoint) { + filters.push({ + type: "lowpass", + freq: xToFrequency(lowpassPoint.x, canvasWidth), + gain: 0, + q: ensureQFactor(lowpassPoint.q), + x: lowpassPoint.x, + y: lowpassPoint.y, + }); + } + + return filters; + }, + + hasCrossoverFilters: (filters) => { + return ( + filters.some((filter) => filter.type === "highpass") && + filters.some((filter) => filter.type === "lowpass") + ); + }, + + getPointIndexAtPosition: (x, y) => { + const peakingIndex = points.findIndex((point) => { + return pointMatchesPosition(point, x, y); + }); + + if (peakingIndex !== -1) { + return { + type: "peaking", + index: peakingIndex, + }; + } + + if (highpassPoint && pointMatchesPosition(highpassPoint, x, y)) { + return { type: "highpass" }; + } + + if (lowpassPoint && pointMatchesPosition(lowpassPoint, x, y)) { + return { type: "lowpass" }; + } + + return null; + }, + + getDraggedPoint, + + setDraggedPoint: (point) => { + if (!dragTarget) { + return; + } + + if (dragTarget.type === "highpass") { + highpassPoint = clonePoint(point); + return; + } + + if (dragTarget.type === "lowpass") { + lowpassPoint = clonePoint(point); + return; + } + + points[dragTarget.index] = clonePoint(point); + }, + + setDragTarget: (target, mode) => { + dragTarget = target; + dragMode = target ? mode : null; + }, + + clearDrag: () => { + dragTarget = null; + dragMode = null; + }, + + getDragMode: () => { + return dragMode; + }, + + resetPoint: (target, dimensions) => { + if (!target) { + return; + } + + if (target.type === "highpass") { + highpassPoint = createDefaultHighpassPoint(dimensions); + return; + } + + if (target.type === "lowpass") { + lowpassPoint = createDefaultLowpassPoint(dimensions); + return; + } + + points[target.index] = createPeakingFilterPoint( + target.index, + points.length, + dimensions, + ); + }, + }; +}; diff --git a/src/ui/equalizerCanvas/createEqualizerCanvas.ts b/src/ui/equalizerCanvas/createEqualizerCanvas.ts new file mode 100644 index 0000000..5cf5637 --- /dev/null +++ b/src/ui/equalizerCanvas/createEqualizerCanvas.ts @@ -0,0 +1,59 @@ +import type { EqualizerCanvasDimensions } from "../../domains/equalizer/equalizerState"; +import { createEqualizerTooltips } from "./equalizerTooltips"; +import { + attachEqualizerGestures, + type EqualizerGestureCleanup, +} from "./equalizerGestures"; +import type { EqualizerCanvasRenderOptions } from "./types"; +import { drawEqualizer, resizeEqualizerCanvas } from "./draw/drawEqualizer"; + +export interface CreateEqualizerCanvasOptions extends EqualizerCanvasRenderOptions { + infoTooltip?: HTMLElement | null; + saveCurrentFilters: () => Promise | void; + refreshToolkitCaptureFilters: () => void; +} + +export interface EqualizerCanvasController { + draw: () => void; + resize: () => void; + getDimensions: () => EqualizerCanvasDimensions; + cleanup: EqualizerGestureCleanup; +} + +export const createEqualizerCanvas = ( + options: CreateEqualizerCanvasOptions, +): EqualizerCanvasController => { + const getDimensions = (): EqualizerCanvasDimensions => { + return { + canvasWidth: options.canvas.width, + canvasHeight: options.canvas.height, + }; + }; + const draw = (): void => { + drawEqualizer(options); + }; + const resize = (): void => { + resizeEqualizerCanvas(options); + }; + const tooltips = createEqualizerTooltips({ + canvas: options.canvas, + infoTooltip: options.infoTooltip ?? null, + state: options.state, + }); + const cleanup = attachEqualizerGestures({ + canvas: options.canvas, + state: options.state, + draw: resize, + saveCurrentFilters: options.saveCurrentFilters, + refreshToolkitCaptureFilters: options.refreshToolkitCaptureFilters, + tooltips, + getDimensions, + }); + + return { + draw, + resize, + getDimensions, + cleanup, + }; +}; diff --git a/src/ui/equalizerCanvas/draw/drawEqualizer.ts b/src/ui/equalizerCanvas/draw/drawEqualizer.ts new file mode 100644 index 0000000..e88b74f --- /dev/null +++ b/src/ui/equalizerCanvas/draw/drawEqualizer.ts @@ -0,0 +1,21 @@ +import type { EqualizerCanvasRenderOptions } from "../types"; +import { drawFilter } from "./drawFilters"; +import { drawAxis } from "./drawFrequencyAxis"; + +export const drawEqualizer = (options: EqualizerCanvasRenderOptions): void => { + const { canvas, ctx } = options; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawAxis(options); + drawFilter(options); +}; + +export const resizeEqualizerCanvas = ( + options: EqualizerCanvasRenderOptions, +): void => { + const { canvas } = options; + + canvas.width = canvas.clientWidth; + canvas.height = canvas.clientHeight; + drawEqualizer(options); +}; diff --git a/src/ui/equalizerCanvas/draw/drawFilters.ts b/src/ui/equalizerCanvas/draw/drawFilters.ts new file mode 100644 index 0000000..2c1c490 --- /dev/null +++ b/src/ui/equalizerCanvas/draw/drawFilters.ts @@ -0,0 +1,110 @@ +import { + ensureQFactor, + xToFrequency, + yToDb, +} from "../../../domains/equalizer/equalizerMath"; +import { + POINT_RADIUS, + type EqualizerCanvasPoint, +} from "../../../domains/equalizer/equalizerState"; +import type { EqualizerCanvasRenderOptions } from "../types"; +import { drawHighpassFilter } from "./drawHighpassFilter"; +import { drawLowpassFilter } from "./drawLowpassFilter"; +import { drawPeakingFilter } from "./drawPeakingFilter"; + +const drawTypedPoint = ( + ctx: CanvasRenderingContext2D, + point: EqualizerCanvasPoint | null, + color: string, + panelBg: string, +): void => { + if (!point) { + return; + } + + ctx.beginPath(); + ctx.arc(point.x, point.y, POINT_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = panelBg; + ctx.fill(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); +}; + +export const drawPoints = ({ + ctx, + state, + colors, +}: Pick): void => { + state.getPoints().forEach((point) => { + ctx.beginPath(); + ctx.arc(point.x, point.y, POINT_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = colors.panelBg; + ctx.fill(); + ctx.strokeStyle = colors.accentMid; + ctx.lineWidth = 2; + ctx.stroke(); + }); + + drawTypedPoint( + ctx, + state.getHighpassPoint(), + colors.highpassFilterColor, + colors.panelBg, + ); + drawTypedPoint( + ctx, + state.getLowpassPoint(), + colors.lowpassFilterColor, + colors.panelBg, + ); +}; + +export const drawFilter = ({ + canvas, + ctx, + audioContext, + state, + colors, +}: EqualizerCanvasRenderOptions): void => { + drawPoints({ ctx, state, colors }); + + const canvasWidth = canvas.width - 10; + const highpassPoint = state.getHighpassPoint(); + + if (highpassPoint) { + drawHighpassFilter({ + canvas, + ctx, + audioContext, + freq: xToFrequency(highpassPoint.x, canvasWidth), + q: ensureQFactor(highpassPoint.q), + colors, + }); + } + + state.getPoints().forEach((point) => { + drawPeakingFilter({ + canvas, + ctx, + audioContext, + freq: xToFrequency(point.x, canvasWidth), + q: ensureQFactor(point.q), + gainDb: yToDb(point.y, canvas.height), + colors, + }); + }); + + const lowpassPoint = state.getLowpassPoint(); + + if (lowpassPoint) { + drawLowpassFilter({ + canvas, + ctx, + audioContext, + freq: xToFrequency(lowpassPoint.x, canvasWidth), + q: ensureQFactor(lowpassPoint.q), + colors, + }); + } +}; diff --git a/src/ui/equalizerCanvas/draw/drawFrequencyAxis.ts b/src/ui/equalizerCanvas/draw/drawFrequencyAxis.ts new file mode 100644 index 0000000..624c783 --- /dev/null +++ b/src/ui/equalizerCanvas/draw/drawFrequencyAxis.ts @@ -0,0 +1,76 @@ +import { LOG_MAX, LOG_MIN } from "../../../domains/equalizer/equalizerMath"; +import type { EqualizerCanvasPaintOptions } from "../types"; + +export const drawAxis = ({ + canvas, + ctx, + colors, +}: EqualizerCanvasPaintOptions): void => { + const freqMargin = 10; + const margin = 10; + const yPos = canvas.height; + + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + + const freqs = [5, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]; + const canvasWidth = canvas.width - 10; + + ctx.strokeStyle = colors.axis; + ctx.fillStyle = colors.axis; + + freqs.forEach((freq) => { + const x = + Math.pow((Math.log10(freq) - LOG_MIN) / (LOG_MAX - LOG_MIN), 2) * + canvasWidth; + ctx.beginPath(); + ctx.moveTo(x, yPos - 10); + ctx.lineTo(x, yPos); + ctx.stroke(); + ctx.fillText(freq.toString(), x, yPos - margin - freqMargin); + + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, 10); + ctx.stroke(); + }); + + const gainMargin = 10; + const gainMin = -25; + const gainMax = 25; + const step = 5; + const gainLabels = Array.from( + { length: (gainMax - gainMin) / step + 1 }, + (_, index) => index * step - gainMax, + ); + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + gainLabels.forEach((gain) => { + const y = + gainMargin + + ((gainMax - gain) / (gainMax - gainMin)) * + (canvas.height - gainMargin * 2); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(10, y); + ctx.stroke(); + ctx.fillText(gain.toString(), 12, y); + }); + + const zeroY = + gainMargin + + ((gainMax - 0) / (gainMax - gainMin)) * (canvas.height - gainMargin * 2); + ctx.textBaseline = "bottom"; + ctx.textAlign = "center"; + + freqs.forEach((freq) => { + const x = + Math.pow((Math.log10(freq) - LOG_MIN) / (LOG_MAX - LOG_MIN), 2) * + canvasWidth; + ctx.beginPath(); + ctx.moveTo(x, zeroY - 5); + ctx.lineTo(x, zeroY + 5); + ctx.stroke(); + }); +}; diff --git a/src/ui/equalizerCanvas/draw/drawHighpassFilter.ts b/src/ui/equalizerCanvas/draw/drawHighpassFilter.ts new file mode 100644 index 0000000..d90748d --- /dev/null +++ b/src/ui/equalizerCanvas/draw/drawHighpassFilter.ts @@ -0,0 +1,51 @@ +import { xToFrequency } from "../../../domains/equalizer/equalizerMath"; +import type { EqualizerFilterRenderOptions } from "../types"; + +export const drawHighpassFilter = ({ + canvas, + ctx, + audioContext, + freq, + q = 0.5, + colors, +}: EqualizerFilterRenderOptions): void => { + const gainMargin = 20; + const width = canvas.width; + const height = canvas.height; + const canvasWidth = width - 10; + + const filter = audioContext.createBiquadFilter(); + filter.type = "highpass"; + filter.frequency.value = freq; + filter.Q.value = q; + + const numPoints = width; + const frequencies = new Float32Array(numPoints); + const magResponse = new Float32Array(numPoints); + const phaseResponse = new Float32Array(numPoints); + const maxResponseFrequency = audioContext.sampleRate / 2; + + for (let i = 0; i < numPoints; i++) { + frequencies[i] = Math.min(xToFrequency(i, canvasWidth), maxResponseFrequency); + } + + filter.getFrequencyResponse(frequencies, magResponse, phaseResponse); + + ctx.strokeStyle = colors.highpassFilterColor; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let i = 0; i < numPoints; i++) { + const db = Math.max(-80, 25 * Math.log10(magResponse[i] || Number.EPSILON)); + const y = height / 2 - (db / 25) * (height / 2 - gainMargin * 2); + const x = i; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + + ctx.stroke(); +}; diff --git a/src/ui/equalizerCanvas/draw/drawLowpassFilter.ts b/src/ui/equalizerCanvas/draw/drawLowpassFilter.ts new file mode 100644 index 0000000..033ea06 --- /dev/null +++ b/src/ui/equalizerCanvas/draw/drawLowpassFilter.ts @@ -0,0 +1,51 @@ +import { xToFrequency } from "../../../domains/equalizer/equalizerMath"; +import type { EqualizerFilterRenderOptions } from "../types"; + +export const drawLowpassFilter = ({ + canvas, + ctx, + audioContext, + freq, + q = 0.5, + colors, +}: EqualizerFilterRenderOptions): void => { + const gainMargin = 20; + const width = canvas.width; + const height = canvas.height; + const canvasWidth = width - 10; + + const filter = audioContext.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = freq; + filter.Q.value = q; + + const numPoints = width; + const frequencies = new Float32Array(numPoints); + const magResponse = new Float32Array(numPoints); + const phaseResponse = new Float32Array(numPoints); + const maxResponseFrequency = audioContext.sampleRate / 2; + + for (let i = 0; i < numPoints; i++) { + frequencies[i] = Math.min(xToFrequency(i, canvasWidth), maxResponseFrequency); + } + + filter.getFrequencyResponse(frequencies, magResponse, phaseResponse); + + ctx.strokeStyle = colors.lowpassFilterColor; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let i = 0; i < numPoints; i++) { + const db = Math.max(-80, 25 * Math.log10(magResponse[i] || Number.EPSILON)); + const y = height / 2 - (db / 25) * (height / 2 - gainMargin * 2); + const x = i; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + + ctx.stroke(); +}; diff --git a/src/ui/equalizerCanvas/draw/drawPeakingFilter.ts b/src/ui/equalizerCanvas/draw/drawPeakingFilter.ts new file mode 100644 index 0000000..9cda70a --- /dev/null +++ b/src/ui/equalizerCanvas/draw/drawPeakingFilter.ts @@ -0,0 +1,61 @@ +import { xToFrequency } from "../../../domains/equalizer/equalizerMath"; +import type { EqualizerFilterRenderOptions } from "../types"; + +export interface PeakingFilterRenderOptions extends EqualizerFilterRenderOptions { + gainDb?: number; +} + +export const drawPeakingFilter = ({ + canvas, + ctx, + audioContext, + freq, + q = 1, + gainDb = 0, + colors, +}: PeakingFilterRenderOptions): void => { + const gainMargin = 20; + const width = canvas.width; + const height = canvas.height; + const canvasWidth = width - 10; + + const filter = audioContext.createBiquadFilter(); + filter.type = "peaking"; + filter.frequency.value = freq; + filter.Q.value = q; + filter.gain.value = gainDb; + + const numPoints = width; + const frequencies = new Float32Array(numPoints); + const magResponse = new Float32Array(numPoints); + const phaseResponse = new Float32Array(numPoints); + const maxResponseFrequency = audioContext.sampleRate / 2; + + for (let i = 0; i < numPoints; i++) { + frequencies[i] = Math.min(xToFrequency(i, canvasWidth), maxResponseFrequency); + } + + filter.getFrequencyResponse(frequencies, magResponse, phaseResponse); + + const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + gradient.addColorStop(0, colors.accentStart); + gradient.addColorStop(0.5, colors.accentMid); + gradient.addColorStop(1, colors.accentEnd); + ctx.strokeStyle = gradient; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let i = 0; i < numPoints; i++) { + const db = 25 * Math.log10(magResponse[i]); + const y = height / 2 - (db / 25) * (height / 2 - gainMargin * 2); + const x = i; + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + + ctx.stroke(); +}; diff --git a/src/ui/equalizerCanvas/draw/drawSpectrum.ts b/src/ui/equalizerCanvas/draw/drawSpectrum.ts new file mode 100644 index 0000000..43ee570 --- /dev/null +++ b/src/ui/equalizerCanvas/draw/drawSpectrum.ts @@ -0,0 +1,210 @@ +import { frequencyToX } from "../../../domains/equalizer/equalizerMath"; +import type { ThemeColors } from "../../../domains/theme/themeColors"; + +export interface SpectrumMeta { + sampleRate: number; + fftSize: number; + minDb: number; + maxDb: number; + frequencyBinCount: number; +} + +export type SpectrumBuffer = ArrayLike | Record; + +export interface SpectrumRendererOptions { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + colors: ThemeColors; + requestAnimationFrame?: (callback: FrameRequestCallback) => number; +} + +export interface SpectrumRenderer { + setMeta: (nextMeta: Partial) => void; + drawSpectrum: (buffer: SpectrumBuffer | null | undefined) => void; + scheduleDraw: (buffer: SpectrumBuffer | null | undefined) => void; +} + +const DEFAULT_META: SpectrumMeta = { + sampleRate: 48000, + fftSize: 2048, + minDb: -100, + maxDb: -30, + frequencyBinCount: 2048, +}; + +const getSpectrumValues = ( + buffer: SpectrumBuffer | null | undefined, +): ArrayLike | number[] => { + if (!buffer) { + return []; + } + + const maybeLength = (buffer as { length?: unknown }).length; + + if (typeof maybeLength === "number") { + return buffer as ArrayLike; + } + + const keyedBuffer = buffer as Record; + + return Object.keys(keyedBuffer) + .filter((key) => !Number.isNaN(Number(key))) + .sort((a, b) => Number(a) - Number(b)) + .map((key) => keyedBuffer[key]); +}; + +const dbToSpectrumY = (db: number, height: number, meta: SpectrumMeta): number => { + const minDb = Number.isFinite(meta.minDb) ? meta.minDb : -100; + const maxDb = Number.isFinite(meta.maxDb) ? meta.maxDb : -30; + const range = maxDb - minDb || 1; + const normalized = (Math.max(minDb, Math.min(maxDb, db)) - minDb) / range; + + return height - normalized * height; +}; + +const syncSpectrumCanvasSize = (canvas: HTMLCanvasElement): void => { + const width = canvas.clientWidth; + const height = canvas.clientHeight; + + if (canvas.width !== width) { + canvas.width = width; + } + + if (canvas.height !== height) { + canvas.height = height; + } +}; + +const spectrumBinToFrequency = ( + index: number, + binCount: number, + meta: SpectrumMeta, +): number => { + const sampleRate = meta.sampleRate || 48000; + const nyquist = sampleRate / 2; + + return (index * nyquist) / binCount; +}; + +export const createSpectrumRenderer = ({ + canvas, + ctx, + colors, + requestAnimationFrame: requestFrame = window.requestAnimationFrame.bind(window), +}: SpectrumRendererOptions): SpectrumRenderer => { + let meta = { ...DEFAULT_META }; + let lastBuffer: SpectrumBuffer | null | undefined = null; + let rafScheduled = false; + + const drawSpectrum = (buffer: SpectrumBuffer | null | undefined): void => { + syncSpectrumCanvasSize(canvas); + + const values = getSpectrumValues(buffer); + const width = canvas.width; + const height = canvas.height; + + ctx.clearRect(0, 0, width, height); + + if (!values.length || width <= 0 || height <= 0) { + return; + } + + const sampleRate = meta.sampleRate || 48000; + const maxFrequency = Math.min(24000, sampleRate / 2); + const fill = ctx.createLinearGradient(0, 0, 0, height); + fill.addColorStop(0, colors.accentStart); + fill.addColorStop(1, colors.accentEnd); + + ctx.beginPath(); + ctx.moveTo(0, height); + + let hasPoint = false; + + for (let i = 0; i < values.length; i++) { + const frequency = spectrumBinToFrequency(i, values.length, meta); + + if (frequency > maxFrequency) { + break; + } + + const db = values[i]; + + if (!Number.isFinite(db)) { + continue; + } + + const x = frequencyToX(frequency, width - 10); + const y = dbToSpectrumY(db, height, meta); + + ctx.lineTo(x, y); + hasPoint = true; + } + + if (!hasPoint) { + return; + } + + ctx.lineTo(width, height); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.globalAlpha = 0.18; + ctx.fill(); + ctx.globalAlpha = 1; + + ctx.beginPath(); + hasPoint = false; + + for (let i = 0; i < values.length; i++) { + const frequency = spectrumBinToFrequency(i, values.length, meta); + + if (frequency > maxFrequency) { + break; + } + + const db = values[i]; + + if (!Number.isFinite(db)) { + continue; + } + + const x = frequencyToX(frequency, width - 10); + const y = dbToSpectrumY(db, height, meta); + + if (!hasPoint) { + ctx.moveTo(x, y); + hasPoint = true; + } else { + ctx.lineTo(x, y); + } + } + + ctx.strokeStyle = colors.accentMid; + ctx.lineWidth = 1.5; + ctx.stroke(); + }; + + const scheduleDraw = (buffer: SpectrumBuffer | null | undefined): void => { + lastBuffer = buffer; + + if (rafScheduled) { + return; + } + + rafScheduled = true; + requestFrame(() => { + rafScheduled = false; + + if (lastBuffer) { + drawSpectrum(lastBuffer); + } + }); + }; + + return { + setMeta: (nextMeta) => { + meta = { ...meta, ...nextMeta }; + }, + drawSpectrum, + scheduleDraw, + }; +}; diff --git a/src/ui/equalizerCanvas/equalizerGestures.ts b/src/ui/equalizerCanvas/equalizerGestures.ts new file mode 100644 index 0000000..b072829 --- /dev/null +++ b/src/ui/equalizerCanvas/equalizerGestures.ts @@ -0,0 +1,160 @@ +import { + DEFAULT_FILTER_Q, + ensureQFactor, +} from "../../domains/equalizer/equalizerMath"; +import type { + EqualizerCanvasDimensions, + EqualizerCanvasPoint, + EqualizerDragTarget, + EqualizerState, +} from "../../domains/equalizer/equalizerState"; +import type { EqualizerTooltipHelpers } from "./equalizerTooltips"; + +export interface EqualizerGestureOptions { + canvas: HTMLCanvasElement; + state: EqualizerState; + draw: () => void; + saveCurrentFilters: () => Promise | void; + refreshToolkitCaptureFilters: () => void; + tooltips: Pick; + getDimensions?: () => EqualizerCanvasDimensions; +} + +export type EqualizerGestureCleanup = () => void; + +const getCanvasDimensions = ( + canvas: HTMLCanvasElement, +): EqualizerCanvasDimensions => { + return { + canvasWidth: canvas.width, + canvasHeight: canvas.height, + }; +}; + +const getMousePosition = ( + canvas: HTMLCanvasElement, + event: MouseEvent, +): { x: number; y: number } => { + const rect = canvas.getBoundingClientRect(); + + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; +}; + +export const attachEqualizerGestures = ({ + canvas, + state, + draw, + saveCurrentFilters, + refreshToolkitCaptureFilters, + tooltips, + getDimensions = () => getCanvasDimensions(canvas), +}: EqualizerGestureOptions): EqualizerGestureCleanup => { + let qDragStartValue = DEFAULT_FILTER_Q; + let qDragStartY = 0; + let activeDragTarget: EqualizerDragTarget | null = null; + + const persistAndRedraw = async (): Promise => { + draw(); + refreshToolkitCaptureFilters(); + await saveCurrentFilters(); + }; + + const handleMouseDown = (event: MouseEvent): void => { + const { x, y } = getMousePosition(canvas, event); + const dragTarget = state.getPointIndexAtPosition(x, y); + + if (!dragTarget) { + return; + } + + state.setDragTarget(dragTarget, event.shiftKey ? "q" : "point"); + activeDragTarget = dragTarget; + qDragStartY = y; + + const draggedPoint = state.getDraggedPoint(); + + if (!draggedPoint) { + return; + } + + qDragStartValue = ensureQFactor(draggedPoint.q); + tooltips.updateInfoTooltip(draggedPoint, getDimensions()); + }; + + const handleMouseUp = (): void => { + activeDragTarget = null; + state.clearDrag(); + tooltips.hideInfoTooltip(); + }; + + const handleMouseMove = (event: MouseEvent): void => { + if (state.getDragMode() === null) { + return; + } + + const { x, y } = getMousePosition(canvas, event); + let mx = x; + let my = y; + const currentPoint = state.getDraggedPoint(); + + if (!currentPoint) { + return; + } + + const dimensions = getDimensions(); + let nextPoint: EqualizerCanvasPoint | null = null; + + if (state.getDragMode() === "q") { + const dy = qDragStartY - my; + const nextQ = qDragStartValue * Math.pow(2, dy / 40); + nextPoint = { ...currentPoint, q: ensureQFactor(nextQ) }; + } else if (mx > 0) { + mx = Math.max(0, Math.min(canvas.width, mx)); + my = Math.max(0, Math.min(canvas.height, my)); + nextPoint = { + ...currentPoint, + x: mx, + y: activeDragTarget?.type === "peaking" + ? my + : dimensions.canvasHeight / 2, + }; + } + + if (!nextPoint) { + return; + } + + state.setDraggedPoint(nextPoint); + tooltips.updateInfoTooltip(nextPoint, dimensions); + void persistAndRedraw(); + }; + + const handleDoubleClick = (event: MouseEvent): void => { + const { x, y } = getMousePosition(canvas, event); + const pointTarget = state.getPointIndexAtPosition(x, y); + + if (!pointTarget) { + return; + } + + state.resetPoint(pointTarget, getDimensions()); + draw(); + refreshToolkitCaptureFilters(); + void saveCurrentFilters(); + }; + + canvas.addEventListener("mousedown", handleMouseDown); + canvas.addEventListener("mousemove", handleMouseMove); + canvas.addEventListener("dblclick", handleDoubleClick); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + canvas.removeEventListener("mousedown", handleMouseDown); + canvas.removeEventListener("mousemove", handleMouseMove); + canvas.removeEventListener("dblclick", handleDoubleClick); + window.removeEventListener("mouseup", handleMouseUp); + }; +}; diff --git a/src/ui/equalizerCanvas/equalizerTooltips.ts b/src/ui/equalizerCanvas/equalizerTooltips.ts new file mode 100644 index 0000000..1895f4f --- /dev/null +++ b/src/ui/equalizerCanvas/equalizerTooltips.ts @@ -0,0 +1,100 @@ +import { + ensureQFactor, + xToFrequency, + yToDb, +} from "../../domains/equalizer/equalizerMath"; +import type { + EqualizerCanvasDimensions, + EqualizerCanvasPoint, + EqualizerState, +} from "../../domains/equalizer/equalizerState"; + +export interface EqualizerTooltipOptions { + canvas: HTMLCanvasElement; + infoTooltip: HTMLElement | null; + state: EqualizerState; +} + +export interface EqualizerTooltipHelpers { + getPointTooltipText: ( + point: EqualizerCanvasPoint, + dimensions: EqualizerCanvasDimensions, + ) => string; + updateInfoTooltip: ( + point: EqualizerCanvasPoint | null, + dimensions: EqualizerCanvasDimensions, + ) => void; + hideInfoTooltip: () => void; +} + +const formatDb = (db: number): string => { + const fixed = Math.abs(db).toFixed(1); + + if (db > 0) { + return `+${fixed} dB`; + } + + if (db < 0) { + return `-${fixed} dB`; + } + + return "0.0 dB"; +}; + +export const createEqualizerTooltips = ({ + canvas, + infoTooltip, + state, +}: EqualizerTooltipOptions): EqualizerTooltipHelpers => { + const getPointTooltipText = ( + point: EqualizerCanvasPoint, + dimensions: EqualizerCanvasDimensions, + ): string => { + if (state.getDragMode() === "q") { + return `Q ${ensureQFactor(point.q).toFixed(2)}`; + } + + const freq = Math.round(xToFrequency(point.x, dimensions.canvasWidth - 10)); + const db = yToDb(point.y, dimensions.canvasHeight); + + return `${freq} Hz, ${formatDb(db)}`; + }; + + const updateInfoTooltip = ( + point: EqualizerCanvasPoint | null, + dimensions: EqualizerCanvasDimensions, + ): void => { + if (!infoTooltip || !point) { + return; + } + + infoTooltip.textContent = getPointTooltipText(point, dimensions); + infoTooltip.style.display = "block"; + + const tooltipWidth = infoTooltip.offsetWidth; + const tooltipHeight = infoTooltip.offsetHeight; + const margin = 6; + const offset = 12; + const maxLeft = canvas.clientWidth - tooltipWidth - margin; + const maxTop = canvas.clientHeight - tooltipHeight - margin; + const left = Math.max(margin, Math.min(maxLeft, point.x + offset)); + const top = Math.max( + margin, + Math.min(maxTop, point.y - tooltipHeight - offset), + ); + + infoTooltip.style.transform = `translate(${left}px, ${top}px)`; + }; + + return { + getPointTooltipText, + updateInfoTooltip, + hideInfoTooltip: () => { + if (!infoTooltip) { + return; + } + + infoTooltip.style.display = "none"; + }, + }; +}; diff --git a/src/ui/equalizerCanvas/types.ts b/src/ui/equalizerCanvas/types.ts new file mode 100644 index 0000000..8e8c190 --- /dev/null +++ b/src/ui/equalizerCanvas/types.ts @@ -0,0 +1,25 @@ +import type { EqualizerState } from "../../domains/equalizer/equalizerState"; +import type { ThemeColors } from "../../domains/theme/themeColors"; + +export interface EqualizerCanvasRenderOptions { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + audioContext: BaseAudioContext; + state: EqualizerState; + colors: ThemeColors; +} + +export interface EqualizerCanvasPaintOptions { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + colors: ThemeColors; +} + +export interface EqualizerFilterRenderOptions { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + audioContext: BaseAudioContext; + freq: number; + q?: number; + colors: ThemeColors; +} From 2c3f4541c66967ed0ee035693feb4c2a8eee2e14 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 4 Jul 2026 11:13:18 +0300 Subject: [PATCH 16/63] fix: refresh equalizer canvas theme colors --- .../draw/colorProvider.test.ts | 112 ++++++++++++++++++ src/ui/equalizerCanvas/draw/drawFilters.ts | 11 +- .../equalizerCanvas/draw/drawFrequencyAxis.ts | 3 +- src/ui/equalizerCanvas/draw/drawSpectrum.ts | 5 +- src/ui/equalizerCanvas/types.ts | 4 +- 5 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 src/ui/equalizerCanvas/draw/colorProvider.test.ts diff --git a/src/ui/equalizerCanvas/draw/colorProvider.test.ts b/src/ui/equalizerCanvas/draw/colorProvider.test.ts new file mode 100644 index 0000000..1d681b1 --- /dev/null +++ b/src/ui/equalizerCanvas/draw/colorProvider.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test, vi } from "vitest"; + +import type { ThemeColors } from "../../../domains/theme/themeColors"; +import { drawAxis } from "./drawFrequencyAxis"; +import { createSpectrumRenderer } from "./drawSpectrum"; + +const createColors = (suffix: string): ThemeColors => { + return { + accentStart: `accent-start-${suffix}`, + accentMid: `accent-mid-${suffix}`, + accentEnd: `accent-end-${suffix}`, + highpassFilterColor: `highpass-${suffix}`, + lowpassFilterColor: `lowpass-${suffix}`, + panelBg: `panel-${suffix}`, + axis: `axis-${suffix}`, + }; +}; + +const createCanvas = (): HTMLCanvasElement => { + return { + width: 100, + height: 60, + clientWidth: 100, + clientHeight: 60, + } as HTMLCanvasElement; +}; + +const createContext = (): CanvasRenderingContext2D & { + gradient: CanvasGradient & { addColorStop: ReturnType }; +} => { + const gradient = { + addColorStop: vi.fn(), + } as unknown as CanvasGradient & { addColorStop: ReturnType }; + + return { + gradient, + clearRect: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillText: vi.fn(), + createLinearGradient: vi.fn(() => gradient), + closePath: vi.fn(), + fill: vi.fn(), + textAlign: "start", + textBaseline: "alphabetic", + strokeStyle: "", + fillStyle: "", + globalAlpha: 1, + lineWidth: 1, + } as unknown as CanvasRenderingContext2D & { + gradient: CanvasGradient & { addColorStop: ReturnType }; + }; +}; + +describe("equalizer canvas color providers", () => { + test("drawAxis reads colors at draw time", () => { + const canvas = createCanvas(); + const ctx = createContext(); + let colors = createColors("initial"); + + drawAxis({ + canvas, + ctx, + getColors: () => colors, + }); + + expect(ctx.strokeStyle).toBe("axis-initial"); + + colors = createColors("updated"); + drawAxis({ + canvas, + ctx, + getColors: () => colors, + }); + + expect(ctx.strokeStyle).toBe("axis-updated"); + }); + + test("spectrum renderer reads colors at draw time", () => { + const canvas = createCanvas(); + const ctx = createContext(); + let colors = createColors("initial"); + const renderer = createSpectrumRenderer({ + canvas, + ctx, + getColors: () => colors, + requestAnimationFrame: (callback) => { + callback(0); + return 1; + }, + }); + + renderer.drawSpectrum([-90, -80, -70]); + expect(ctx.gradient.addColorStop).toHaveBeenCalledWith( + 0, + "accent-start-initial", + ); + expect(ctx.strokeStyle).toBe("accent-mid-initial"); + + colors = createColors("updated"); + ctx.gradient.addColorStop.mockClear(); + renderer.drawSpectrum([-90, -80, -70]); + + expect(ctx.gradient.addColorStop).toHaveBeenCalledWith( + 0, + "accent-start-updated", + ); + expect(ctx.strokeStyle).toBe("accent-mid-updated"); + }); +}); diff --git a/src/ui/equalizerCanvas/draw/drawFilters.ts b/src/ui/equalizerCanvas/draw/drawFilters.ts index 2c1c490..3879605 100644 --- a/src/ui/equalizerCanvas/draw/drawFilters.ts +++ b/src/ui/equalizerCanvas/draw/drawFilters.ts @@ -7,6 +7,7 @@ import { POINT_RADIUS, type EqualizerCanvasPoint, } from "../../../domains/equalizer/equalizerState"; +import type { ThemeColors } from "../../../domains/theme/themeColors"; import type { EqualizerCanvasRenderOptions } from "../types"; import { drawHighpassFilter } from "./drawHighpassFilter"; import { drawLowpassFilter } from "./drawLowpassFilter"; @@ -35,7 +36,11 @@ export const drawPoints = ({ ctx, state, colors, -}: Pick): void => { +}: { + ctx: CanvasRenderingContext2D; + state: EqualizerCanvasRenderOptions["state"]; + colors: ThemeColors; +}): void => { state.getPoints().forEach((point) => { ctx.beginPath(); ctx.arc(point.x, point.y, POINT_RADIUS, 0, Math.PI * 2); @@ -65,8 +70,10 @@ export const drawFilter = ({ ctx, audioContext, state, - colors, + getColors, }: EqualizerCanvasRenderOptions): void => { + const colors = getColors(); + drawPoints({ ctx, state, colors }); const canvasWidth = canvas.width - 10; diff --git a/src/ui/equalizerCanvas/draw/drawFrequencyAxis.ts b/src/ui/equalizerCanvas/draw/drawFrequencyAxis.ts index 624c783..7e4725c 100644 --- a/src/ui/equalizerCanvas/draw/drawFrequencyAxis.ts +++ b/src/ui/equalizerCanvas/draw/drawFrequencyAxis.ts @@ -4,8 +4,9 @@ import type { EqualizerCanvasPaintOptions } from "../types"; export const drawAxis = ({ canvas, ctx, - colors, + getColors, }: EqualizerCanvasPaintOptions): void => { + const colors = getColors(); const freqMargin = 10; const margin = 10; const yPos = canvas.height; diff --git a/src/ui/equalizerCanvas/draw/drawSpectrum.ts b/src/ui/equalizerCanvas/draw/drawSpectrum.ts index 43ee570..7bbfe3a 100644 --- a/src/ui/equalizerCanvas/draw/drawSpectrum.ts +++ b/src/ui/equalizerCanvas/draw/drawSpectrum.ts @@ -14,7 +14,7 @@ export type SpectrumBuffer = ArrayLike | Record; export interface SpectrumRendererOptions { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; - colors: ThemeColors; + getColors: () => ThemeColors; requestAnimationFrame?: (callback: FrameRequestCallback) => number; } @@ -89,7 +89,7 @@ const spectrumBinToFrequency = ( export const createSpectrumRenderer = ({ canvas, ctx, - colors, + getColors, requestAnimationFrame: requestFrame = window.requestAnimationFrame.bind(window), }: SpectrumRendererOptions): SpectrumRenderer => { let meta = { ...DEFAULT_META }; @@ -111,6 +111,7 @@ export const createSpectrumRenderer = ({ const sampleRate = meta.sampleRate || 48000; const maxFrequency = Math.min(24000, sampleRate / 2); + const colors = getColors(); const fill = ctx.createLinearGradient(0, 0, 0, height); fill.addColorStop(0, colors.accentStart); fill.addColorStop(1, colors.accentEnd); diff --git a/src/ui/equalizerCanvas/types.ts b/src/ui/equalizerCanvas/types.ts index 8e8c190..5cb21f2 100644 --- a/src/ui/equalizerCanvas/types.ts +++ b/src/ui/equalizerCanvas/types.ts @@ -6,13 +6,13 @@ export interface EqualizerCanvasRenderOptions { ctx: CanvasRenderingContext2D; audioContext: BaseAudioContext; state: EqualizerState; - colors: ThemeColors; + getColors: () => ThemeColors; } export interface EqualizerCanvasPaintOptions { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; - colors: ThemeColors; + getColors: () => ThemeColors; } export interface EqualizerFilterRenderOptions { From 92f6b262c983d3914fb6deaea48ba8f3724a9303 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 4 Jul 2026 19:34:01 +0300 Subject: [PATCH 17/63] build: bundle extension entries independently --- package.json | 2 +- tools/buildExtension.mjs | 75 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tools/buildExtension.mjs diff --git a/package.json b/package.json index 894ecde..91c344b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "lint:locales": "node tools/validateLocalizationLengths.js", "typecheck": "tsc --noEmit", "test": "vitest run", - "build": "vite build", + "build": "node tools/buildExtension.mjs", "prezip": "npm run build && node -e \"require('node:fs').mkdirSync('build/Release',{recursive:true})\"", "zip": "bestzip ./build/Release/eq-toolkit_v1.8.0_unpacked.zip dist/**" }, diff --git a/tools/buildExtension.mjs b/tools/buildExtension.mjs new file mode 100644 index 0000000..b0440db --- /dev/null +++ b/tools/buildExtension.mjs @@ -0,0 +1,75 @@ +import { cp, mkdir, readdir, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "vite"; + +const toolsDir = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(toolsDir, ".."); +const distDir = resolve(rootDir, "dist"); + +const entries = { + background: resolve(rootDir, "src/app/background/background.ts"), + popup: resolve(rootDir, "src/app/popup/popup.ts"), + "content-isolated": resolve(rootDir, "src/app/content-isolated/contentIsolated.ts"), + "content-main": resolve(rootDir, "src/app/content-main/contentMain.ts"), +}; + +const expectedEntryFiles = new Set( + Object.keys(entries).map((entryName) => `${entryName}.js`), +); + +await rm(distDir, { recursive: true, force: true }); +await mkdir(resolve(distDir, "scripts"), { recursive: true }); + +for (const [entryName, entryPath] of Object.entries(entries)) { + await build({ + configFile: false, + root: rootDir, + build: { + emptyOutDir: false, + outDir: distDir, + target: "es2022", + cssCodeSplit: false, + rollupOptions: { + input: entryPath, + output: { + format: "iife", + entryFileNames: `scripts/${entryName}.js`, + assetFileNames: "assets/[name][extname]", + }, + }, + }, + }); +} + +await Promise.all([ + cp(resolve(rootDir, "manifest.json"), resolve(distDir, "manifest.json")), + cp(resolve(rootDir, "popup.html"), resolve(distDir, "popup.html")), + cp(resolve(rootDir, "resources"), resolve(distDir, "resources"), { + recursive: true, + }), + cp(resolve(rootDir, "_locales"), resolve(distDir, "_locales"), { + recursive: true, + }), + cp(resolve(rootDir, "scripts"), resolve(distDir, "scripts"), { + recursive: true, + }), +]); + +const scriptEntries = await readdir(resolve(distDir, "scripts"), { + withFileTypes: true, +}); +const unexpectedRootScripts = scriptEntries + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith(".js") && + !expectedEntryFiles.has(entry.name), + ) + .map((entry) => entry.name); + +if (unexpectedRootScripts.length > 0) { + throw new Error( + `Unexpected top-level script bundles emitted: ${unexpectedRootScripts.join(", ")}`, + ); +} From c081c87f2e7625dcc6024e373b0f69bd89e8d22e Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 4 Jul 2026 19:35:25 +0300 Subject: [PATCH 18/63] feat: migrate popup app to explicit TypeScript modules --- popup.html | 30 - src/app/popup/createPopupApp.ts | 534 ++++++++++++++++++ src/app/popup/popup.ts | 31 +- .../createToolkitWindowController.ts | 414 ++++++++++++++ src/ui/popup/autostartView.ts | 229 ++++++++ src/ui/popup/capturedTabsView.ts | 87 +++ src/ui/popup/controlsView.ts | 54 ++ src/ui/popup/installUpdateNoticeView.test.ts | 44 ++ src/ui/popup/installUpdateNoticeView.ts | 66 +++ src/ui/popup/popupShortcuts.ts | 187 ++++++ src/ui/popup/presetsView.ts | 140 +++++ src/ui/popup/settingsView.ts | 358 ++++++++++++ 12 files changed, 2143 insertions(+), 31 deletions(-) create mode 100644 src/app/popup/createPopupApp.ts create mode 100644 src/app/window-mode/createToolkitWindowController.ts create mode 100644 src/ui/popup/autostartView.ts create mode 100644 src/ui/popup/capturedTabsView.ts create mode 100644 src/ui/popup/controlsView.ts create mode 100644 src/ui/popup/installUpdateNoticeView.test.ts create mode 100644 src/ui/popup/installUpdateNoticeView.ts create mode 100644 src/ui/popup/popupShortcuts.ts create mode 100644 src/ui/popup/presetsView.ts create mode 100644 src/ui/popup/settingsView.ts diff --git a/popup.html b/popup.html index e7bf432..eef1169 100644 --- a/popup.html +++ b/popup.html @@ -228,36 +228,6 @@

Patch Notes 1.8.0

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/app/popup/createPopupApp.ts b/src/app/popup/createPopupApp.ts new file mode 100644 index 0000000..a632596 --- /dev/null +++ b/src/app/popup/createPopupApp.ts @@ -0,0 +1,534 @@ +import { dbToGain } from "../../domains/equalizer/equalizerMath"; +import type { + EqualizerPersistedFilter, + EqualizerState, +} from "../../domains/equalizer/equalizerState"; +import { clampPointCount } from "../../domains/equalizer/equalizerMath"; +import { type LocalizationService } from "../../domains/localization/localizationService"; +import { + isPopupEditableShortcutTarget, + matchesPopupShortcut, + POPUP_SHORTCUT_ACTION_MUTE_NAME, + POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME, +} from "../../ui/popup/popupShortcuts"; +import type { ThemeColors } from "../../domains/theme/themeColors"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; +import type { PopupElements } from "../../infrastructure/dom/popupElements"; +import { createToolkitWindowController } from "../window-mode/createToolkitWindowController"; +import { createEqualizerCanvas } from "../../ui/equalizerCanvas/createEqualizerCanvas"; +import { + createSpectrumRenderer, + type SpectrumBuffer, + type SpectrumMeta, +} from "../../ui/equalizerCanvas/draw/drawSpectrum"; +import { createAutostartView } from "../../ui/popup/autostartView"; +import { createControlsView, type ControlsView } from "../../ui/popup/controlsView"; +import { createInstallUpdateNoticeView } from "../../ui/popup/installUpdateNoticeView"; +import { createPresetsView, type PresetsView } from "../../ui/popup/presetsView"; +import { createSettingsView, type SettingsView } from "../../ui/popup/settingsView"; + +export interface PopupApp { + start(): Promise; + resize(): void; +} + +export interface PopupAppDependencies { + elements: PopupElements; + audioContext: AudioContext; + equalizerState: EqualizerState; + localization: LocalizationService; + readThemeColors(element?: Element): ThemeColors; +} + +interface SpectrumStorageMessage extends Partial { + type?: "meta" | "spectrum"; + buffer?: SpectrumBuffer; +} + +export const createPopupApp = ({ + elements, + audioContext, + equalizerState, + localization, + readThemeColors, +}: PopupAppDependencies): PopupApp => { + const ctx = elements.eqCanvas.getContext("2d", { alpha: true }); + if (!ctx) { + throw new Error("Equalizer canvas context is unavailable"); + } + + const spectrumCtx = elements.spectrumCanvas.getContext("2d"); + if (!spectrumCtx) { + throw new Error("Spectrum canvas context is unavailable"); + } + spectrumCtx.imageSmoothingEnabled = true; + spectrumCtx.imageSmoothingQuality = "high"; + + let controlsView: ControlsView; + let presetsView: PresetsView; + let settingsView: SettingsView; + + const getColors = (): ThemeColors => readThemeColors(document.documentElement); + + const getPointCount = async (): Promise => { + const stored = await chrome.storage.local.get([STORAGE_KEYS.POINT_COUNT]); + return clampPointCount(Number.parseInt(String(stored[STORAGE_KEYS.POINT_COUNT]), 10)); + }; + + const equalizerCanvas = createEqualizerCanvas({ + canvas: elements.eqCanvas, + ctx, + audioContext, + state: equalizerState, + getColors, + infoTooltip: elements.infoTooltip, + saveCurrentFilters: () => saveCurrentFilters(), + refreshToolkitCaptureFilters: () => toolkitController.refreshCaptureFilters(), + }); + + const spectrumRenderer = createSpectrumRenderer({ + canvas: elements.spectrumCanvas, + ctx: spectrumCtx, + getColors, + }); + + const resize = (): void => { + equalizerCanvas.resize(); + }; + + const getCurrentFilters = (): EqualizerPersistedFilter[] => { + return equalizerState.getFilters(equalizerCanvas.getDimensions()); + }; + + const setCurrentFilters = (filters: EqualizerPersistedFilter[]): void => { + equalizerState.setPoints(filters, equalizerCanvas.getDimensions(), { + onPointCountChange: (pointCount) => settingsView?.updatePointCountSelect(pointCount), + }); + }; + + const initPoints = (count: number): void => { + equalizerState.initPoints(clampPointCount(count), equalizerCanvas.getDimensions()); + }; + + const setGainValue = (value: number): void => { + elements.masterVolume.value = String(value); + }; + + const renderCaptureError = (message: string | null): void => { + if (!message) { + elements.captureError.style.display = "none"; + elements.captureError.textContent = ""; + return; + } + + console.log(message); + elements.captureError.textContent = localization.getMessage("capture_error_prefix"); + elements.captureError.style.display = "block"; + }; + + const toolkitController = createToolkitWindowController({ + body: document.body, + capturedTabs: elements.capturedTabs, + changeEqButton: elements.changeEqButton, + audioContext, + equalizerState, + getDimensions: equalizerCanvas.getDimensions, + getPointCount, + getFilters: getCurrentFilters, + setFilters: setCurrentFilters, + initPoints, + resize, + setEnableButtonText: (enabled) => controlsView?.setEnableButtonText(enabled), + setMuteButtonClass: (muted) => controlsView?.setMuteButtonClass(muted), + renderCaptureError, + getGainValue: () => Number(elements.masterVolume.value), + setGainValue, + isMuted: () => elements.volumeMuteButton.className === "volume-mute-active", + getMessage: localization.getMessage, + }); + + const getCurrentTabId = (): Promise => { + return toolkitController.getCurrentTabId(); + }; + + const saveCurrentFilters = async ( + options: { enableCurrentTab?: boolean } = {}, + ): Promise => { + const enableCurrentTab = options.enableCurrentTab ?? true; + const tabId = await getCurrentTabId(); + if (tabId == null) return; + + const newFilters = getCurrentFilters(); + const values: Record = { + [STORAGE_KEYS.tabFilters(tabId)]: newFilters, + [STORAGE_KEYS.FILTERS]: newFilters, + }; + if (!toolkitController.isToolkitWindow && enableCurrentTab) { + values[STORAGE_KEYS.tabEnabled(tabId)] = true; + } + await chrome.storage.local.set(values); + }; + + const saveLoadedFilters = async ( + filters: EqualizerPersistedFilter[], + ): Promise => { + const tabId = await getCurrentTabId(); + if (tabId == null) return; + + const values: Record = { + [STORAGE_KEYS.tabFilters(tabId)]: filters, + [STORAGE_KEYS.FILTERS]: filters, + }; + if (!toolkitController.isToolkitWindow) { + values[STORAGE_KEYS.tabEnabled(tabId)] = true; + } + await chrome.storage.local.set(values); + }; + + const toggleCurrentTabStorageValue = async ( + key: "mute" | "enabled", + options: { enableTab?: boolean } = {}, + ): Promise => { + const tabId = await getCurrentTabId(); + if (tabId == null) return; + + const storageKey = `${key}.${tabId}`; + const values = await chrome.storage.local.get([storageKey]); + const nextValues: Record = { + [storageKey]: !values[storageKey], + }; + if (options.enableTab && !toolkitController.isToolkitWindow) { + nextValues[STORAGE_KEYS.tabEnabled(tabId)] = true; + } + await chrome.storage.local.set(nextValues); + }; + + const refreshDynamicContent = async (): Promise => { + await autostartView.renderWhitelist(); + await autostartView.refreshPresetSelects(); + controlsView.setEnableButtonText(false); + }; + + const onToggleEqualizer = async (): Promise => { + if (toolkitController.isToolkitWindow) return; + + const tabId = await getCurrentTabId(); + if (tabId == null) return; + + const enabledKey = STORAGE_KEYS.tabEnabled(tabId); + const result = await chrome.storage.local.get([enabledKey]); + await chrome.storage.local.set({ + [enabledKey]: !result[enabledKey], + [STORAGE_KEYS.tabFilters(tabId)]: getCurrentFilters(), + [STORAGE_KEYS.tabGain(tabId)]: elements.masterVolume.value, + }); + }; + + const onReset = async (): Promise => { + elements.masterVolume.value = "0"; + initPoints(await getPointCount()); + resize(); + toolkitController.refreshCaptureFilters(); + + const tabId = await getCurrentTabId(); + if (tabId == null) return; + + await chrome.storage.local.set({ + [STORAGE_KEYS.tabVolume(tabId)]: 1, + [STORAGE_KEYS.tabGain(tabId)]: 0, + [STORAGE_KEYS.tabFilters(tabId)]: getCurrentFilters(), + }); + }; + + const onVolumeInput = async (value: number): Promise => { + toolkitController.applyCaptureSettings(); + const tabId = await getCurrentTabId(); + if (tabId == null) return; + + const values: Record = { + [STORAGE_KEYS.tabVolume(tabId)]: dbToGain(value), + [STORAGE_KEYS.tabGain(tabId)]: elements.masterVolume.value, + }; + if (!toolkitController.isToolkitWindow) { + values[STORAGE_KEYS.tabEnabled(tabId)] = true; + } + await chrome.storage.local.set(values); + }; + + const onToggleMute = async (): Promise => { + const tabId = await getCurrentTabId(); + if (tabId == null) return; + + if (!toolkitController.isToolkitWindow) { + await chrome.storage.local.set({ [STORAGE_KEYS.tabEnabled(tabId)]: true }); + } + + const result = await chrome.storage.local.get([STORAGE_KEYS.tabMute(tabId)]); + await chrome.storage.local.set({ + [STORAGE_KEYS.tabMute(tabId)]: !result[STORAGE_KEYS.tabMute(tabId)], + }); + }; + + const onWindowMode = async (): Promise => { + const tabId = await getCurrentTabId(); + if (tabId == null) return; + + await chrome.runtime.sendMessage({ + method: "enableWindowMode", + tabId, + }); + window.close(); + }; + + controlsView = createControlsView({ + changeEqButton: elements.changeEqButton, + resetButton: elements.resetButton, + masterVolume: elements.masterVolume, + volumeMuteButton: elements.volumeMuteButton, + windowModeButton: elements.windowModeButton, + isToolkitWindow: toolkitController.isToolkitWindow, + getMessage: localization.getMessage, + onToggleEqualizer, + onReset, + onVolumeInput, + onToggleMute, + onWindowMode, + onMuteStateApplied: () => toolkitController.applyCaptureSettings(), + }); + + presetsView = createPresetsView({ + dropdown: elements.presets, + toggle: elements.presetsToggle, + menu: elements.presetsMenu, + nameInput: elements.presetName, + saveButton: elements.savePresetButton, + isToolkitWindow: toolkitController.isToolkitWindow, + getMessage: localization.getMessage, + getCurrentTabId, + getCurrentFilters, + setCurrentFilters, + saveLoadedFilters, + redraw: resize, + refreshToolkitCaptureFilters: () => toolkitController.refreshCaptureFilters(), + }); + + const autostartView = createAutostartView({ + addToWhitelistButton: elements.addToAutostartWhitelistButton, + modal: elements.autostartModal, + closeButton: elements.autostartModalClose, + cancelButton: elements.autostartModalCancel, + confirmButton: elements.autostartModalConfirm, + modalPreset: elements.autostartModalPreset, + modalError: elements.autostartModalError, + modalDomainValue: elements.autostartModalDomainValue, + modalUrlValue: elements.autostartModalUrlValue, + settingsList: elements.autostartSettingsList, + settingsType: elements.autostartSettingsType, + settingsAddValue: elements.autostartSettingsAddValue, + settingsAddPreset: elements.autostartSettingsAddPreset, + settingsAddButton: elements.autostartSettingsAddButton, + settingsError: elements.autostartSettingsError, + isToolkitWindow: toolkitController.isToolkitWindow, + getMessage: localization.getMessage, + }); + + settingsView = createSettingsView({ + settingsModal: elements.settingsModal, + settingsButton: elements.settingsButton, + closeSettingsButton: elements.closeSettingsButton, + themeSelect: elements.themeSelect, + pointsCount: elements.pointsCount, + pointsResetModal: elements.pointsResetModal, + pointsResetConfirm: elements.pointsResetConfirm, + pointsResetCancel: elements.pointsResetCancel, + skipResetConfirm: elements.skipResetConfirm, + exportPresetsButton: elements.exportPresetsButton, + importPresetsButton: elements.importPresetsButton, + importInput: elements.importInput, + enableSpectrum: elements.enableSpectrum, + languageSelect: elements.languageSelect, + shortcutMute: elements.shortcutMute, + shortcutToggleEq: elements.shortcutToggleEq, + shortcutsError: elements.shortcutsSettingsError, + localization, + addPresetToDropdown: presetsView.addPresetToDropdown, + initPoints, + redraw: resize, + refreshToolkitCaptureFilters: () => toolkitController.refreshCaptureFilters(), + saveCurrentFilters, + refreshDynamicContent, + }); + + const installUpdateNoticeView = createInstallUpdateNoticeView({ + modal: elements.installUpdateNoticeModal, + closeButton: elements.installUpdateNoticeClose, + currentVersion: chrome.runtime.getManifest().version, + isToolkitWindow: toolkitController.isToolkitWindow, + }); + + elements.infoButton.addEventListener("click", () => { + elements.infoModal.style.display = "block"; + }); + elements.closeInfoModal.addEventListener("click", () => { + elements.infoModal.style.display = "none"; + }); + + document.addEventListener("keydown", (event) => { + void (async () => { + if (event.repeat || isPopupEditableShortcutTarget(event.target)) return; + + const shortcuts = settingsView.getShortcutSettings(); + if (matchesPopupShortcut(event, shortcuts[POPUP_SHORTCUT_ACTION_MUTE_NAME])) { + event.preventDefault(); + event.stopPropagation(); + await toggleCurrentTabStorageValue("mute", { enableTab: true }); + return; + } + + if (matchesPopupShortcut( + event, + shortcuts[POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME], + )) { + if (toolkitController.isToolkitWindow) return; + + event.preventDefault(); + event.stopPropagation(); + await toggleCurrentTabStorageValue("enabled"); + } + })(); + }); + + chrome.storage.onChanged.addListener((changes) => { + void (async () => { + await toolkitController.handleStorageChange(changes); + + const tabId = await getCurrentTabId(); + if (tabId == null) return; + + if (changes[STORAGE_KEYS.tabEnabled(tabId)]) { + controlsView.setEnableButtonText( + changes[STORAGE_KEYS.tabEnabled(tabId)].newValue === true, + ); + } + if (changes[STORAGE_KEYS.tabMute(tabId)]) { + controlsView.setMuteButtonClass(changes[STORAGE_KEYS.tabMute(tabId)].newValue === true); + } + if (changes[STORAGE_KEYS.tabCaptureError(tabId)]) { + renderCaptureError( + typeof changes[STORAGE_KEYS.tabCaptureError(tabId)].newValue === "string" + ? (changes[STORAGE_KEYS.tabCaptureError(tabId)].newValue as string) + : null, + ); + } + + const spectrumChange = changes[STORAGE_KEYS.tabSpectrum(tabId)]; + if (spectrumChange) { + const message = spectrumChange.newValue as SpectrumStorageMessage | undefined; + if (message?.type === "meta") { + spectrumRenderer.setMeta(message); + } + if (message?.type === "spectrum" && message.buffer) { + spectrumRenderer.scheduleDraw(message.buffer); + } + } + + if (toolkitController.isToolkitWindow) { + toolkitController.refreshCaptureFilters(); + } + })(); + }); + + const start = async (): Promise => { + await localization.ready; + await settingsView.init(); + await autostartView.init(); + + const stored = await chrome.storage.local.get([ + STORAGE_KEYS.POINT_COUNT, + STORAGE_KEYS.THEME, + STORAGE_KEYS.SKIP_POINTS_CONFIRM, + STORAGE_KEYS.INSTALL_UPDATE_NOTICE, + ]); + settingsView.applyTheme(stored[STORAGE_KEYS.THEME]); + + if (await toolkitController.shouldShowToolkitWindowNotice(await getCurrentTabId())) { + toolkitController.showToolkitWindowNotice(); + return; + } + + resize(); + const savedPointCount = clampPointCount( + Number.parseInt(String(stored[STORAGE_KEYS.POINT_COUNT]), 10), + ); + settingsView.updatePointCountSelect(savedPointCount); + + const tabId = await getCurrentTabId(); + if (tabId == null) { + initPoints(savedPointCount); + resize(); + return; + } + + const result = await chrome.storage.local.get([ + STORAGE_KEYS.FILTERS, + STORAGE_KEYS.tabFilters(tabId), + STORAGE_KEYS.tabGain(tabId), + STORAGE_KEYS.PRESETS, + STORAGE_KEYS.PRESET_NAMES, + STORAGE_KEYS.tabEnabled(tabId), + STORAGE_KEYS.tabMute(tabId), + STORAGE_KEYS.ENABLE_SPECTRUM, + STORAGE_KEYS.tabCaptureError(tabId), + ]); + + const tabFilters = result[STORAGE_KEYS.tabFilters(tabId)] as + | EqualizerPersistedFilter[] + | undefined; + const defaultFilters = result[STORAGE_KEYS.FILTERS] as + | EqualizerPersistedFilter[] + | undefined; + const loadedFilters = tabFilters?.length ? tabFilters : defaultFilters?.length ? defaultFilters : null; + + if (loadedFilters) { + setCurrentFilters(loadedFilters); + } else { + initPoints(savedPointCount); + } + + if (!loadedFilters || !equalizerState.hasCrossoverFilters(loadedFilters)) { + await saveCurrentFilters({ enableCurrentTab: false }); + } + + const gain = result[STORAGE_KEYS.tabGain(tabId)]; + if (typeof gain === "string" || typeof gain === "number") { + elements.masterVolume.value = String(gain); + } + + resize(); + controlsView.setEnableButtonText(result[STORAGE_KEYS.tabEnabled(tabId)] === true); + controlsView.setMuteButtonClass(result[STORAGE_KEYS.tabMute(tabId)] === true); + + if (result[STORAGE_KEYS.ENABLE_SPECTRUM] === true) { + elements.enableSpectrum.checked = true; + } + + ((result[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[]).forEach((name) => { + presetsView.addPresetToDropdown(name); + }); + + renderCaptureError( + typeof result[STORAGE_KEYS.tabCaptureError(tabId)] === "string" + ? (result[STORAGE_KEYS.tabCaptureError(tabId)] as string) + : null, + ); + + installUpdateNoticeView.showInstallUpdateNotice(stored); + await toolkitController.startTabCapture(); + await toolkitController.renderCapturedTabs(); + }; + + return { + start, + resize, + }; +}; diff --git a/src/app/popup/popup.ts b/src/app/popup/popup.ts index cb0ff5c..41ad347 100644 --- a/src/app/popup/popup.ts +++ b/src/app/popup/popup.ts @@ -1 +1,30 @@ -export {}; +import { createEqualizerState } from "../../domains/equalizer/equalizerState"; +import { createLocalizationService } from "../../domains/localization/localizationService"; +import { readThemeColors } from "../../domains/theme/themeColors"; +import { getPopupElements } from "../../infrastructure/dom/popupElements"; +import { createPopupApp } from "./createPopupApp"; + +declare global { + interface Window { + webkitAudioContext?: typeof AudioContext; + } +} + +window.addEventListener("load", () => { + const elements = getPopupElements(document); + const AudioContextConstructor = window.AudioContext || window.webkitAudioContext; + if (!AudioContextConstructor) { + throw new Error("AudioContext is unavailable"); + } + + const app = createPopupApp({ + elements, + audioContext: new AudioContextConstructor(), + equalizerState: createEqualizerState(), + localization: createLocalizationService(), + readThemeColors, + }); + + window.addEventListener("resize", app.resize); + void app.start(); +}); diff --git a/src/app/window-mode/createToolkitWindowController.ts b/src/app/window-mode/createToolkitWindowController.ts new file mode 100644 index 0000000..3769314 --- /dev/null +++ b/src/app/window-mode/createToolkitWindowController.ts @@ -0,0 +1,414 @@ +import type { + EqualizerPersistedFilter, + EqualizerState, +} from "../../domains/equalizer/equalizerState"; +import { dbToGain } from "../../domains/equalizer/equalizerMath"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; +import { + createCapturedTabsView, + type CapturedTabsView, +} from "../../ui/popup/capturedTabsView"; + +interface ToolkitCapture { + streamId: string; + stream: MediaStream; + source: MediaStreamAudioSourceNode; + preamp: GainNode | null; + filters: BiquadFilterNode[]; + output: AudioNode | null; + filterSettings: EqualizerPersistedFilter[]; +} + +interface ToolkitBiquadInput { + freq: number; + gain: number; + q: number; + type?: BiquadFilterType; +} + +const toBiquadInput = (filter: EqualizerPersistedFilter): ToolkitBiquadInput => ({ + freq: Number(filter.freq ?? 0), + gain: Number(filter.gain ?? 0), + q: Number(filter.q ?? 0.5), + type: filter.type as BiquadFilterType | undefined, +}); + +const applyBiquadSettings = ( + biquadFilter: BiquadFilterNode, + filter: ToolkitBiquadInput, +): void => { + biquadFilter.type = filter.type ?? "peaking"; + biquadFilter.gain.value = filter.gain; + biquadFilter.frequency.value = filter.freq; + biquadFilter.Q.value = filter.q; +}; + +const createBiquadFilter = ( + context: BaseAudioContext, + filter: ToolkitBiquadInput, +): BiquadFilterNode => { + const biquadFilter = context.createBiquadFilter(); + applyBiquadSettings(biquadFilter, filter); + return biquadFilter; +}; + +export interface ToolkitWindowController { + readonly isToolkitWindow: boolean; + getCurrentTabId(): Promise; + shouldShowToolkitWindowNotice(currentTabId: number | null): Promise; + showToolkitWindowNotice(): void; + loadTabSettings(tabId: number | null): Promise; + startTabCapture(): Promise; + renderCapturedTabs(): Promise; + refreshCaptureFilters(tabId?: number | null): void; + applyCaptureSettings(tabId?: number | null): void; + stopTabCapture(): void; + handleStorageChange(changes: Record): Promise; +} + +export const createToolkitWindowController = (deps: { + body: HTMLElement; + capturedTabs: HTMLElement; + changeEqButton: HTMLButtonElement; + audioContext: AudioContext; + equalizerState: EqualizerState; + getDimensions(): { canvasWidth: number; canvasHeight: number }; + getPointCount(): Promise; + getFilters(): EqualizerPersistedFilter[]; + setFilters(filters: EqualizerPersistedFilter[]): void; + initPoints(count: number): void; + resize(): void; + setEnableButtonText(enabled: boolean): void; + setMuteButtonClass(muted: boolean): void; + renderCaptureError(message: string | null): void; + getGainValue(): number; + setGainValue(value: number): void; + isMuted(): boolean; + getMessage(messageName: string): string; +}): ToolkitWindowController => { + const isToolkitWindow = new URLSearchParams(window.location.search).get("mode") === "window"; + let activeTabId: number | null = null; + const captures = new Map(); + let capturedTabsView: CapturedTabsView | null = null; + + if (isToolkitWindow) { + deps.body.classList.add("toolkit-window-body"); + deps.changeEqButton.disabled = true; + deps.changeEqButton.classList.add("disabled"); + } + + const getCurrentTabId = async (): Promise => { + if (isToolkitWindow) { + if (activeTabId != null) return activeTabId; + + const stored = await chrome.storage.session.get( + STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID, + ); + activeTabId = + (stored[STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID] as number | undefined) ?? + null; + return activeTabId; + } + + const [tab] = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true, + }); + return tab?.id ?? null; + }; + + const shouldShowToolkitWindowNotice = async ( + currentTabId: number | null, + ): Promise => { + if (isToolkitWindow || currentTabId == null) return false; + + const stored = await chrome.storage.session.get([ + STORAGE_KEYS.TOOLKIT_WINDOW_ID, + STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS, + ]); + const toolkitWindowTabIds = Array.isArray( + stored[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS], + ) + ? (stored[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS] as number[]) + : []; + return ( + stored[STORAGE_KEYS.TOOLKIT_WINDOW_ID] != null && + toolkitWindowTabIds.includes(currentTabId) + ); + }; + + const showToolkitWindowNotice = (): void => { + const notice = document.createElement("div"); + notice.className = "window-open-notice"; + notice.textContent = + deps.getMessage("toolkit_window_already_open") || + "Equalizer is already open in a window"; + + deps.body.className = "window-open-notice-body"; + deps.body.replaceChildren(notice); + }; + + const getCaptureStreamIds = async (): Promise> => { + const stored = await chrome.storage.session.get( + STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS, + ); + return ( + (stored[STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS] as + | Record + | undefined) ?? {} + ); + }; + + const disconnectCaptureGraph = (capture: ToolkitCapture): void => { + try { + capture.source.disconnect(); + } catch (e) { + // A node can already be disconnected when the stream is being replaced. + } + + if (capture.preamp) { + try { + capture.preamp.disconnect(); + } catch (e) { + // A node can already be disconnected when the stream is being replaced. + } + } + + capture.filters.forEach((filter) => { + try { + filter.disconnect(); + } catch (e) { + // A node can already be disconnected when the stream is being replaced. + } + }); + capture.preamp = null; + capture.filters = []; + capture.output = null; + }; + + const stopCaptureEntry = (capture: ToolkitCapture): void => { + disconnectCaptureGraph(capture); + capture.stream.getTracks().forEach((track) => track.stop()); + }; + + const getCaptureFilterSettings = ( + tabId: number | string | null = activeTabId, + ): EqualizerPersistedFilter[] => { + if (tabId != null && Number(tabId) === activeTabId) { + return deps.getFilters(); + } + + const capture = captures.get(String(tabId)); + if (capture?.filterSettings?.length) { + return capture.filterSettings; + } + + return deps.getFilters(); + }; + + const applyCaptureSettings = ( + tabId: number | string | null = activeTabId, + ): void => { + const capture = captures.get(String(tabId)); + if (!capture?.preamp) return; + + capture.preamp.gain.value = deps.isMuted() ? 0 : dbToGain(deps.getGainValue()); + + const filterSettings = getCaptureFilterSettings(tabId); + capture.filterSettings = filterSettings; + filterSettings.forEach((filter, index) => { + if (!capture.filters[index]) return; + applyBiquadSettings(capture.filters[index], toBiquadInput(filter)); + }); + }; + + const buildCaptureGraph = (tabId: number | string): void => { + const capture = captures.get(String(tabId)); + if (!capture?.source) return; + + disconnectCaptureGraph(capture); + capture.preamp = deps.audioContext.createGain(); + capture.source.connect(capture.preamp); + + let previousNode: AudioNode = capture.preamp; + const filterSettings = getCaptureFilterSettings(tabId); + capture.filters = filterSettings.map((filter) => { + const biquadFilter = createBiquadFilter(deps.audioContext, toBiquadInput(filter)); + previousNode.connect(biquadFilter); + previousNode = biquadFilter; + return biquadFilter; + }); + + capture.output = previousNode; + capture.output.connect(deps.audioContext.destination); + applyCaptureSettings(tabId); + }; + + const refreshCaptureFilters = ( + tabId: number | string | null = activeTabId, + ): void => { + const capture = captures.get(String(tabId)); + if (!capture?.source) return; + + const filterSettings = getCaptureFilterSettings(tabId); + capture.filterSettings = filterSettings; + if (capture.filters.length !== filterSettings.length) { + buildCaptureGraph(tabId ?? activeTabId ?? ""); + return; + } + + applyCaptureSettings(tabId); + }; + + const loadTabSettings = async (tabId: number | null): Promise => { + if (tabId == null) return; + activeTabId = tabId; + + const result = await chrome.storage.local.get([ + STORAGE_KEYS.FILTERS, + STORAGE_KEYS.tabFilters(tabId), + STORAGE_KEYS.tabGain(tabId), + STORAGE_KEYS.tabEnabled(tabId), + STORAGE_KEYS.tabMute(tabId), + STORAGE_KEYS.tabCaptureError(tabId), + ]); + + const tabFilters = result[STORAGE_KEYS.tabFilters(tabId)] as + | EqualizerPersistedFilter[] + | undefined; + const defaultFilters = result[STORAGE_KEYS.FILTERS] as + | EqualizerPersistedFilter[] + | undefined; + + if (tabFilters?.length) { + deps.setFilters(tabFilters); + } else if (defaultFilters?.length) { + deps.setFilters(defaultFilters); + } else { + deps.initPoints(await deps.getPointCount()); + } + + const gain = result[STORAGE_KEYS.tabGain(tabId)]; + deps.resize(); + deps.setEnableButtonText(result[STORAGE_KEYS.tabEnabled(tabId)] === true); + deps.setMuteButtonClass(result[STORAGE_KEYS.tabMute(tabId)] === true); + + deps.setGainValue(typeof gain === "string" || typeof gain === "number" ? Number(gain) : 0); + + deps.renderCaptureError( + typeof result[STORAGE_KEYS.tabCaptureError(tabId)] === "string" + ? (result[STORAGE_KEYS.tabCaptureError(tabId)] as string) + : null, + ); + refreshCaptureFilters(tabId); + }; + + const startTabCapture = async (): Promise => { + if (!isToolkitWindow) return; + + const streamIds = await getCaptureStreamIds(); + const streamEntries = Object.entries(streamIds); + + try { + await deps.audioContext.resume(); + + const activeTabIds = new Set(streamEntries.map(([tabId]) => tabId)); + captures.forEach((capture, tabId) => { + if (activeTabIds.has(tabId)) return; + stopCaptureEntry(capture); + captures.delete(tabId); + }); + + await Promise.all( + streamEntries.map(async ([tabId, streamId]) => { + const existing = captures.get(tabId); + if (existing?.streamId === streamId) return; + + if (existing) { + stopCaptureEntry(existing); + captures.delete(tabId); + } + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: "tab", + chromeMediaSourceId: streamId, + }, + } as MediaTrackConstraints, + video: false, + }); + const source = deps.audioContext.createMediaStreamSource(stream); + const capture: ToolkitCapture = { + streamId, + stream, + source, + preamp: null, + filters: [], + output: null, + filterSettings: [], + }; + captures.set(tabId, capture); + buildCaptureGraph(tabId); + await chrome.storage.local.remove(STORAGE_KEYS.tabCaptureError(tabId)); + }), + ); + + deps.renderCaptureError(null); + } catch (e) { + const tabId = await getCurrentTabId(); + const message = e instanceof Error ? e.message : "Tab audio capture failed"; + if (tabId != null) { + await chrome.storage.local.set({ + [STORAGE_KEYS.tabCaptureError(tabId)]: message, + }); + } + deps.renderCaptureError(message); + } + }; + + capturedTabsView = createCapturedTabsView({ + root: deps.capturedTabs, + isToolkitWindow, + onSelectTab: loadTabSettings, + }); + + const renderCapturedTabs = async (): Promise => { + await capturedTabsView?.render(); + }; + + const stopTabCapture = (): void => { + captures.forEach((capture) => stopCaptureEntry(capture)); + captures.clear(); + }; + + window.addEventListener("beforeunload", stopTabCapture); + + return { + isToolkitWindow, + getCurrentTabId, + shouldShowToolkitWindowNotice, + showToolkitWindowNotice, + loadTabSettings, + startTabCapture, + renderCapturedTabs, + refreshCaptureFilters, + applyCaptureSettings, + stopTabCapture, + handleStorageChange: async (changes) => { + if (isToolkitWindow && changes[STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]) { + activeTabId = + (changes[STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID].newValue as + | number + | undefined) ?? null; + await loadTabSettings(activeTabId); + await renderCapturedTabs(); + } + + if (isToolkitWindow && changes[STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS]) { + await startTabCapture(); + await renderCapturedTabs(); + } + }, + }; +}; diff --git a/src/ui/popup/autostartView.ts b/src/ui/popup/autostartView.ts new file mode 100644 index 0000000..c981732 --- /dev/null +++ b/src/ui/popup/autostartView.ts @@ -0,0 +1,229 @@ +import { + createWhitelistEntry, + getWhitelistDomain, + normalizeWhitelistUrl, + type AutostartEntryType, + type AutostartWhitelistEntry, +} from "../../domains/autostart/autostartRules"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +export interface AutostartView { + init(): Promise; + refreshPresetSelects(): Promise; + renderWhitelist(): Promise; +} + +const getActiveTab = async (): Promise => { + const [tab] = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true, + }); + return tab ?? null; +}; + +export const createAutostartView = (deps: { + addToWhitelistButton: HTMLElement; + modal: HTMLElement; + closeButton: HTMLElement; + cancelButton: HTMLButtonElement; + confirmButton: HTMLButtonElement; + modalPreset: HTMLSelectElement; + modalError: HTMLElement; + modalDomainValue: HTMLElement; + modalUrlValue: HTMLElement; + settingsList: HTMLElement; + settingsType: HTMLSelectElement; + settingsAddValue: HTMLInputElement; + settingsAddPreset: HTMLSelectElement; + settingsAddButton: HTMLButtonElement; + settingsError: HTMLElement; + isToolkitWindow: boolean; + getMessage(messageName: string): string; +}): AutostartView => { + const setError = (element: HTMLElement, messageName: string): void => { + element.textContent = messageName ? deps.getMessage(messageName) : ""; + element.style.display = messageName ? "block" : "none"; + }; + + const fillPresetSelect = ( + select: HTMLSelectElement, + presetNames: string[], + selectedName = "", + ): void => { + select.textContent = ""; + + const emptyOption = document.createElement("option"); + emptyOption.value = ""; + emptyOption.textContent = deps.getMessage( + "autostart_settings_select_preset_placeholder", + ); + select.appendChild(emptyOption); + + presetNames.forEach((name) => { + const option = document.createElement("option"); + option.value = name; + option.textContent = name; + select.appendChild(option); + }); + + select.value = selectedName; + }; + + const refreshPresetSelects = async (): Promise => { + const stored = await chrome.storage.local.get([STORAGE_KEYS.PRESET_NAMES]); + const presetNames = (stored[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[]; + fillPresetSelect(deps.modalPreset, presetNames); + fillPresetSelect(deps.settingsAddPreset, presetNames); + }; + + const formatWhitelistEntry = (entry: AutostartWhitelistEntry): string => { + const typeLabel = + entry.type === "url" + ? deps.getMessage("autostart_rule_type_url_label") + : deps.getMessage("autostart_rule_type_domain_label"); + return `${typeLabel}: ${entry.value}`; + }; + + const saveWhitelistEntry = async ( + type: string | undefined, + value: string | undefined, + presetName: string, + errorElement: HTMLElement, + ): Promise => { + const entry = createWhitelistEntry( + type as AutostartEntryType, + value ?? "", + presetName, + ); + if (!entry) { + setError(errorElement, "autostart_validation_error"); + return false; + } + + const stored = await chrome.storage.local.get([STORAGE_KEYS.AUTOSTART_RULES]); + const entries = ((stored[STORAGE_KEYS.AUTOSTART_RULES] ?? []) as AutostartWhitelistEntry[]) + .filter((item) => item.id !== entry.id); + entries.push(entry); + await chrome.storage.local.set({ [STORAGE_KEYS.AUTOSTART_RULES]: entries }); + setError(errorElement, ""); + return true; + }; + + const removeWhitelistEntry = async (id: string): Promise => { + const stored = await chrome.storage.local.get([STORAGE_KEYS.AUTOSTART_RULES]); + const entries = ((stored[STORAGE_KEYS.AUTOSTART_RULES] ?? []) as AutostartWhitelistEntry[]) + .filter((entry) => entry.id !== id); + await chrome.storage.local.set({ [STORAGE_KEYS.AUTOSTART_RULES]: entries }); + }; + + const renderWhitelist = async (): Promise => { + const stored = await chrome.storage.local.get([STORAGE_KEYS.AUTOSTART_RULES]); + const entries = (stored[STORAGE_KEYS.AUTOSTART_RULES] ?? []) as AutostartWhitelistEntry[]; + deps.settingsList.textContent = ""; + + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.id = "whitelist-empty"; + empty.className = "whitelist-empty"; + empty.textContent = deps.getMessage("autostart_whitelist_empty"); + deps.settingsList.appendChild(empty); + return; + } + + entries.forEach((entry) => { + const item = document.createElement("div"); + item.className = "whitelist-item"; + + const text = document.createElement("div"); + text.className = "whitelist-item-text"; + + const value = document.createElement("span"); + value.textContent = formatWhitelistEntry(entry); + + const preset = document.createElement("small"); + preset.textContent = `${deps.getMessage("autostart_modal_preset_label")}: ${ + entry.presetName + }`; + + text.append(value, preset); + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "whitelist-delete"; + deleteButton.textContent = "\u00d7"; + deleteButton.setAttribute("aria-label", deps.getMessage("delete")); + deleteButton.addEventListener("click", () => { + void removeWhitelistEntry(entry.id); + }); + + item.append(text, deleteButton); + deps.settingsList.appendChild(item); + }); + }; + + const closeModal = (): void => { + deps.modal.style.display = "none"; + }; + + deps.addToWhitelistButton.addEventListener("click", () => { + void (async () => { + if (deps.isToolkitWindow) return; + + const tab = await getActiveTab(); + deps.modalDomainValue.textContent = getWhitelistDomain(tab?.url ?? ""); + deps.modalUrlValue.textContent = normalizeWhitelistUrl(tab?.url ?? ""); + await refreshPresetSelects(); + setError(deps.modalError, ""); + deps.modal.style.display = "block"; + })(); + }); + + deps.closeButton.addEventListener("click", closeModal); + deps.cancelButton.addEventListener("click", closeModal); + deps.modal.addEventListener("click", (event) => { + if (event.target === deps.modal) closeModal(); + }); + + deps.confirmButton.addEventListener("click", () => { + void (async () => { + const tab = await getActiveTab(); + const selectedType = deps.modal.querySelector( + "input[name='autostart-modal-add-type']:checked", + )?.value; + const saved = await saveWhitelistEntry( + selectedType, + tab?.url, + deps.modalPreset.value, + deps.modalError, + ); + if (saved) closeModal(); + })(); + }); + + deps.settingsAddButton.addEventListener("click", () => { + void (async () => { + const saved = await saveWhitelistEntry( + deps.settingsType.value, + deps.settingsAddValue.value, + deps.settingsAddPreset.value, + deps.settingsError, + ); + if (saved) deps.settingsAddValue.value = ""; + })(); + }); + + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== "local") return; + if (changes[STORAGE_KEYS.AUTOSTART_RULES]) void renderWhitelist(); + if (changes[STORAGE_KEYS.PRESET_NAMES]) void refreshPresetSelects(); + }); + + return { + init: async () => { + await refreshPresetSelects(); + await renderWhitelist(); + }, + refreshPresetSelects, + renderWhitelist, + }; +}; diff --git a/src/ui/popup/capturedTabsView.ts b/src/ui/popup/capturedTabsView.ts new file mode 100644 index 0000000..946e18c --- /dev/null +++ b/src/ui/popup/capturedTabsView.ts @@ -0,0 +1,87 @@ +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +export interface CapturedTabInfo { + id: number; + title?: string; + url?: string; + favIconUrl?: string; +} + +export interface CapturedTabsResponse { + tabs: CapturedTabInfo[]; + activeTabId: number | null; +} + +export interface CapturedTabsView { + render(): Promise; +} + +const getCapturedTabs = (): Promise => { + return (chrome.runtime.sendMessage({ + method: "getCapturedTabs", + }) as Promise).catch((error: unknown) => { + console.error(error); + return { tabs: [], activeTabId: null }; + }); +}; + +export const createCapturedTabsView = (deps: { + root: HTMLElement; + isToolkitWindow: boolean; + onSelectTab(tabId: number): Promise; +}): CapturedTabsView => { + const render = async (): Promise => { + if (!deps.isToolkitWindow) return; + + const result = await getCapturedTabs(); + if (!Array.isArray(result.tabs)) { + console.error("Invalid toolkit tabs response", result); + return; + } + + deps.root.replaceChildren(); + result.tabs.forEach((tab) => { + const item = document.createElement("button"); + item.type = "button"; + item.className = "captured-tab"; + if (tab.id === result.activeTabId) item.classList.add("active"); + item.dataset.tabId = String(tab.id); + item.title = tab.title || tab.url || String(tab.id); + + if (tab.favIconUrl) { + const icon = document.createElement("img"); + icon.className = "captured-tab-icon"; + icon.src = tab.favIconUrl; + icon.alt = ""; + item.appendChild(icon); + } + + const title = document.createElement("span"); + title.className = "captured-tab-title"; + title.textContent = tab.title || tab.url || `Tab ${tab.id}`; + item.appendChild(title); + + deps.root.appendChild(item); + }); + }; + + deps.root.addEventListener("click", (event) => { + void (async () => { + if (!(event.target instanceof Element)) return; + + const item = event.target.closest(".captured-tab"); + if (!item) return; + + const tabId = Number.parseInt(item.dataset.tabId ?? "", 10); + if (Number.isNaN(tabId)) return; + + await chrome.storage.session.set({ + [STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]: tabId, + }); + await deps.onSelectTab(tabId); + await render(); + })(); + }); + + return { render }; +}; diff --git a/src/ui/popup/controlsView.ts b/src/ui/popup/controlsView.ts new file mode 100644 index 0000000..88c6123 --- /dev/null +++ b/src/ui/popup/controlsView.ts @@ -0,0 +1,54 @@ +export interface ControlsView { + setEnableButtonText(enabled: boolean): void; + setMuteButtonClass(muted: boolean): void; +} + +export const createControlsView = (deps: { + changeEqButton: HTMLButtonElement; + resetButton: HTMLButtonElement; + masterVolume: HTMLInputElement; + volumeMuteButton: HTMLElement; + windowModeButton: HTMLElement; + isToolkitWindow: boolean; + getMessage(messageName: string): string; + onToggleEqualizer(): Promise; + onReset(): Promise; + onVolumeInput(value: number): Promise; + onToggleMute(): Promise; + onWindowMode(): Promise; + onMuteStateApplied(): void; +}): ControlsView => { + deps.changeEqButton.addEventListener("click", () => { + if (deps.isToolkitWindow) return; + void deps.onToggleEqualizer(); + }); + + deps.resetButton.addEventListener("click", () => { + void deps.onReset(); + }); + + deps.masterVolume.addEventListener("input", () => { + void deps.onVolumeInput(Number(deps.masterVolume.value)); + }); + + deps.volumeMuteButton.addEventListener("click", () => { + void deps.onToggleMute(); + }); + + deps.windowModeButton.addEventListener("click", () => { + void deps.onWindowMode(); + }); + + return { + setEnableButtonText: (enabled) => { + deps.changeEqButton.textContent = deps.getMessage( + enabled ? "stop_eq_button_label" : "enable_eq_button_label", + ); + }, + + setMuteButtonClass: (muted) => { + deps.volumeMuteButton.className = muted ? "volume-mute-active" : "volume-mute"; + deps.onMuteStateApplied(); + }, + }; +}; diff --git a/src/ui/popup/installUpdateNoticeView.test.ts b/src/ui/popup/installUpdateNoticeView.test.ts new file mode 100644 index 0000000..2ae95c9 --- /dev/null +++ b/src/ui/popup/installUpdateNoticeView.test.ts @@ -0,0 +1,44 @@ +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; +import { getPendingInstallUpdateNotice } from "./installUpdateNoticeView"; + +describe("getPendingInstallUpdateNotice", () => { + const stored = { + [STORAGE_KEYS.INSTALL_UPDATE_NOTICE]: { + reason: "update", + version: "1.7.0", + }, + }; + + it("returns the current install/update notice outside toolkit window mode", () => { + expect( + getPendingInstallUpdateNotice({ + stored, + currentVersion: "1.7.0", + isToolkitWindow: false, + }), + ).toEqual({ + reason: "update", + version: "1.7.0", + }); + }); + + it("does not return notices in toolkit window mode", () => { + expect( + getPendingInstallUpdateNotice({ + stored, + currentVersion: "1.7.0", + isToolkitWindow: true, + }), + ).toBeNull(); + }); + + it("does not return stale notices for older versions", () => { + expect( + getPendingInstallUpdateNotice({ + stored, + currentVersion: "1.8.0", + isToolkitWindow: false, + }), + ).toBeNull(); + }); +}); diff --git a/src/ui/popup/installUpdateNoticeView.ts b/src/ui/popup/installUpdateNoticeView.ts new file mode 100644 index 0000000..f8c44cb --- /dev/null +++ b/src/ui/popup/installUpdateNoticeView.ts @@ -0,0 +1,66 @@ +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +export interface InstallUpdateNotice { + reason: "install" | "update"; + version: string; +} + +export interface PendingInstallUpdateNoticeOptions { + stored: Record; + currentVersion: string; + isToolkitWindow: boolean; +} + +export interface InstallUpdateNoticeView { + showInstallUpdateNotice(stored: Record): void; +} + +export const getPendingInstallUpdateNotice = ({ + stored, + currentVersion, + isToolkitWindow, +}: PendingInstallUpdateNoticeOptions): InstallUpdateNotice | null => { + const notice = stored[STORAGE_KEYS.INSTALL_UPDATE_NOTICE]; + if (isToolkitWindow) return null; + if (!notice || typeof notice !== "object") return null; + + const pendingNotice = notice as Partial; + if (pendingNotice.reason !== "install" && pendingNotice.reason !== "update") { + return null; + } + if (pendingNotice.version !== currentVersion) return null; + + return { + reason: pendingNotice.reason, + version: pendingNotice.version, + }; +}; + +export const createInstallUpdateNoticeView = (deps: { + modal: HTMLElement; + closeButton: HTMLElement; + currentVersion: string; + isToolkitWindow: boolean; +}): InstallUpdateNoticeView => { + const closeInstallUpdateNotice = async (): Promise => { + deps.modal.style.display = "none"; + await chrome.storage.local.remove(STORAGE_KEYS.INSTALL_UPDATE_NOTICE); + }; + + deps.closeButton.addEventListener("click", () => { + void closeInstallUpdateNotice(); + }); + + return { + showInstallUpdateNotice: (stored) => { + const notice = getPendingInstallUpdateNotice({ + stored, + currentVersion: deps.currentVersion, + isToolkitWindow: deps.isToolkitWindow, + }); + if (!notice) return; + + deps.modal.style.display = "block"; + }, + }; +}; diff --git a/src/ui/popup/popupShortcuts.ts b/src/ui/popup/popupShortcuts.ts new file mode 100644 index 0000000..58185ca --- /dev/null +++ b/src/ui/popup/popupShortcuts.ts @@ -0,0 +1,187 @@ +export const POPUP_SHORTCUT_ACTION_MUTE_NAME = "mute"; +export const POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME = "toggleEq"; + +export interface PopupShortcut { + alt: boolean; + ctrl: boolean; + shift: boolean; + meta: boolean; + key: string; +} + +export type PopupShortcutActionName = + | typeof POPUP_SHORTCUT_ACTION_MUTE_NAME + | typeof POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME; + +export type PopupShortcutMap = Record; +export type PopupShortcutValidationError = "invalid" | "duplicate"; + +export const POPUP_DEFAULT_SHORTCUTS = Object.freeze({ + [POPUP_SHORTCUT_ACTION_MUTE_NAME]: Object.freeze({ + alt: true, + ctrl: false, + shift: false, + meta: false, + key: "M", + }), + [POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME]: Object.freeze({ + alt: true, + ctrl: false, + shift: false, + meta: false, + key: "K", + }), +}) as PopupShortcutMap; + +interface PopupShortcutLike { + alt?: unknown; + ctrl?: unknown; + shift?: unknown; + meta?: unknown; + key?: unknown; +} + +export const normalizePopupShortcutKey = (key: unknown): string | null => { + if (typeof key !== "string") return null; + + const value = key.trim(); + if (/^[a-z0-9]$/i.test(value)) return value.toUpperCase(); + + return null; +}; + +export const normalizePopupShortcutCode = (code: unknown): string | null => { + if (typeof code !== "string") return null; + + if (/^Key[A-Z]$/.test(code)) return code.slice(3); + if (/^Digit[0-9]$/.test(code)) return code.slice(5); + + return null; +}; + +export const isPopupModifierShortcutKey = (key: string): boolean => { + return ["Alt", "Control", "Shift", "Meta"].includes(key); +}; + +export const isPopupEditableShortcutTarget = ( + target: EventTarget | null, +): boolean => { + if (!target) return false; + + const element = + typeof Element !== "undefined" && target instanceof Element + ? target + : typeof Element !== "undefined" && + "parentElement" in target && + target.parentElement instanceof Element + ? target.parentElement + : null; + if (!element) return false; + + return Boolean( + element.closest( + "input, textarea, select, [contenteditable=''], [contenteditable='true']", + ), + ); +}; + +export const formatPopupShortcut = (shortcut: PopupShortcut | null): string => { + if (!shortcut) return ""; + + const parts = []; + if (shortcut.ctrl) parts.push("Ctrl"); + if (shortcut.alt) parts.push("Alt"); + if (shortcut.shift) parts.push("Shift"); + if (shortcut.meta) parts.push("Meta"); + parts.push(shortcut.key); + return parts.join("+"); +}; + +export const arePopupShortcutsEqual = ( + first: PopupShortcut | null | undefined, + second: PopupShortcut | null | undefined, +): boolean => { + return ( + first?.ctrl === second?.ctrl && + first?.alt === second?.alt && + first?.shift === second?.shift && + first?.meta === second?.meta && + first?.key === second?.key + ); +}; + +export const normalizePopupShortcut = (value: unknown): PopupShortcut | null => { + if (!value || typeof value !== "object") return null; + + const shortcutValue = value as PopupShortcutLike; + const shortcut = { + ctrl: shortcutValue.ctrl === true, + alt: shortcutValue.alt === true, + shift: shortcutValue.shift === true, + meta: shortcutValue.meta === true, + key: typeof shortcutValue.key === "string" ? shortcutValue.key : "", + }; + + if (!shortcut.key) return null; + if (!shortcut.ctrl && !shortcut.alt && !shortcut.shift && !shortcut.meta) { + return null; + } + + return shortcut; +}; + +export const normalizePopupShortcutFromKeyboardEvent = ( + event: KeyboardEvent | null | undefined, +): PopupShortcut | null => { + if (!event || event.isComposing) return null; + const key = + normalizePopupShortcutCode(event.code) ?? normalizePopupShortcutKey(event.key); + + return normalizePopupShortcut({ + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + key, + }); +}; + +export const resolvePopupShortcuts = ( + storedShortcuts: Partial | null | undefined, +): PopupShortcutMap => { + const shortcuts = storedShortcuts ?? POPUP_DEFAULT_SHORTCUTS; + return { + [POPUP_SHORTCUT_ACTION_MUTE_NAME]: normalizePopupShortcut( + shortcuts[POPUP_SHORTCUT_ACTION_MUTE_NAME], + ), + [POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME]: normalizePopupShortcut( + shortcuts[POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME], + ), + }; +}; + +export const validatePopupShortcutConfig = ( + shortcuts: Partial | null | undefined, +): PopupShortcutValidationError | null => { + const muteShortcut = normalizePopupShortcut( + shortcuts?.[POPUP_SHORTCUT_ACTION_MUTE_NAME], + ); + const toggleEqShortcut = normalizePopupShortcut( + shortcuts?.[POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME], + ); + + if (!muteShortcut || !toggleEqShortcut) return "invalid"; + if (arePopupShortcutsEqual(muteShortcut, toggleEqShortcut)) return "duplicate"; + + return null; +}; + +export const matchesPopupShortcut = ( + event: KeyboardEvent, + shortcut: PopupShortcut | null, +): boolean => { + const normalized = normalizePopupShortcutFromKeyboardEvent(event); + if (!normalized) return false; + + return arePopupShortcutsEqual(normalized, shortcut); +}; diff --git a/src/ui/popup/presetsView.ts b/src/ui/popup/presetsView.ts new file mode 100644 index 0000000..0e9f774 --- /dev/null +++ b/src/ui/popup/presetsView.ts @@ -0,0 +1,140 @@ +import { isPresetUsedInWhitelist } from "../../domains/autostart/autostartRules"; +import type { EqualizerPersistedFilter } from "../../domains/equalizer/equalizerState"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +export interface PresetsView { + addPresetToDropdown(name: string): void; +} + +type PresetStorage = Record; + +export const createPresetsView = (deps: { + dropdown: HTMLElement; + toggle: HTMLElement; + menu: HTMLElement; + nameInput: HTMLInputElement; + saveButton: HTMLButtonElement; + isToolkitWindow: boolean; + getMessage(messageName: string): string; + getCurrentTabId(): Promise; + getCurrentFilters(): EqualizerPersistedFilter[]; + setCurrentFilters(filters: EqualizerPersistedFilter[]): void; + saveLoadedFilters(filters: EqualizerPersistedFilter[]): Promise; + redraw(): void; + refreshToolkitCaptureFilters(): void; +}): PresetsView => { + const addPresetToDropdown = (name: string): void => { + const option = document.createElement("div"); + option.textContent = name; + option.setAttribute("data-value", name); + option.className = "dropdown-item"; + + const closeButton = document.createElement("span"); + closeButton.className = "close-btn"; + closeButton.textContent = "\u00d7"; + closeButton.setAttribute("data-value", name); + option.appendChild(closeButton); + + deps.menu.appendChild(option); + }; + + deps.saveButton.addEventListener("click", () => { + void (async () => { + const name = deps.nameInput.value; + if (name.length === 0) return; + + const tabId = await deps.getCurrentTabId(); + if (tabId == null) return; + + const prefs = await chrome.storage.local.get([ + STORAGE_KEYS.tabFilters(tabId), + STORAGE_KEYS.PRESETS, + STORAGE_KEYS.PRESET_NAMES, + ]); + const presets = (prefs[STORAGE_KEYS.PRESETS] ?? {}) as PresetStorage; + const presetNames = [...((prefs[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[])]; + presets[name] = + (prefs[STORAGE_KEYS.tabFilters(tabId)] as EqualizerPersistedFilter[]) ?? + deps.getCurrentFilters(); + + const needAdd = !presetNames.includes(name); + if (needAdd) presetNames.push(name); + + await chrome.storage.local.set({ + [STORAGE_KEYS.PRESETS]: presets, + [STORAGE_KEYS.PRESET_NAMES]: presetNames, + }); + if (needAdd) addPresetToDropdown(name); + deps.nameInput.value = ""; + })(); + }); + + deps.toggle.addEventListener("click", () => { + deps.menu.style.display = deps.menu.style.display === "block" ? "none" : "block"; + }); + + document.addEventListener("click", (event) => { + if (!(event.target instanceof Node)) return; + if (!deps.dropdown.contains(event.target)) deps.menu.style.display = "none"; + }); + + deps.menu.addEventListener("click", (event) => { + void (async () => { + if (!(event.target instanceof HTMLElement)) return; + + const choice = event.target.getAttribute("data-value"); + if (!choice) return; + + if (choice === "none") { + deps.toggle.textContent = deps.getMessage("empty_preset_name"); + deps.menu.style.display = "none"; + return; + } + + if (event.target.classList.contains("close-btn")) { + const prefs = await chrome.storage.local.get([ + STORAGE_KEYS.PRESETS, + STORAGE_KEYS.PRESET_NAMES, + STORAGE_KEYS.AUTOSTART_RULES, + ]); + + if (isPresetUsedInWhitelist(prefs[STORAGE_KEYS.AUTOSTART_RULES], choice)) { + alert(deps.getMessage("preset_delete_error")); + return; + } + + event.target.parentElement?.remove(); + const presets = (prefs[STORAGE_KEYS.PRESETS] ?? {}) as PresetStorage; + const presetNames = ((prefs[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[]).filter( + (name) => name !== choice, + ); + delete presets[choice]; + + await chrome.storage.local.set({ + [STORAGE_KEYS.PRESETS]: presets, + [STORAGE_KEYS.PRESET_NAMES]: presetNames, + }); + return; + } + + if (!event.target.classList.contains("dropdown-item")) return; + + deps.toggle.textContent = + choice === "none" ? deps.getMessage("empty_preset_name") : choice; + const prefs = await chrome.storage.local.get([STORAGE_KEYS.PRESETS]); + const presets = prefs[STORAGE_KEYS.PRESETS] as PresetStorage | undefined; + const filters = presets?.[choice]; + if (!filters) return; + + deps.setCurrentFilters(filters); + await deps.saveLoadedFilters(deps.getCurrentFilters()); + deps.redraw(); + deps.refreshToolkitCaptureFilters(); + deps.menu.style.display = "none"; + })(); + }); + + return { + addPresetToDropdown, + }; +}; diff --git a/src/ui/popup/settingsView.ts b/src/ui/popup/settingsView.ts new file mode 100644 index 0000000..e1ecc91 --- /dev/null +++ b/src/ui/popup/settingsView.ts @@ -0,0 +1,358 @@ +import { clampPointCount } from "../../domains/equalizer/equalizerMath"; +import { type LocalizationService } from "../../domains/localization/localizationService"; +import { + formatPopupShortcut, + isPopupModifierShortcutKey, + normalizePopupShortcutFromKeyboardEvent, + POPUP_SHORTCUT_ACTION_MUTE_NAME, + POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME, + resolvePopupShortcuts, + type PopupShortcut, + type PopupShortcutActionName, + type PopupShortcutMap, + validatePopupShortcutConfig, +} from "./popupShortcuts"; +import { themes, type ThemeName } from "../../domains/theme/themes"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; + +const DEFAULT_THEME: ThemeName = "dark"; + +export interface SettingsView { + init(): Promise; + applyTheme(theme: unknown): ThemeName; + updatePointCountSelect(count: unknown): void; + getShortcutSettings(): PopupShortcutMap; +} + +const isThemeName = (theme: unknown): theme is ThemeName => { + return typeof theme === "string" && theme in themes; +}; + +const saveTextAsFile = (text: string, filename = "file.txt"): void => { + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +}; + +export const createSettingsView = (deps: { + settingsModal: HTMLElement; + settingsButton: HTMLElement; + closeSettingsButton: HTMLElement; + themeSelect: HTMLSelectElement; + pointsCount: HTMLSelectElement; + pointsResetModal: HTMLElement; + pointsResetConfirm: HTMLButtonElement; + pointsResetCancel: HTMLButtonElement; + skipResetConfirm: HTMLInputElement; + exportPresetsButton: HTMLButtonElement; + importPresetsButton: HTMLButtonElement; + importInput: HTMLInputElement; + enableSpectrum: HTMLInputElement; + languageSelect: HTMLSelectElement; + shortcutMute: HTMLInputElement; + shortcutToggleEq: HTMLInputElement; + shortcutsError: HTMLElement; + localization: LocalizationService; + addPresetToDropdown(name: string): void; + initPoints(count: number): void; + redraw(): void; + refreshToolkitCaptureFilters(): void; + saveCurrentFilters(): Promise; + refreshDynamicContent(): Promise; +}): SettingsView => { + let pendingPointCount: number | null = null; + let shortcutSettings: PopupShortcutMap = resolvePopupShortcuts(null); + + const applyTheme = (theme: unknown): ThemeName => { + const chosenTheme = isThemeName(theme) ? theme : DEFAULT_THEME; + const chosen = themes[chosenTheme] ?? themes.dark; + + Object.entries(chosen).forEach(([key, value]) => { + document.documentElement.style.setProperty(`--${key}`, value); + }); + deps.themeSelect.value = chosenTheme; + deps.redraw(); + return chosenTheme; + }; + + const saveTheme = (theme: ThemeName): Promise => { + return chrome.storage.local.set({ [STORAGE_KEYS.THEME]: theme }); + }; + + const shouldSkipPointsResetConfirm = async (): Promise => { + const result = await chrome.storage.local.get([STORAGE_KEYS.SKIP_POINTS_CONFIRM]); + return ( + result[STORAGE_KEYS.SKIP_POINTS_CONFIRM] === "true" || + result[STORAGE_KEYS.SKIP_POINTS_CONFIRM] === true + ); + }; + + const setSkipPointsResetConfirm = (value: boolean): Promise => { + return chrome.storage.local.set({ + [STORAGE_KEYS.SKIP_POINTS_CONFIRM]: Boolean(value), + }); + }; + + const updatePointCountSelect = (count: unknown): void => { + deps.pointsCount.value = clampPointCount(Number.parseInt(String(count), 10)).toString(); + }; + + const applyPointCountChange = async (newCount: number): Promise => { + await chrome.storage.local.set({ [STORAGE_KEYS.POINT_COUNT]: newCount }); + deps.initPoints(newCount); + deps.redraw(); + deps.refreshToolkitCaptureFilters(); + await deps.saveCurrentFilters(); + }; + + const closePointsResetModal = (): void => { + deps.pointsResetModal.style.display = "none"; + }; + + const resetPointCountSelectFromStorage = async (): Promise => { + const result = await chrome.storage.local.get([STORAGE_KEYS.POINT_COUNT]); + updatePointCountSelect(result[STORAGE_KEYS.POINT_COUNT]); + }; + + const setShortcutsError = (messageName: string | null): void => { + if (!messageName) { + deps.shortcutsError.style.display = "none"; + deps.shortcutsError.textContent = ""; + return; + } + + deps.shortcutsError.textContent = deps.localization.getMessage(messageName); + deps.shortcutsError.style.display = "block"; + }; + + const shortcutInputs: Record = { + [POPUP_SHORTCUT_ACTION_MUTE_NAME]: deps.shortcutMute, + [POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME]: deps.shortcutToggleEq, + }; + + const renderShortcutInputs = ( + invalidAction: PopupShortcutActionName | null = null, + ): void => { + Object.entries(shortcutInputs).forEach(([action, input]) => { + input.value = formatPopupShortcut( + shortcutSettings[action as PopupShortcutActionName], + ); + input.classList.toggle("invalid", action === invalidAction); + }); + }; + + const startShortcutEdit = (input: HTMLInputElement): void => { + input.value = "?"; + input.classList.remove("invalid"); + setShortcutsError(null); + }; + + const saveShortcut = async ( + action: PopupShortcutActionName, + shortcut: PopupShortcut, + ): Promise => { + const nextShortcuts = { + ...shortcutSettings, + [action]: shortcut, + }; + const validationError = validatePopupShortcutConfig(nextShortcuts); + + if (validationError) { + const messageName = + validationError === "duplicate" + ? "shortcut_duplicate_error" + : "shortcut_validation_error"; + setShortcutsError(messageName); + renderShortcutInputs(action); + return false; + } + + shortcutSettings = resolvePopupShortcuts(nextShortcuts); + await chrome.storage.local.set({ + [STORAGE_KEYS.SHORTCUTS]: shortcutSettings, + }); + setShortcutsError(null); + renderShortcutInputs(); + return true; + }; + + deps.settingsButton.addEventListener("click", () => { + deps.settingsModal.style.display = "block"; + }); + + deps.closeSettingsButton.addEventListener("click", () => { + deps.settingsModal.style.display = "none"; + }); + + window.addEventListener("click", (event) => { + if (event.target === deps.settingsModal) { + deps.settingsModal.style.display = "none"; + } + }); + + deps.themeSelect.addEventListener("change", () => { + const theme = applyTheme(deps.themeSelect.value); + void saveTheme(theme); + }); + + deps.pointsCount.addEventListener("change", () => { + void (async () => { + const newCount = clampPointCount(Number.parseInt(deps.pointsCount.value, 10)); + if (Number.isNaN(newCount)) return; + + if (await shouldSkipPointsResetConfirm()) { + await applyPointCountChange(newCount); + return; + } + + pendingPointCount = newCount; + deps.skipResetConfirm.checked = await shouldSkipPointsResetConfirm(); + deps.pointsResetModal.style.display = "block"; + })(); + }); + + deps.pointsResetConfirm.addEventListener("click", () => { + void (async () => { + await setSkipPointsResetConfirm(deps.skipResetConfirm.checked); + if (pendingPointCount != null) { + await applyPointCountChange(pendingPointCount); + } + pendingPointCount = null; + closePointsResetModal(); + })(); + }); + + deps.pointsResetCancel.addEventListener("click", () => { + void (async () => { + await resetPointCountSelectFromStorage(); + pendingPointCount = null; + closePointsResetModal(); + })(); + }); + + deps.pointsResetModal.addEventListener("click", (event) => { + void (async () => { + if (event.target !== deps.pointsResetModal) return; + + await resetPointCountSelectFromStorage(); + pendingPointCount = null; + closePointsResetModal(); + })(); + }); + + deps.exportPresetsButton.addEventListener("click", () => { + void (async () => { + const result = await chrome.storage.local.get([ + STORAGE_KEYS.PRESETS, + STORAGE_KEYS.PRESET_NAMES, + ]); + saveTextAsFile(JSON.stringify(result), "eq_toolkit_presets.json"); + })(); + }); + + deps.importPresetsButton.addEventListener("click", () => { + deps.importInput.click(); + }); + + deps.importInput.addEventListener("change", () => { + const file = deps.importInput.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + void (async () => { + if (typeof reader.result !== "string") return; + + const presetsInfo = JSON.parse(reader.result) as { + presets?: Record; + presetNames?: string[]; + }; + const prefs = await chrome.storage.local.get([ + STORAGE_KEYS.PRESETS, + STORAGE_KEYS.PRESET_NAMES, + ]); + const presets = (prefs[STORAGE_KEYS.PRESETS] ?? {}) as Record; + const presetNames = [...((prefs[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[])]; + + (presetsInfo.presetNames ?? []).forEach((name) => { + const needAdd = !presetNames.includes(name); + if (!needAdd) return; + + presetNames.push(name); + deps.addPresetToDropdown(name); + presets[name] = presetsInfo.presets?.[name]; + }); + + await chrome.storage.local.set({ + [STORAGE_KEYS.PRESETS]: presets, + [STORAGE_KEYS.PRESET_NAMES]: presetNames, + }); + })(); + }; + reader.readAsText(file, "utf-8"); + }); + + deps.enableSpectrum.addEventListener("change", () => { + void chrome.storage.local.set({ + [STORAGE_KEYS.ENABLE_SPECTRUM]: deps.enableSpectrum.checked, + }); + }); + + deps.languageSelect.addEventListener("change", () => { + void deps.localization.setLanguage(deps.languageSelect.value, { + save: true, + refreshDynamicContent: deps.refreshDynamicContent, + }); + }); + + Object.entries(shortcutInputs).forEach(([action, input]) => { + const shortcutAction = action as PopupShortcutActionName; + + input.addEventListener("focus", () => { + startShortcutEdit(input); + }); + + input.addEventListener("blur", () => { + setShortcutsError(null); + renderShortcutInputs(); + }); + + input.addEventListener("keydown", (event) => { + void (async () => { + event.preventDefault(); + event.stopPropagation(); + + if (isPopupModifierShortcutKey(event.key)) return; + + const shortcut = normalizePopupShortcutFromKeyboardEvent(event); + if (!shortcut) { + setShortcutsError("shortcut_validation_error"); + input.classList.add("invalid"); + return; + } + + const saved = await saveShortcut(shortcutAction, shortcut); + if (!saved) input.value = "?"; + })(); + }); + }); + + return { + init: async () => { + const stored = await chrome.storage.local.get([STORAGE_KEYS.SHORTCUTS]); + shortcutSettings = resolvePopupShortcuts( + stored[STORAGE_KEYS.SHORTCUTS] as Partial, + ); + renderShortcutInputs(); + }, + applyTheme, + updatePointCountSelect, + getShortcutSettings: () => shortcutSettings, + }; +}; From 18536972e7a067cbe3abc521b60569bd48764c75 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 4 Jul 2026 19:37:20 +0300 Subject: [PATCH 19/63] chore: finalize TypeScript Vite migration --- README.md | 7 +++++++ tools/buildExtension.mjs | 3 --- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 35adab6..858d6e1 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,13 @@ You can help translate EQ Toolkit on [Weblate](https://hosted.weblate.org/projec [![Translation status](https://hosted.weblate.org/widget/eq-toolkit-chrome-extension/svg-badge.svg)](https://hosted.weblate.org/engage/eq-toolkit-chrome-extension/) +## Development +1. Install dependencies with `npm ci`. +2. Build the extension with `npm run build`. +3. Load `dist/` as an unpacked extension from your browser extensions page. + +Run `npm run typecheck` and `npm test` before packaging changes. + ## Support The extention are provided for free. If you like the project, I would appreciate your [support](https://www.donationalerts.com/r/maitrog). diff --git a/tools/buildExtension.mjs b/tools/buildExtension.mjs index b0440db..1e2800e 100644 --- a/tools/buildExtension.mjs +++ b/tools/buildExtension.mjs @@ -51,9 +51,6 @@ await Promise.all([ cp(resolve(rootDir, "_locales"), resolve(distDir, "_locales"), { recursive: true, }), - cp(resolve(rootDir, "scripts"), resolve(distDir, "scripts"), { - recursive: true, - }), ]); const scriptEntries = await readdir(resolve(distDir, "scripts"), { From 4a6bfdd8d56a2890e3abd70273186485a90ff191 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sun, 5 Jul 2026 13:39:22 +0300 Subject: [PATCH 20/63] fix: stop spectrum vizualisation when point counts was changed --- src/app/content-main/contentMain.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/app/content-main/contentMain.ts b/src/app/content-main/contentMain.ts index aab784e..6bd1d7c 100644 --- a/src/app/content-main/contentMain.ts +++ b/src/app/content-main/contentMain.ts @@ -263,6 +263,24 @@ const updateSpectrumState = (): void => { } }; +const refreshSpectrumForGraph = ( + source: AudioNode, + filters: EqualizerNodeChain, +): void => { + if ( + port.dataset.enableSpectrum !== "true" || + port.dataset.enabled !== "true" + ) { + stopSpectrum(); + return; + } + + currentAudioCtx = getAudioContext(source); + currentSourceNode = getLastBiquadFilter(filters, filters.balance); + analyser = null; + startSpectrum(); +}; + port.addEventListener("spectrum-state-changed", updateSpectrumState); AudioNode.prototype.connect = new Proxy(nativeConnect, { @@ -345,7 +363,8 @@ port.addEventListener("filters-changed", () => { const filterSettings = readFilterSettings(); if (getBiquadFilterCount(filters) !== filterSettings.length) { rebuildBiquadChain(source, filters, filterSettings); - updateSpectrumState(); + currentGraphSource = source; + refreshSpectrumForGraph(source, filters); port.dispatchEvent(new Event("connected")); return; } From c1606696a8e1e8b670188b0a4c844ad47569b949 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sun, 5 Jul 2026 13:44:12 +0300 Subject: [PATCH 21/63] fix: spectrum visualization wasn't changing in real time --- src/app/content-main/contentMain.test.ts | 197 ++++++++++++++++++ src/app/content-main/contentMain.ts | 31 ++- src/app/popup/createPopupApp.ts | 4 +- .../draw/colorProvider.test.ts | 18 ++ src/ui/equalizerCanvas/draw/drawSpectrum.ts | 5 +- 5 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 src/app/content-main/contentMain.test.ts diff --git a/src/app/content-main/contentMain.test.ts b/src/app/content-main/contentMain.test.ts new file mode 100644 index 0000000..909d9fc --- /dev/null +++ b/src/app/content-main/contentMain.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +const filters = JSON.stringify([ + { freq: 20, gain: 0, q: 0.5, type: "highpass" }, + { freq: 180, gain: 0, q: 0.5, type: "peaking" }, + { freq: 20000, gain: 0, q: 0.5, type: "lowpass" }, +]); + +class FakePort extends EventTarget { + id = "eq-tools-port"; + dataset: Record = { + enabled: "true", + enableSpectrum: "false", + freqs: filters, + mute: "false", + preamp: "1", + }; + + remove(): void {} +} + +class FakeAudioNode { + context: FakeAudioContext; + readonly label: string; + connections: unknown[] = []; + + constructor(context: FakeAudioContext, label = "node") { + this.context = context; + this.label = label; + } + + connect(destination: unknown): unknown { + this.connections.push(destination); + return destination; + } + + disconnect(): void { + this.connections = []; + } +} + +class FakeAudioDestinationNode extends FakeAudioNode {} + +class FakeBiquadFilterNode extends FakeAudioNode { + type: BiquadFilterType = "peaking"; + gain = { value: 0 }; + frequency = { value: 0 }; + Q = { value: 0 }; +} + +class FakeGainNode extends FakeAudioNode { + gain = { value: 1 }; +} + +class FakeStereoPannerNode extends FakeAudioNode { + pan = { value: 0 }; +} + +class FakeAnalyserNode extends FakeAudioNode { + sampleRate = 48000; + fftSize = 2048; + minDecibels = -100; + maxDecibels = -30; + frequencyBinCount = 8; + + getFloatFrequencyData(buffer: Float32Array): void { + buffer.fill(this.context.spectrumDb); + } +} + +class FakeAudioContext { + sampleRate = 48000; + destination = new FakeAudioDestinationNode(this, "destination"); + spectrumDb: number; + + constructor(spectrumDb: number) { + this.spectrumDb = spectrumDb; + } + + createGain(): FakeGainNode { + return new FakeGainNode(this, "gain"); + } + + createStereoPanner(): FakeStereoPannerNode { + return new FakeStereoPannerNode(this, "panner"); + } + + createBiquadFilter(): FakeBiquadFilterNode { + return new FakeBiquadFilterNode(this, "biquad"); + } + + createAnalyser(): FakeAnalyserNode { + return new FakeAnalyserNode(this, "analyser"); + } +} + +const loadContentMain = async (port: FakePort): Promise => { + vi.resetModules(); + + vi.stubGlobal("document", { + getElementById: (id: string) => (id === "eq-tools-port" ? port : null), + }); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + Audio: class {}, + }); + vi.stubGlobal("self", globalThis.window); + vi.stubGlobal("AudioNode", FakeAudioNode); + vi.stubGlobal("AudioDestinationNode", FakeAudioDestinationNode); + vi.stubGlobal("BiquadFilterNode", FakeBiquadFilterNode); + class FakeHTMLMediaElement { + play(): Promise { + return Promise.resolve(); + } + } + vi.stubGlobal("HTMLMediaElement", FakeHTMLMediaElement); + vi.stubGlobal("setTimeout", vi.fn()); + vi.stubGlobal("setInterval", vi.fn((callback: () => void) => { + callback(); + return 1; + })); + vi.stubGlobal("clearInterval", vi.fn()); + + await import("./contentMain"); +}; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("contentMain spectrum state", () => { + const getLastSpectrumFrame = ( + frames: unknown[], + ): { buffer?: Float32Array | null } | undefined => { + for (let i = frames.length - 1; i >= 0; i--) { + const frame = frames[i] as { + type?: string; + buffer?: Float32Array | null; + }; + if (frame.type === "spectrum") { + return { buffer: frame.buffer }; + } + } + + return undefined; + }; + + test("uses the latest connected graph when spectrum is enabled", async () => { + const port = new FakePort(); + const frames: unknown[] = []; + port.addEventListener("spectrum-frame", (event) => { + frames.push((event as CustomEvent).detail); + }); + await loadContentMain(port); + + const oldContext = new FakeAudioContext(-90); + const oldSource = new FakeAudioNode(oldContext, "old-source"); + oldSource.connect(oldContext.destination); + + const activeContext = new FakeAudioContext(-42); + const activeSource = new FakeAudioNode(activeContext, "active-source"); + activeSource.connect(activeContext.destination); + + port.dataset.enableSpectrum = "true"; + port.dispatchEvent(new Event("spectrum-state-changed")); + + const spectrumFrame = getLastSpectrumFrame(frames); + + if (!spectrumFrame?.buffer) { + throw new Error("Expected a spectrum frame with a buffer"); + } + expect(spectrumFrame.buffer[0]).toBe(-42); + }); + + test("emits a clear spectrum frame when spectrum is stopped", async () => { + const port = new FakePort(); + const frames: unknown[] = []; + port.addEventListener("spectrum-frame", (event) => { + frames.push((event as CustomEvent).detail); + }); + await loadContentMain(port); + + const activeContext = new FakeAudioContext(-42); + const activeSource = new FakeAudioNode(activeContext, "active-source"); + activeSource.connect(activeContext.destination); + + port.dataset.enableSpectrum = "true"; + port.dispatchEvent(new Event("spectrum-state-changed")); + expect(getLastSpectrumFrame(frames)?.buffer?.[0]).toBe(-42); + + port.dataset.enableSpectrum = "false"; + port.dispatchEvent(new Event("spectrum-state-changed")); + + expect(getLastSpectrumFrame(frames)?.buffer).toBeNull(); + }); +}); diff --git a/src/app/content-main/contentMain.ts b/src/app/content-main/contentMain.ts index 6bd1d7c..fb4ffd7 100644 --- a/src/app/content-main/contentMain.ts +++ b/src/app/content-main/contentMain.ts @@ -28,6 +28,7 @@ port.remove(); const equalizerGraphs = new Map(); let currentAudioCtx: AudioContext | null = null; let currentSourceNode: AudioNode | null = null; +let currentGraphSource: AudioNode | null = null; let analyser: AnalyserWithLegacySampleRate | null = null; let spectrumTimer: ReturnType | null = null; const mediaSources = new WeakMap(); @@ -145,6 +146,7 @@ const attach = (source: AudioNode): AudioNode => { filters.balance.pan.value = 0; filters.preamp.connect(filters.balance); equalizerGraphs.set(source, filters); + currentGraphSource = source; const filterSettings = readFilterSettings(); rebuildBiquadChain(source, filters, filterSettings); @@ -254,13 +256,21 @@ const updateSpectrumState = (): void => { return; } - for (const [source, filters] of equalizerGraphs) { - currentAudioCtx = getAudioContext(source); - currentSourceNode = getLastBiquadFilter(filters, filters.balance); - analyser = null; - startSpectrum(); + const source = + currentGraphSource && equalizerGraphs.has(currentGraphSource) + ? currentGraphSource + : Array.from(equalizerGraphs.keys()).at(-1) ?? null; + if (!source) { return; } + + const filters = equalizerGraphs.get(source); + if (!filters) { + return; + } + + currentGraphSource = source; + refreshSpectrumForGraph(source, filters); }; const refreshSpectrumForGraph = ( @@ -431,4 +441,15 @@ function stopSpectrum(): void { clearInterval(spectrumTimer); spectrumTimer = null; } + + port.dispatchEvent( + new CustomEvent("spectrum-frame", { + detail: { + type: "spectrum", + buffer: null, + }, + bubbles: true, + composed: true, + }), + ); } diff --git a/src/app/popup/createPopupApp.ts b/src/app/popup/createPopupApp.ts index a632596..a7dd44e 100644 --- a/src/app/popup/createPopupApp.ts +++ b/src/app/popup/createPopupApp.ts @@ -42,7 +42,7 @@ export interface PopupAppDependencies { interface SpectrumStorageMessage extends Partial { type?: "meta" | "spectrum"; - buffer?: SpectrumBuffer; + buffer?: SpectrumBuffer | null; } export const createPopupApp = ({ @@ -427,7 +427,7 @@ export const createPopupApp = ({ if (message?.type === "meta") { spectrumRenderer.setMeta(message); } - if (message?.type === "spectrum" && message.buffer) { + if (message?.type === "spectrum") { spectrumRenderer.scheduleDraw(message.buffer); } } diff --git a/src/ui/equalizerCanvas/draw/colorProvider.test.ts b/src/ui/equalizerCanvas/draw/colorProvider.test.ts index 1d681b1..d1d2d79 100644 --- a/src/ui/equalizerCanvas/draw/colorProvider.test.ts +++ b/src/ui/equalizerCanvas/draw/colorProvider.test.ts @@ -109,4 +109,22 @@ describe("equalizer canvas color providers", () => { ); expect(ctx.strokeStyle).toBe("accent-mid-updated"); }); + + test("spectrum renderer clears canvas when a null frame is scheduled", () => { + const canvas = createCanvas(); + const ctx = createContext(); + const renderer = createSpectrumRenderer({ + canvas, + ctx, + getColors: () => createColors("initial"), + requestAnimationFrame: (callback) => { + callback(0); + return 1; + }, + }); + + renderer.scheduleDraw(null); + + expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 100, 60); + }); }); diff --git a/src/ui/equalizerCanvas/draw/drawSpectrum.ts b/src/ui/equalizerCanvas/draw/drawSpectrum.ts index 7bbfe3a..b36f819 100644 --- a/src/ui/equalizerCanvas/draw/drawSpectrum.ts +++ b/src/ui/equalizerCanvas/draw/drawSpectrum.ts @@ -194,10 +194,7 @@ export const createSpectrumRenderer = ({ rafScheduled = true; requestFrame(() => { rafScheduled = false; - - if (lastBuffer) { - drawSpectrum(lastBuffer); - } + drawSpectrum(lastBuffer); }); }; From 1c3472cde3e003a5fb091abff588a90094b095dc Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sun, 5 Jul 2026 19:56:34 +0300 Subject: [PATCH 22/63] test: rename some tests --- src/app/background/messageRouter.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/background/messageRouter.test.ts b/src/app/background/messageRouter.test.ts index 7f05e96..928d649 100644 --- a/src/app/background/messageRouter.test.ts +++ b/src/app/background/messageRouter.test.ts @@ -90,7 +90,7 @@ describe("createRuntimeMessageHandler", () => { expect(response).toHaveBeenCalledWith({ tabs: [], activeTabId: null }); }); - test("registers sender tab id before responding", () => { + test("stores sender tab id and responds synchronously", () => { const chromeMock = createChromeMock(); chromeMock.sessionGet.mockImplementation((_keys, callback) => { callback({ [STORAGE_KEYS.REGISTERED_TAB_IDS]: [3] }); @@ -140,7 +140,7 @@ describe("createRuntimeMessageHandler", () => { ); }); - test("does not keep channel open for fire-and-forget tab messages", () => { + test("updates connected tab badge without an async response", () => { const chromeMock = createChromeMock(); const handler = createRuntimeMessageHandler({ applyAutostartForTab: vi.fn(), @@ -162,7 +162,7 @@ describe("createRuntimeMessageHandler", () => { }); }); - test("does not keep channel open for unknown tab messages", () => { + test("ignores unknown tab messages without an async response", () => { createChromeMock(); const handler = createRuntimeMessageHandler({ applyAutostartForTab: vi.fn(), From a88276fc0edefb184123df1dd3ab92643159391e Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sun, 5 Jul 2026 19:57:06 +0300 Subject: [PATCH 23/63] test: delete tests for chrome storage --- .../chrome/chromeStorage.test.ts | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/infrastructure/chrome/chromeStorage.test.ts diff --git a/src/infrastructure/chrome/chromeStorage.test.ts b/src/infrastructure/chrome/chromeStorage.test.ts deleted file mode 100644 index ae85471..0000000 --- a/src/infrastructure/chrome/chromeStorage.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -describe("chromeStorage", () => { - test("can be imported without a chrome global", async () => { - const originalChrome = globalThis.chrome; - - try { - delete (globalThis as { chrome?: typeof chrome }).chrome; - - const module = await import("./chromeStorage"); - - expect(typeof module.createChromeStorageArea).toBe("function"); - expect(typeof module.getLocalStorageArea).toBe("function"); - expect(typeof module.getSessionStorageArea).toBe("function"); - } finally { - if (originalChrome) { - (globalThis as { chrome?: typeof chrome }).chrome = originalChrome; - } - } - }); -}); From 12b50c2f41074504fe80425c19eeeeb967b1d1b8 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sun, 5 Jul 2026 21:21:50 +0300 Subject: [PATCH 24/63] chore: remove obsolete JavaScript files --- scripts/background.js | 347 ----------------- scripts/equalizer/draw/drawFilters.js | 51 --- scripts/equalizer/draw/drawFrequensyAxis.js | 75 ---- scripts/equalizer/draw/drawHighpassFilter.js | 42 --- scripts/equalizer/draw/drawLowpassFilter.js | 42 --- scripts/equalizer/draw/drawPeakingFilter.js | 44 --- scripts/equalizer/draw/drawSpectrum.js | 156 -------- scripts/equalizer/equalizerGestures.js | 72 ---- scripts/equalizer/equalizerMath.js | 44 --- scripts/equalizer/equalizerPoints.js | 173 --------- scripts/equalizer/equalizerTooltips.js | 30 -- scripts/events.js | 185 --------- scripts/filter.js | 371 ------------------- scripts/installUpdateModal.js | 33 -- scripts/languages.js | 89 ----- scripts/loadColors.js | 20 - scripts/localization.js | 161 -------- scripts/popup.js | 179 --------- scripts/popupControls.js | 115 ------ scripts/popupPresets.js | 105 ------ scripts/popupSettings.js | 312 ---------------- scripts/popupState.js | 31 -- scripts/popupWhitelist.js | 221 ----------- scripts/shortcuts.js | 142 ------- scripts/themes.js | 107 ------ scripts/whitelist.js | 71 ---- scripts/windowMod/windowMod.js | 57 --- scripts/windowMod/windowModCapturedTabs.js | 66 ---- scripts/windowMod/windowModTabCapture.js | 222 ----------- 29 files changed, 3563 deletions(-) delete mode 100644 scripts/background.js delete mode 100644 scripts/equalizer/draw/drawFilters.js delete mode 100644 scripts/equalizer/draw/drawFrequensyAxis.js delete mode 100644 scripts/equalizer/draw/drawHighpassFilter.js delete mode 100644 scripts/equalizer/draw/drawLowpassFilter.js delete mode 100644 scripts/equalizer/draw/drawPeakingFilter.js delete mode 100644 scripts/equalizer/draw/drawSpectrum.js delete mode 100644 scripts/equalizer/equalizerGestures.js delete mode 100644 scripts/equalizer/equalizerMath.js delete mode 100644 scripts/equalizer/equalizerPoints.js delete mode 100644 scripts/equalizer/equalizerTooltips.js delete mode 100644 scripts/events.js delete mode 100644 scripts/filter.js delete mode 100644 scripts/installUpdateModal.js delete mode 100644 scripts/languages.js delete mode 100644 scripts/loadColors.js delete mode 100644 scripts/localization.js delete mode 100644 scripts/popup.js delete mode 100644 scripts/popupControls.js delete mode 100644 scripts/popupPresets.js delete mode 100644 scripts/popupSettings.js delete mode 100644 scripts/popupState.js delete mode 100644 scripts/popupWhitelist.js delete mode 100644 scripts/shortcuts.js delete mode 100644 scripts/themes.js delete mode 100644 scripts/whitelist.js delete mode 100644 scripts/windowMod/windowMod.js delete mode 100644 scripts/windowMod/windowModCapturedTabs.js delete mode 100644 scripts/windowMod/windowModTabCapture.js diff --git a/scripts/background.js b/scripts/background.js deleted file mode 100644 index e604095..0000000 --- a/scripts/background.js +++ /dev/null @@ -1,347 +0,0 @@ -importScripts(chrome.runtime.getURL("scripts/whitelist.js")); - -importScripts(chrome.runtime.getURL("scripts/shortcuts.js")); - -const register = async () => { - await chrome.scripting.unregisterContentScripts(); - - const props = { - matches: ["*://*/*"], - allFrames: true, - matchOriginAsFallback: true, - runAt: "document_start", - }; - - await chrome.scripting.registerContentScripts([ - { - id: "events", - js: ["scripts/shortcuts.js", "scripts/events.js"], - world: "ISOLATED", - ...props, - }, - { - id: "filter", - js: ["scripts/filter.js"], - world: "MAIN", - ...props, - }, - ]); -}; - -const INSTALL_UPDATE_NOTICE_KEY = "installUpdateNotice"; - -chrome.runtime.onStartup.addListener(register); -chrome.runtime.onInstalled.addListener(async (details) => { - await register(); - await prepareInstallUpdateNotice(details); -}); - -async function prepareInstallUpdateNotice(details) { - if (!["install", "update"].includes(details.reason)) return; - - await chrome.storage.local.set({ - [INSTALL_UPDATE_NOTICE_KEY]: { - reason: details.reason, - version: chrome.runtime.getManifest().version, - }, - }); -} - -chrome.storage.local.get(["enabled"], async (ps) => { - const id = await getCurrentTabId(); - - if (ps.enabled) { - const onStartup = () => { - ps.enabled[id] = false; - chrome.storage.local.set({ - ["enabled." + id]: false, - }); - }; - chrome.runtime.onStartup.addListener(onStartup); - chrome.runtime.onInstalled.addListener(onStartup); - } -}); - -chrome.runtime.onMessage.addListener((request, sender, response) => { - if (request.method === "log") { - console.log(request.message); - return; - } - - if (request.method === "enableWindowMode") { - toggleWindowMod(request.tabId); - return; - } - - if (request.method === "getCapturedTabs") { - getCapturedTabs() - .then(response) - .catch(() => { - response({ tabs: [], activeTabId: null }); - }); - return true; - } - - const tabId = sender.tab?.id; - if (tabId == null) return; - - if (request.method == "spectrum-frame") { - chrome.storage.local.set({ - ["spectrum." + tabId]: request.payload, - }); - } else if (request.method === "getTabId") { - chrome.storage.session.get(["tabs"], (prefs) => { - const tabs = prefs?.tabs ?? []; - if (!tabs.includes(tabId)) { - tabs.push(tabId); - chrome.storage.session.set({ tabs: tabs }); - } - }); - response(tabId); - } else if (request.method === "pageStarted") { - applyWhitelistForTab(tabId, sender.tab?.url, { resetWhenNoMatch: true }); - } else if (request.method === "connected") { - chrome.action.setBadgeText({ - text: "ON", - tabId, - }); - } else if (request.method === "disconnected") { - chrome.action.setBadgeText({ - text: "OFF", - tabId, - }); - } else if (request.method === "clearStorage") { - clearUnusedStorage(); - } - return true; -}); - -chrome.tabs.onActivated.addListener(async ({ tabId }) => { - try { - const tab = await chrome.tabs.get(tabId); - await applyWhitelistForTab(tabId, tab.url); - } catch (e) {} -}); - -chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - if (!changeInfo.url) return; - applyWhitelistForTab(tabId, tab.url); -}); - -async function getCurrentTabId() { - let queryOptions = { active: true, lastFocusedWindow: true }; - let [tab] = await chrome.tabs.query(queryOptions); - return tab.id; -} - -async function clearUnusedStorage() { - const constParamNames = [ - "presets", - "presetNames", - "whitelist", - "gain", - "filters", - "enableSpectrum", - "theme", - "uiLanguage", - "shortcuts", - INSTALL_UPDATE_NOTICE_KEY, - ]; - const tabIds = (await chrome.storage.session.get(["tabs"])).tabs ?? []; - - chrome.storage.local.getKeys((keys) => - keys.forEach((key) => { - const keySplited = key.split("."); - if ( - !constParamNames.includes(key) && - !( - keySplited.length == 2 && - tabIds.includes(Number.parseInt(keySplited[1])) - ) - ) - chrome.storage.local.remove(key); - }) - ); -} - -async function applyWhitelistForTab(tabId, url, options = {}) { - if (tabId == null || !url || !url.startsWith("http")) return; - - const stored = await chrome.storage.local.get(["whitelist", "presets"]); - const entry = findWhitelistMatch(stored.whitelist, url); - if (!entry) { - if (options.resetWhenNoMatch) { - await chrome.storage.local.set({ ["enabled." + tabId]: false }); - } - return; - } - - const preset = stored.presets?.[entry.presetName]; - if (!Array.isArray(preset) || preset.length === 0) { - await chrome.storage.local.set({ ["enabled." + tabId]: false }); - return; - } - - await chrome.storage.local.set({ - ["filters." + tabId]: preset, - ["filters"]: preset, - ["enabled." + tabId]: true, - }); -} - -const WINDOW_KEY = "toolkitWindowId"; // ключ, под которым хранится id окна -const WINDOW_TAB_IDS_KEY = "toolkitWindowTabIds"; -const WINDOW_ACTIVE_TAB_KEY = "toolkitWindowActiveTabId"; -const WINDOW_CAPTURE_STREAM_IDS_KEY = "toolkitWindowCaptureStreamIds"; - -async function toggleWindowMod(tabId) { - tabId ??= await getCurrentTabId(); - let id = await getWindowId(); - await prepareTabCapture(tabId); - - if (await isWindowExist()) { - id = await getWindowId(); - await addTabIdToStore(tabId); - await focusWindowMod(id); - return; - } - - // Окно не сохранено — создаём - await createWindowMod(tabId); -} - -async function getWindowId() { - const obj = await chrome.storage.session.get(WINDOW_KEY); - return obj[WINDOW_KEY] ?? null; -} - -async function isWindowExist() { - const id = await getWindowId(); - if (!id) return false; - - try { - await chrome.windows.get(id); - return true; - } catch (e) { - await clearWindowId(); - return false; - } -} - -async function addTabIdToStore(tabId) { - if (tabId == null) return; - - const stored = await chrome.storage.session.get(WINDOW_TAB_IDS_KEY); - const tabIds = Array.isArray(stored[WINDOW_TAB_IDS_KEY]) - ? stored[WINDOW_TAB_IDS_KEY] - : []; - - if (!tabIds.includes(tabId)) { - tabIds.push(tabId); - await chrome.storage.session.set({ [WINDOW_TAB_IDS_KEY]: tabIds }); - } -} - -async function getCapturedTabs() { - const stored = await chrome.storage.session.get([ - WINDOW_TAB_IDS_KEY, - WINDOW_ACTIVE_TAB_KEY, - ]); - const tabIds = Array.isArray(stored[WINDOW_TAB_IDS_KEY]) - ? stored[WINDOW_TAB_IDS_KEY] - : []; - const activeTabId = stored[WINDOW_ACTIVE_TAB_KEY] ?? null; - const tabs = []; - - for (const tabId of tabIds) { - try { - const tab = await chrome.tabs.get(tabId); - tabs.push({ - id: tab.id, - title: tab.title, - url: tab.url, - favIconUrl: tab.favIconUrl, - active: tab.id === activeTabId, - }); - } catch (e) {} - } - - return { tabs, activeTabId }; -} - -async function focusWindowMod(windowId) { - try { - const window = await chrome.windows.get(windowId); - const updateInfo = { focused: true }; - if (window.state === "minimized") updateInfo.state = "normal"; - await chrome.windows.update(windowId, updateInfo); - } catch (e) { - await clearWindowId(); - } -} - -async function clearWindowId() { - await chrome.storage.session.remove([ - WINDOW_KEY, - WINDOW_TAB_IDS_KEY, - WINDOW_ACTIVE_TAB_KEY, - WINDOW_CAPTURE_STREAM_IDS_KEY, - ]); -} - -async function prepareTabCapture(tabId) { - if (tabId == null) return; - - await chrome.storage.local.set({ - ["enabled." + tabId]: false, - }); - - try { - const streamId = await chrome.tabCapture.getMediaStreamId({ - targetTabId: tabId, - }); - const stored = await chrome.storage.session.get( - WINDOW_CAPTURE_STREAM_IDS_KEY - ); - const streamIds = stored[WINDOW_CAPTURE_STREAM_IDS_KEY] ?? {}; - streamIds[tabId] = streamId; - await chrome.storage.session.set({ - [WINDOW_ACTIVE_TAB_KEY]: tabId, - [WINDOW_CAPTURE_STREAM_IDS_KEY]: streamIds, - }); - } catch (e) { - const stored = await chrome.storage.session.get( - WINDOW_CAPTURE_STREAM_IDS_KEY - ); - const streamIds = stored[WINDOW_CAPTURE_STREAM_IDS_KEY] ?? {}; - delete streamIds[tabId]; - await chrome.storage.session.set({ - [WINDOW_ACTIVE_TAB_KEY]: tabId, - [WINDOW_CAPTURE_STREAM_IDS_KEY]: streamIds, - }); - await chrome.storage.local.set({ - ["captureError." + tabId]: e?.message ?? "Tab audio capture failed", - }); - } -} - -async function createWindowMod(tabId) { - const w = await chrome.windows.create({ - url: chrome.runtime.getURL("popup.html?mode=window"), - type: "popup", - width: 560, - height: 586, - focused: true, - }); - const storageValues = { - [WINDOW_KEY]: w.id, - }; - if (tabId != null) storageValues[WINDOW_TAB_IDS_KEY] = [tabId]; - await chrome.storage.session.set(storageValues); - return w.id; -} - -chrome.windows.onRemoved.addListener(async (windowId) => { - const id = await getWindowId(); - if (id === windowId) await clearWindowId(); -}); diff --git a/scripts/equalizer/draw/drawFilters.js b/scripts/equalizer/draw/drawFilters.js deleted file mode 100644 index 79d65f9..0000000 --- a/scripts/equalizer/draw/drawFilters.js +++ /dev/null @@ -1,51 +0,0 @@ -function drawPoints() { - points.forEach((p) => { - ctx.beginPath(); - ctx.arc(p.x, p.y, pointRadius, 0, Math.PI * 2); - ctx.fillStyle = panelBg; - ctx.fill(); - ctx.strokeStyle = g_accentMid; - ctx.lineWidth = 2; - ctx.stroke(); - }); - - drawTypedPoint(g_highpassPoint, g_highpassFilterColor); - drawTypedPoint(g_lowpassPoint, g_lowpassFilterColor); -} - -function drawTypedPoint(point, color) { - if (!point) return; - - ctx.beginPath(); - ctx.arc(point.x, point.y, pointRadius, 0, Math.PI * 2); - ctx.fillStyle = panelBg; - ctx.fill(); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.stroke(); -} - -function drawFilter() { - drawPoints(); - - if (g_highpassPoint) { - const canvasWidth = canvas.width - 10; - const freq = xToFrequency(g_highpassPoint.x, canvasWidth); - drawHighpassFilter(canvas, audioCtx, freq, ensureQFactor(g_highpassPoint.q)); - } - - if (points.length > 0) { - const canvasWidth = canvas.width - 10; - points.forEach((p) => { - const freq = xToFrequency(p.x, canvasWidth); - const db = yToDb(p.y); - drawPeakingFilter(canvas, audioCtx, freq, ensureQFactor(p.q), db); - }); - } - - if (g_lowpassPoint) { - const canvasWidth = canvas.width - 10; - const freq = xToFrequency(g_lowpassPoint.x, canvasWidth); - drawLowpassFilter(canvas, audioCtx, freq, ensureQFactor(g_lowpassPoint.q)); - } -} diff --git a/scripts/equalizer/draw/drawFrequensyAxis.js b/scripts/equalizer/draw/drawFrequensyAxis.js deleted file mode 100644 index b8a5dc9..0000000 --- a/scripts/equalizer/draw/drawFrequensyAxis.js +++ /dev/null @@ -1,75 +0,0 @@ -function resizeCanvas() { - canvas.width = canvas.clientWidth; - canvas.height = canvas.clientHeight; - draw(); -} - -function draw() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - drawAxis(); - drawFilter(); -} - -function drawAxis() { - const freqMargin = 10; - const margin = 10; - const yPos = canvas.height; - - ctx.textAlign = "center"; - ctx.textBaseline = "top"; - - const freqs = [5, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]; - const canvasWidth = canvas.width - 10; - - ctx.strokeStyle = axis; - ctx.fillStyle = axis; - freqs.forEach((f) => { - const x = - Math.pow((Math.log10(f) - logMin) / (logMax - logMin), 2) * canvasWidth; - ctx.beginPath(); - ctx.moveTo(x, yPos - 10); - ctx.lineTo(x, yPos); - ctx.stroke(); - ctx.fillText(f.toString(), x, yPos - margin - freqMargin); - - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, 10); - ctx.stroke(); - }); - - const gainMargin = 10; - const gainMin = -25; - const gainMax = 25; - const step = 5; - const gainLabels = Array.from( - { length: (gainMax - gainMin) / step + 1 }, - (_, i) => i * step - gainMax - ); - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - gainLabels.forEach((g) => { - const y = - gainMargin + - ((gainMax - g) / (gainMax - gainMin)) * (canvas.height - gainMargin * 2); - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(10, y); - ctx.stroke(); - ctx.fillText(g.toString(), 12, y); - }); - - const zeroY = - gainMargin + - ((gainMax - 0) / (gainMax - gainMin)) * (canvas.height - gainMargin * 2); - ctx.textBaseline = "bottom"; - ctx.textAlign = "center"; - freqs.forEach((f) => { - const x = - Math.pow((Math.log10(f) - logMin) / (logMax - logMin), 2) * canvasWidth; - ctx.beginPath(); - ctx.moveTo(x, zeroY - 5); - ctx.lineTo(x, zeroY + 5); - ctx.stroke(); - }); -} diff --git a/scripts/equalizer/draw/drawHighpassFilter.js b/scripts/equalizer/draw/drawHighpassFilter.js deleted file mode 100644 index cbd1a3c..0000000 --- a/scripts/equalizer/draw/drawHighpassFilter.js +++ /dev/null @@ -1,42 +0,0 @@ -function drawHighpassFilter(canvas, audioCtx, freq, Q = 0.5) { - const gainMargin = 20; - const ctx = canvas.getContext("2d"); - const width = canvas.width; - const height = canvas.height; - const canvasWidth = width - 10; - - const filter = audioCtx.createBiquadFilter(); - filter.type = "highpass"; - filter.frequency.value = freq; - filter.Q.value = Q; - - const numPoints = width; - const frequencies = new Float32Array(numPoints); - const magResponse = new Float32Array(numPoints); - const phaseResponse = new Float32Array(numPoints); - const maxResponseFrequency = audioCtx.sampleRate / 2; - - for (let i = 0; i < numPoints; i++) { - frequencies[i] = Math.min( - xToFrequency(i, canvasWidth), - maxResponseFrequency - ); - } - - filter.getFrequencyResponse(frequencies, magResponse, phaseResponse); - - ctx.strokeStyle = g_highpassFilterColor; - ctx.lineWidth = 1; - ctx.beginPath(); - for (let i = 0; i < numPoints; i++) { - const db = Math.max( - -80, - 25 * Math.log10(magResponse[i] || Number.EPSILON) - ); - const y = height / 2 - (db / 25) * (height / 2 - gainMargin * 2); - const x = i; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); -} diff --git a/scripts/equalizer/draw/drawLowpassFilter.js b/scripts/equalizer/draw/drawLowpassFilter.js deleted file mode 100644 index 5c9a9bf..0000000 --- a/scripts/equalizer/draw/drawLowpassFilter.js +++ /dev/null @@ -1,42 +0,0 @@ -function drawLowpassFilter(canvas, audioCtx, freq, Q = 0.5) { - const gainMargin = 20; - const ctx = canvas.getContext("2d"); - const width = canvas.width; - const height = canvas.height; - const canvasWidth = width - 10; - - const filter = audioCtx.createBiquadFilter(); - filter.type = "lowpass"; - filter.frequency.value = freq; - filter.Q.value = Q; - - const numPoints = width; - const frequencies = new Float32Array(numPoints); - const magResponse = new Float32Array(numPoints); - const phaseResponse = new Float32Array(numPoints); - const maxResponseFrequency = audioCtx.sampleRate / 2; - - for (let i = 0; i < numPoints; i++) { - frequencies[i] = Math.min( - xToFrequency(i, canvasWidth), - maxResponseFrequency - ); - } - - filter.getFrequencyResponse(frequencies, magResponse, phaseResponse); - - ctx.strokeStyle = g_lowpassFilterColor; - ctx.lineWidth = 1; - ctx.beginPath(); - for (let i = 0; i < numPoints; i++) { - const db = Math.max( - -80, - 25 * Math.log10(magResponse[i] || Number.EPSILON) - ); - const y = height / 2 - (db / 25) * (height / 2 - gainMargin * 2); - const x = i; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); -} diff --git a/scripts/equalizer/draw/drawPeakingFilter.js b/scripts/equalizer/draw/drawPeakingFilter.js deleted file mode 100644 index 2a1e784..0000000 --- a/scripts/equalizer/draw/drawPeakingFilter.js +++ /dev/null @@ -1,44 +0,0 @@ -function drawPeakingFilter(canvas, audioCtx, freq, Q = 1, gainDb = 0) { - const gainMargin = 20; - const ctx = canvas.getContext("2d"); - const width = canvas.width; - const height = canvas.height; - const canvasWidth = width - 10; - - const filter = audioCtx.createBiquadFilter(); - filter.type = "peaking"; - filter.frequency.value = freq; - filter.Q.value = Q; - filter.gain.value = gainDb; - - const numPoints = width; - const frequencies = new Float32Array(numPoints); - const magResponse = new Float32Array(numPoints); - const phaseResponse = new Float32Array(numPoints); - const maxResponseFrequency = audioCtx.sampleRate / 2; - - for (let i = 0; i < numPoints; i++) { - frequencies[i] = Math.min( - xToFrequency(i, canvasWidth), - maxResponseFrequency - ); - } - - filter.getFrequencyResponse(frequencies, magResponse, phaseResponse); - - const grad = ctx.createLinearGradient(0, 0, canvas.width, 0); - grad.addColorStop(0, g_accentStart); - grad.addColorStop(0.5, g_accentMid); - grad.addColorStop(1, g_accentEnd); - ctx.strokeStyle = grad; - ctx.lineWidth = 1; - ctx.beginPath(); - for (let i = 0; i < numPoints; i++) { - const db = 25 * Math.log10(magResponse[i]); - const y = height / 2 - (db / 25) * (height / 2 - gainMargin * 2); - const x = i; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); -} diff --git a/scripts/equalizer/draw/drawSpectrum.js b/scripts/equalizer/draw/drawSpectrum.js deleted file mode 100644 index 0d3282a..0000000 --- a/scripts/equalizer/draw/drawSpectrum.js +++ /dev/null @@ -1,156 +0,0 @@ -const spectrumCanvas = document.getElementById("spectrum-canvas"); -const spectrumCtx = spectrumCanvas.getContext("2d"); -spectrumCtx.imageSmoothingEnabled = true; -spectrumCtx.imageSmoothingQuality = "high"; - -let meta = { - sampleRate: 48000, - fftSize: 2048, - minDb: -100, - maxDb: -30, - frequencyBinCount: 2048, -}; -let g_lastBuffer = null; -let g_rafScheduled = false; - -function scheduleDraw() { - if (g_rafScheduled) return; - g_rafScheduled = true; - requestAnimationFrame(() => { - g_rafScheduled = false; - if (g_lastBuffer) drawSpectrum(g_lastBuffer); - }); -} - -function getSpectrumValues(buffer) { - if (!buffer) return []; - if (typeof buffer.length === "number") return buffer; - - return Object.keys(buffer) - .filter((key) => !Number.isNaN(Number(key))) - .sort((a, b) => Number(a) - Number(b)) - .map((key) => buffer[key]); -} - -function dbToSpectrumY(db, height) { - const minDb = Number.isFinite(meta.minDb) ? meta.minDb : -100; - const maxDb = Number.isFinite(meta.maxDb) ? meta.maxDb : -30; - const range = maxDb - minDb || 1; - const normalized = (Math.max(minDb, Math.min(maxDb, db)) - minDb) / range; - return height - normalized * height; -} - -function syncSpectrumCanvasSize() { - const width = spectrumCanvas.clientWidth; - const height = spectrumCanvas.clientHeight; - - if (spectrumCanvas.width !== width) spectrumCanvas.width = width; - if (spectrumCanvas.height !== height) spectrumCanvas.height = height; -} - -function spectrumBinToFrequency(index, binCount) { - const sampleRate = meta.sampleRate || 48000; - const nyquist = sampleRate / 2; - - return (index * nyquist) / binCount; -} - -function drawSpectrum(buffer) { - syncSpectrumCanvasSize(); - - const values = getSpectrumValues(buffer); - const width = spectrumCanvas.width; - const height = spectrumCanvas.height; - - spectrumCtx.clearRect(0, 0, width, height); - if (!values.length || width <= 0 || height <= 0) return; - - const sampleRate = meta.sampleRate || 48000; - const maxFrequency = Math.min(24000, sampleRate / 2); - const lineColor = g_accentMid; - const fillStart = g_accentStart; - const fillEnd = g_accentEnd; - - const fill = spectrumCtx.createLinearGradient(0, 0, 0, height); - fill.addColorStop(0, fillStart); - fill.addColorStop(1, fillEnd); - - spectrumCtx.beginPath(); - spectrumCtx.moveTo(0, height); - - let hasPoint = false; - for (let i = 0; i < values.length; i++) { - const frequency = spectrumBinToFrequency(i, values.length); - if (frequency > maxFrequency) break; - - const db = values[i]; - if (!Number.isFinite(db)) continue; - - const x = frequencyToX(frequency, width - 10); - const y = dbToSpectrumY(db, height); - - if (!hasPoint) { - spectrumCtx.lineTo(x, y); - hasPoint = true; - } else { - spectrumCtx.lineTo(x, y); - } - } - - if (!hasPoint) return; - - spectrumCtx.lineTo(width, height); - spectrumCtx.closePath(); - spectrumCtx.fillStyle = fill; - spectrumCtx.globalAlpha = 0.18; - spectrumCtx.fill(); - spectrumCtx.globalAlpha = 1; - - spectrumCtx.beginPath(); - hasPoint = false; - for (let i = 0; i < values.length; i++) { - const frequency = spectrumBinToFrequency(i, values.length); - if (frequency > maxFrequency) break; - - const db = values[i]; - if (!Number.isFinite(db)) continue; - - const x = frequencyToX(frequency, width - 10); - const y = dbToSpectrumY(db, height); - - if (!hasPoint) { - spectrumCtx.moveTo(x, y); - hasPoint = true; - } else { - spectrumCtx.lineTo(x, y); - } - } - - spectrumCtx.strokeStyle = lineColor; - spectrumCtx.lineWidth = 1.5; - spectrumCtx.stroke(); -} - -chrome.storage.onChanged.addListener(async (ps) => { - const tabId = await getCurrentTabId(); - if (tabId == null || tabId == undefined) return; - if (ps["spectrum." + tabId]) { - const msg = ps["spectrum." + tabId].newValue; - - if (!msg || !msg.type) return; - - if (msg.type === "meta") { - meta.sampleRate = msg.sampleRate; - meta.fftSize = msg.fftSize; - meta.minDb = msg.minDb; - meta.maxDb = msg.maxDb; - meta.frequencyBinCount = msg.frequencyBinCount; - return; - } - if (msg.type === "spectrum" && msg.buffer) { - g_lastBuffer = msg.buffer; - scheduleDraw(); - return; - } - } -}); diff --git a/scripts/equalizer/equalizerGestures.js b/scripts/equalizer/equalizerGestures.js deleted file mode 100644 index 8b9b282..0000000 --- a/scripts/equalizer/equalizerGestures.js +++ /dev/null @@ -1,72 +0,0 @@ -canvas.addEventListener("mousedown", (e) => { - const rect = canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const dragTarget = getPointIndexAtPosition(mx, my); - if (!dragTarget) return; - - g_dragIndex = dragTarget; - g_dragMode = e.shiftKey ? "q" : "point"; - g_qDragStartY = my; - g_qDragStartValue = ensureQFactor(getDraggedPoint().q); - updateInfoTooltip(getDraggedPoint()); -}); - -window.addEventListener("mouseup", () => { - g_dragIndex = null; - g_dragMode = null; - hideinfoTooltip(); -}); - -canvas.addEventListener("mousemove", async (e) => { - if (g_dragIndex !== null) { - const rect = canvas.getBoundingClientRect(); - let mx = e.clientX - rect.left; - let my = e.clientY - rect.top; - const currentPoint = getDraggedPoint(); - if (!currentPoint) return; - - if (g_dragMode === "q") { - const dy = g_qDragStartY - my; - const nextQ = g_qDragStartValue * Math.pow(2, dy / 40); - const nextPoint = { ...currentPoint, q: ensureQFactor(nextQ) }; - setDraggedPoint(nextPoint); - updateInfoTooltip(nextPoint); - mainResize(); - refreshToolkitCaptureFilters(); - await saveCurrentFilters(); - } else if (mx > 0) { - mx = Math.max(0, Math.min(canvas.width, mx)); - my = Math.max(0, Math.min(canvas.height, my)); - const y = g_dragIndex.type === "peaking" ? my : canvas.height / 2; - const nextPoint = { ...currentPoint, x: mx, y: y }; - setDraggedPoint(nextPoint); - updateInfoTooltip(nextPoint); - mainResize(); - refreshToolkitCaptureFilters(); - await saveCurrentFilters(); - } - } -}); - -canvas.addEventListener("dblclick", (e) => { - const rect = canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const pointTarget = getPointIndexAtPosition(mx, my); - if (!pointTarget) return; - - if (pointTarget.type === "highpass") { - g_highpassPoint = createDefaultHighpassPoint(); - } else if (pointTarget.type === "lowpass") { - g_lowpassPoint = createDefaultLowpassPoint(); - } else { - points[pointTarget.index] = createPeakingFilterPoint( - pointTarget.index, - points.length - ); - } - mainResize(); - refreshToolkitCaptureFilters(); - saveCurrentFilters(); -}); diff --git a/scripts/equalizer/equalizerMath.js b/scripts/equalizer/equalizerMath.js deleted file mode 100644 index 87b626d..0000000 --- a/scripts/equalizer/equalizerMath.js +++ /dev/null @@ -1,44 +0,0 @@ -function clampPointCount(value) { - return Math.max(MIN_POINT_COUNT, Math.min(MAX_POINT_COUNT, value)); -} - -function ensureQFactor(value) { - const q = Number(value); - if (!Number.isFinite(q)) return DEFAULT_FILTER_Q; - return Math.max(MIN_FILTER_Q, Math.min(MAX_FILTER_Q, q)); -} - -function xToFrequency(x, canvasWidth = null) { - canvasWidth ??= canvas.width - 10; - const freq = Math.pow( - 10, - Math.sqrt(x / canvasWidth) * (logMax - logMin) + logMin - ); - return freq > 24000 ? 24000 : freq; -} - -function frequencyToX(freq, canvasWidth = null) { - if (freq == 0) return 0; - - freq = freq > 24000 ? 24000 : freq; - canvasWidth ??= canvas.width - 10; - const x = - Math.pow((Math.log10(freq) - logMin) / (logMax - logMin), 2) * canvasWidth; - return x; -} - -function yToDb(y, canvasHeight = null) { - canvasHeight ??= canvas.height; - return ((canvasHeight / 2 - y) / (canvasHeight / 2 - 20)) * 25; -} - -function dbToGain(db) { - let gain; - if (db >= 0) { - gain = 1 + db / 3; - } else { - gain = 1.5 / Math.abs(db); - } - - return gain; -} diff --git a/scripts/equalizer/equalizerPoints.js b/scripts/equalizer/equalizerPoints.js deleted file mode 100644 index 6b57e8b..0000000 --- a/scripts/equalizer/equalizerPoints.js +++ /dev/null @@ -1,173 +0,0 @@ -let points = []; -let g_highpassPoint = null; -let g_lowpassPoint = null; -const pointRadius = 3; - -function savePointCount(count) { - return chrome.storage.local.set({ g_pointCount: clampPointCount(count) }); -} - -function createPeakingFilterPoint(index, count = g_pointCount) { - const actualCount = clampPointCount(count); - const centerY = canvas.height / 2; - const pointStep = canvas.width / (actualCount + 1); - return { x: pointStep * (index + 1), y: centerY, q: DEFAULT_FILTER_Q }; -} - -function createCrossoverPoint(freq, filter = {}) { - return { - x: Number.isFinite(Number(filter.x)) - ? Number(filter.x) - : frequencyToX(Number(filter.freq ?? freq)), - y: canvas.height / 2, - q: ensureQFactor(filter.q), - }; -} - -function createDefaultHighpassPoint(filter = {}) { - return createCrossoverPoint(DEFAULT_HIGHPASS_FREQ, filter); -} - -function createDefaultLowpassPoint(filter = {}) { - return createCrossoverPoint(DEFAULT_LOWPASS_FREQ, filter); -} - -function createPeakingFilterPoints(count = g_pointCount) { - const actualCount = clampPointCount(count); - return Array.from({ length: actualCount }, (_, i) => { - return createPeakingFilterPoint(i, actualCount); - }); -} - -function initPoints(count = g_pointCount) { - g_pointCount = clampPointCount(count); - points = createPeakingFilterPoints(g_pointCount); - g_highpassPoint = createDefaultHighpassPoint(); - g_lowpassPoint = createDefaultLowpassPoint(); -} - -function setPoints(filters) { - const peakingFilters = filters.filter((filter) => { - return (filter.type ?? "peaking") === "peaking"; - }); - const highpassFilter = filters.find((filter) => filter.type === "highpass"); - const lowpassFilter = filters.find((filter) => filter.type === "lowpass"); - - g_pointCount = clampPointCount(peakingFilters.length); - savePointCount(g_pointCount); - updatePointCountSelect(g_pointCount); - g_highpassPoint = createDefaultHighpassPoint(highpassFilter); - g_lowpassPoint = createDefaultLowpassPoint(lowpassFilter); - points = peakingFilters.map((filter, index) => { - const centeredPoint = createPeakingFilterPoint(index, g_pointCount); - const freq = filter.freq; - const gain = filter.gain; - const x = Number.isFinite(Number(filter.x)) - ? Number(filter.x) - : Number.isFinite(Number(freq)) - ? frequencyToX(Number(freq)) - : centeredPoint.x; - const y = Number.isFinite(Number(filter.y)) - ? Number(filter.y) - : Number.isFinite(Number(gain)) - ? canvas.height / 2 - (Number(gain) / 25) * (canvas.height / 2 - 20) - : centeredPoint.y; - - return { x: x, y: y, q: ensureQFactor(filter.q) }; - }); -} - -function hasCrossoverFilters(filters) { - return ( - filters.some((filter) => filter.type === "highpass") && - filters.some((filter) => filter.type === "lowpass") - ); -} - -function pointsToFilters(points) { - const filters = []; - - if (g_highpassPoint) { - filters.push({ - type: "highpass", - freq: xToFrequency(g_highpassPoint.x), - gain: 0, - q: ensureQFactor(g_highpassPoint.q), - x: g_highpassPoint.x, - y: g_highpassPoint.y, - }); - } - - points.forEach((p) => { - filters.push({ - type: "peaking", - freq: xToFrequency(p.x), - gain: yToDb(p.y), - q: ensureQFactor(p.q), - x: p.x, - y: p.y, - }); - }); - - if (g_lowpassPoint) { - filters.push({ - type: "lowpass", - freq: xToFrequency(g_lowpassPoint.x), - gain: 0, - q: ensureQFactor(g_lowpassPoint.q), - x: g_lowpassPoint.x, - y: g_lowpassPoint.y, - }); - } - - return filters; -} - -function getDraggedPoint() { - if (!g_dragIndex) return null; - if (g_dragIndex.type === "highpass") return g_highpassPoint; - if (g_dragIndex.type === "lowpass") return g_lowpassPoint; - return points[g_dragIndex.index]; -} - -function setDraggedPoint(point) { - if (!g_dragIndex) return; - if (g_dragIndex.type === "highpass") { - g_highpassPoint = point; - return; - } - if (g_dragIndex.type === "lowpass") { - g_lowpassPoint = point; - return; - } - points[g_dragIndex.index] = point; -} - -function getPointIndexAtPosition(x, y) { - const peakingIndex = points.findIndex((p) => { - return Math.hypot(p.x - x, p.y - y) < pointRadius + 2; - }); - - if (peakingIndex !== -1) { - return { - type: "peaking", - index: peakingIndex, - }; - } - - if ( - g_highpassPoint && - Math.hypot(g_highpassPoint.x - x, g_highpassPoint.y - y) < pointRadius + 2 - ) { - return { type: "highpass" }; - } - - if ( - g_lowpassPoint && - Math.hypot(g_lowpassPoint.x - x, g_lowpassPoint.y - y) < pointRadius + 2 - ) { - return { type: "lowpass" }; - } - - return null; -} diff --git a/scripts/equalizer/equalizerTooltips.js b/scripts/equalizer/equalizerTooltips.js deleted file mode 100644 index 183de94..0000000 --- a/scripts/equalizer/equalizerTooltips.js +++ /dev/null @@ -1,30 +0,0 @@ -function hideinfoTooltip() { - if (!infoTooltip) return; - infoTooltip.style.display = "none"; -} - -function getPointTooltipText(point) { - if (g_dragMode === "q") return `Q ${ensureQFactor(point.q).toFixed(2)}`; - return `${Math.round(xToFrequency(point.x))} Hz`; -} - -function updateInfoTooltip(point) { - if (!infoTooltip || !point) return; - - infoTooltip.textContent = getPointTooltipText(point); - infoTooltip.style.display = "block"; - - const tooltipWidth = infoTooltip.offsetWidth; - const tooltipHeight = infoTooltip.offsetHeight; - const margin = 6; - const offset = 12; - const maxLeft = canvas.clientWidth - tooltipWidth - margin; - const maxTop = canvas.clientHeight - tooltipHeight - margin; - const left = Math.max(margin, Math.min(maxLeft, point.x + offset)); - const top = Math.max( - margin, - Math.min(maxTop, point.y - tooltipHeight - offset) - ); - - infoTooltip.style.transform = `translate(${left}px, ${top}px)`; -} diff --git a/scripts/events.js b/scripts/events.js deleted file mode 100644 index 3cb0ca2..0000000 --- a/scripts/events.js +++ /dev/null @@ -1,185 +0,0 @@ -const port = document.createElement("span"); -port.id = "eq-tools-port"; -document.documentElement.append(port); - -const peakingFreqs = [5, 30, 180, 800, 5000]; -let g_currentTabId = null; -let g_shortcuts = resolveShortcuts(null); - -const withTabId = (callback) => { - if (g_currentTabId !== null) { - callback(g_currentTabId); - return; - } - - chrome.runtime.sendMessage({ method: "getTabId" }, (tabId) => { - g_currentTabId = tabId; - callback(tabId); - }); -}; - -const setCaptureError = (message) => - withTabId((tabId) => { - chrome.storage.local.set({ - ["captureError." + tabId]: message, - }); - }); - -const clearCaptureError = () => - withTabId((tabId) => { - chrome.storage.local.remove("captureError." + tabId); - }); - -port.addEventListener("connected", () => { - clearCaptureError(); - chrome.runtime.sendMessage({ - method: "connected", - }); -}); -port.addEventListener("disconnected", () => - chrome.runtime.sendMessage({ - method: "disconnected", - }) -); -port.addEventListener("capture-error", (e) => { - const message = e?.detail?.message ?? "Audio capture failed"; - setCaptureError(message); -}); - -chrome.runtime.sendMessage({ method: "getTabId" }, (tabId) => { - g_currentTabId = tabId; - const defaultFilters = [ - { freq: 20, gain: 0, q: 0.5, type: "highpass" }, - ...peakingFreqs.map((freq) => { - return { freq: freq, gain: 0, q: 0.5, type: "peaking" }; - }), - { freq: 20000, gain: 0, q: 0.5, type: "lowpass" }, - ]; - chrome.storage.local.get( - { - ["volume." + tabId]: 1, - ["pan." + tabId]: 0, - ["filters." + tabId]: defaultFilters, - enableSpectrum: false, - ["enabled." + tabId]: false, - ["mute." + tabId]: false, - }, - (prefs) => { - const filters = prefs["filters." + tabId] ?? defaultFilters; - const freqsMapped = filters.map((filter) => { - return { - freq: filter.freq, - gain: filter.gain, - q: filter.q ?? 0.5, - type: filter.type ?? "peaking", - }; - }); - port.dataset.freqs = JSON.stringify(freqsMapped); - port.dataset.pan = prefs["pan." + tabId]; - port.dataset.preamp = prefs["volume." + tabId]; - port.dataset.enabled = prefs["enabled." + tabId]; - port.dataset.mute = prefs["mute." + tabId]; - port.dataset.enableSpectrum = prefs.enableSpectrum; - - if (prefs["mute." + tabId]) { - port.dispatchEvent(new Event("mute-enabled")); - } - if (prefs["enabled." + tabId]) { - port.dispatchEvent(new Event("enabled-changed")); - } - } - ); -}); - -chrome.storage.onChanged.addListener((ps) => { - if (ps["shortcuts"]) { - g_shortcuts = resolveShortcuts(ps["shortcuts"].newValue); - } - - withTabId((tabId) => { - if (ps["filters." + tabId]) { - var newF = ps["filters." + tabId].newValue.map((filter, i) => { - return { - freq: filter.freq, - gain: filter.gain, - q: filter.q ?? 0.5, - type: filter.type ?? "peaking", - }; - }); - port.dataset.freqs = JSON.stringify(newF); - port.dispatchEvent(new Event("filters-changed")); - } - - if (ps["volume." + tabId]) { - port.dataset.preamp = ps["volume." + tabId].newValue; - if (port.dataset.mute != "true") - port.dispatchEvent(new Event("preamp-changed")); - } - if (ps["enabled." + tabId]) { - port.dataset.enabled = ps["enabled." + tabId].newValue; - port.dispatchEvent(new Event("enabled-changed")); - } - if (ps["mute." + tabId]) { - port.dataset.mute = ps["mute." + tabId].newValue; - if (ps["mute." + tabId].newValue) - port.dispatchEvent(new Event("mute-enabled")); - else port.dispatchEvent(new Event("mute-disabled")); - } - }); -}); - -chrome.storage.local.get(["shortcuts"], (prefs) => { - g_shortcuts = resolveShortcuts(prefs["shortcuts"]); -}); - -function toggleTabStorageValue(tabId, key, options = {}) { - chrome.storage.local.get([key]).then((prefs) => { - const values = { - [key]: !prefs[key], - }; - if (options.enableTab) values["enabled." + tabId] = true; - chrome.storage.local.set(values); - }); -} - -document.addEventListener( - "keydown", - (event) => { - if (event.repeat || isEditableShortcutTarget(event.target)) return; - - if (matchesShortcut(event, g_shortcuts[SHORTCUT_ACTION_MUTE_NAME])) { - event.preventDefault(); - event.stopPropagation(); - withTabId((tabId) => { - toggleTabStorageValue(tabId, "mute." + tabId, { enableTab: true }); - }); - return; - } - - if (matchesShortcut(event, g_shortcuts[SHORTCUT_ACTION_TOGGLE_EQ_NAME])) { - event.preventDefault(); - event.stopPropagation(); - withTabId((tabId) => { - toggleTabStorageValue(tabId, "enabled." + tabId); - }); - } - }, - true -); - -port.addEventListener("spectrum-frame", (e) => { - chrome.runtime.sendMessage({ method: "spectrum-frame", payload: e.detail }); -}); - -self.start = () => { - if (window.top !== window) return; - - chrome.runtime.sendMessage({ method: "getTabId" }, async (tabId) => { - chrome.runtime.sendMessage({ method: "pageStarted" }); - }); - - setTimeout(async () => { - chrome.runtime.sendMessage({ method: "clearStorage" }); - }, 1000); -}; -self.start(); diff --git a/scripts/filter.js b/scripts/filter.js deleted file mode 100644 index 7c65e99..0000000 --- a/scripts/filter.js +++ /dev/null @@ -1,371 +0,0 @@ -const port = document.getElementById("eq-tools-port"); -port.remove(); - -{ - const map = new Map(); - let currentAudioCtx = null; - let currentSourceNode = null; - let analyser = null; - let spectrumTimer = null; - const mediaSources = new WeakMap(); - const pendingMedia = new WeakSet(); - - const { connect } = AudioNode.prototype; - - const getBiquadFilterCount = (filters) => { - let count = 0; - while (filters[count]) count++; - return count; - }; - - const applyBiquadSettings = (biquadFilter, filter) => { - biquadFilter.type = filter.type ?? "peaking"; - biquadFilter.gain.value = Number(filter.gain ?? 0); - biquadFilter.frequency.value = Number(filter.freq); - biquadFilter.Q.value = Number(filter.q ?? 0.5); - }; - - const createBiquadFilter = (context, filter) => { - const biquadFilter = context.createBiquadFilter(); - applyBiquadSettings(biquadFilter, filter); - return biquadFilter; - }; - - const getLastBiquadFilter = (filters) => { - const count = getBiquadFilterCount(filters); - return count > 0 ? filters[count - 1] : filters.balance; - }; - - const rebuildBiquadChain = (source, filters, filterSettings) => { - filters.balance.disconnect(); - - const oldCount = getBiquadFilterCount(filters); - for (let i = 0; i < oldCount; i++) { - filters[i].disconnect(); - delete filters[i]; - } - - let previousNode = filters.balance; - filterSettings.forEach((filter, i) => { - const biquadFilter = createBiquadFilter(source.context, filter); - previousNode.connect(biquadFilter); - filters[i] = biquadFilter; - previousNode = biquadFilter; - }); - - connect.call(previousNode, source.context.destination); - }; - - const attach = (source) => { - const { context } = source; - - if (port.dataset.enabled === "false") { - return connect.call(source, context.destination); - } - - if (map.has(source)) { - port.dispatchEvent(new Event("connected")); - return context.destination; - } - - const filters = { - preamp: context.createGain(), - balance: context.createStereoPanner(), - }; - const preampValue = isNaN(port.dataset.preamp) - ? 1 - : Number(port.dataset.preamp); - filters.preamp.gain.value = port.dataset.mute === "true" ? 0 : preampValue; - source.connect(filters.preamp); - filters.balance.pan.value = 0; - filters.preamp.connect(filters.balance); - map.set(source, filters); - - var newF = JSON.parse(port.dataset.freqs); - rebuildBiquadChain(source, filters, newF); - - if (port.dataset.enableSpectrum === "true") { - currentSourceNode = filters[newF.length - 1]; - startSpectrum(); - } - - port.dispatchEvent(new Event("connected")); - return context.destination; - }; - - const source = (target) => - new Promise((resolve, reject) => { - const existing = mediaSources.get(target); - if (existing) { - setCurrentAudioGraph(existing.context, existing.source); - resolve(existing.source); - return; - } - - const context = new AudioContext(); - - const next = () => { - try { - const source = context.createMediaElementSource(target); - mediaSources.set(target, { context, source }); - setCurrentAudioGraph(context, source); - resolve(source); - } catch (e) { - reject(e); - } - }; - - setTimeout(() => { - try { - target.setAttribute("crossOrigin", "anonymous"); - // make sure we do not have CORS issue - target.captureStream(); - // get the source - next(); - } catch (e) { - if (e?.message?.includes("cross-origin")) { - reject(e); - } else { - next(); - } - } - }); - }); - - const detach = () => { - map.forEach((filters, source) => { - source.disconnect(); - getLastBiquadFilter(filters).disconnect(); - connect.call(source, source.context.destination); - port.dispatchEvent(new Event("disconnected")); - }); - }; - - const reattach = () => { - var newF = JSON.parse(port.dataset.freqs); - map.forEach((filters, source) => { - source.disconnect(); - source.connect(filters.preamp); - if (getBiquadFilterCount(filters) !== newF.length) { - rebuildBiquadChain(source, filters, newF); - } else { - newF.forEach((filter, i) => { - applyBiquadSettings(filters[i], filter); - }); - const lastFilter = getLastBiquadFilter(filters); - lastFilter.disconnect(); - connect.call(lastFilter, source.context.destination); - } - - if (port.dataset.enableSpectrum === "true") { - currentSourceNode = getLastBiquadFilter(filters); - analyser = null; - startSpectrum(); - } - port.dispatchEvent(new Event("connected")); - }); - if (map.size) { - port.dispatchEvent(new Event("connected")); - } - }; - - AudioNode.prototype.connect = new Proxy(AudioNode.prototype.connect, { - apply(target, self, args) { - const [node] = args; - - if (node && node instanceof AudioDestinationNode) { - try { - return attach(self); - } catch (e) { - console.warn("cannot equalize;", e.message); - port.dispatchEvent(new Event("cannot-attach")); - } - } - - return Reflect.apply(target, self, args); - }, - }); - - const convert = async (target) => { - if (!(target instanceof HTMLMediaElement)) return; - - if (port.dataset.enabled === "false") { - convert.caches.add(target); - return; - } - - if (pendingMedia.has(target)) return; - - try { - pendingMedia.add(target); - const sourceNode = await source(target); - if (!map.has(sourceNode)) { - attach(sourceNode); - } else { - port.dispatchEvent(new Event("connected")); - } - } catch (e) { - port.dispatchEvent( - new CustomEvent("capture-error", { - detail: { message: e?.message ?? "Unknown error" }, - }) - ); - } finally { - pendingMedia.delete(target); - } - }; - convert.caches = new Set(); - - window.addEventListener("playing", (e) => convert(e.target), true); - - self.Audio = new Proxy(self.Audio, { - construct(target, args, newTarget) { - const r = Reflect.construct(target, args, newTarget); - try { - convert(r); - } catch (e) { - console.error(e); - } - return r; - }, - }); - - HTMLMediaElement.prototype.play = new Proxy(HTMLMediaElement.prototype.play, { - apply(target, self, args) { - if (self.isConnected === false) { - try { - convert(self); - } catch (e) { - console.error(e); - } - } - - return Reflect.apply(target, self, args); - }, - }); - - port.addEventListener("filters-changed", () => - map.forEach((filters, source) => { - var newF = JSON.parse(port.dataset.freqs); - if (getBiquadFilterCount(filters) !== newF.length) { - rebuildBiquadChain(source, filters, newF); - if (port.dataset.enableSpectrum === "true") { - currentSourceNode = filters[newF.length - 1]; - analyser = null; - startSpectrum(); - } - port.dispatchEvent(new Event("connected")); - return; - } - - newF.forEach((filter, i) => { - applyBiquadSettings(filters[i], filter); - }); - }) - ); - port.addEventListener("preamp-changed", () => - map.forEach((filters) => { - filters.preamp.gain.value = Number(port.dataset.preamp); - }) - ); - port.addEventListener("mute-enabled", () => - map.forEach((filters) => { - filters.preamp.gain.value = Number(0); - }) - ); - port.addEventListener("mute-disabled", () => - map.forEach((filters) => { - filters.preamp.gain.value = Number(port.dataset.preamp); - }) - ); - port.addEventListener("enabled-changed", () => { - if (port.dataset.enabled === "false") { - detach(); - } else { - reattach(); - - if (convert.caches.size) { - for (const target of convert.caches) { - convert(target); - } - convert.caches.clear(); - } - } - }); - - function ensureAnalyser(audioCtx, sourceNode) { - if (!audioCtx || !sourceNode) return null; - if (analyser) return analyser; - - analyser = audioCtx.createAnalyser(); - analyser.sampleRate = 48000; - analyser.fftSize = 2048; - analyser.smoothingTimeConstant = 0.5; - - try { - sourceNode.connect(analyser); - } catch (e) {} - return analyser; - } - - function setCurrentAudioGraph(audioCtx, sourceNode) { - currentAudioCtx = audioCtx; - currentSourceNode = sourceNode; - analyser = null; - } - - function startSpectrum() { - { - const ac = currentAudioCtx; - const src = currentSourceNode; - const an = ensureAnalyser(ac, src); - - if (!ac || !src || !an) { - return; - } - - let payload = { - type: "meta", - sampleRate: ac.sampleRate, - fftSize: an.fftSize, - minDb: an.minDecibels, - maxDb: an.maxDecibels, - frequencyBinCount: an.frequencyBinCount, - }; - port.dispatchEvent( - new CustomEvent("spectrum-frame", { - detail: { ...payload }, - bubbles: true, - composed: true, - }) - ); - - if (spectrumTimer) clearInterval(spectrumTimer); - spectrumTimer = setInterval(() => { - if (!an) return; - const n = an.frequencyBinCount; - const f32 = new Float32Array(n); - an.getFloatFrequencyData(f32); - - payload = { - type: "spectrum", - buffer: f32, - }; - port.dispatchEvent( - new CustomEvent("spectrum-frame", { - detail: { ...payload }, - bubbles: true, - composed: true, - }) - ); - }, 50); - } - } - - function stopSpectrum() { - if (spectrumTimer) { - clearInterval(spectrumTimer); - spectrumTimer = null; - } - } -} diff --git a/scripts/installUpdateModal.js b/scripts/installUpdateModal.js deleted file mode 100644 index 4ac2095..0000000 --- a/scripts/installUpdateModal.js +++ /dev/null @@ -1,33 +0,0 @@ -function getPendingInstallUpdateNotice(stored) { - const notice = stored[INSTALL_UPDATE_NOTICE_KEY]; - if (isToolkitWindow) return null; - if (!notice || typeof notice !== "object") return null; - if (!["install", "update"].includes(notice.reason)) return null; - if (notice.version !== chrome.runtime.getManifest().version) return null; - return notice; -} - -function showInstallUpdateNotice(stored) { - const notice = getPendingInstallUpdateNotice(stored); - const modal = document.getElementById("install-update-notice-modal"); - if (!notice || !modal) return; - - modal.style.display = "block"; -} - -async function closeInstallUpdateNotice() { - const modal = document.getElementById("install-update-notice-modal"); - if (modal) modal.style.display = "none"; - await chrome.storage.local.remove(INSTALL_UPDATE_NOTICE_KEY); -} - -const installUpdateNoticeCloseBtn = document.getElementById( - "install-update-notice-close" -); - -if (installUpdateNoticeCloseBtn) { - installUpdateNoticeCloseBtn.addEventListener( - "click", - closeInstallUpdateNotice - ); -} diff --git a/scripts/languages.js b/scripts/languages.js deleted file mode 100644 index 43de0b4..0000000 --- a/scripts/languages.js +++ /dev/null @@ -1,89 +0,0 @@ -const LANGUAGE_KEY = "uiLanguage"; -const DEFAULT_LANGUAGE = "en"; - -const CHROME_I18N_LOCALES = new Map([ - ["ar", "Arabic"], - ["am", "Amharic"], - ["bg", "Bulgarian"], - ["bn", "Bengali"], - ["ca", "Catalan"], - ["cs", "Czech"], - ["da", "Danish"], - ["de", "German"], - ["el", "Greek"], - ["en", "English"], - ["en_AU", "English (Australia)"], - ["en_GB", "English (Great Britain)"], - ["en_US", "English (USA)"], - ["es", "Spanish"], - ["es_419", "Spanish (Latin America and Caribbean)"], - ["et", "Estonian"], - ["fa", "Persian"], - ["fi", "Finnish"], - ["fil", "Filipino"], - ["fr", "French"], - ["gu", "Gujarati"], - ["he", "Hebrew"], - ["hi", "Hindi"], - ["hr", "Croatian"], - ["hu", "Hungarian"], - ["id", "Indonesian"], - ["it", "Italian"], - ["ja", "Japanese"], - ["kn", "Kannada"], - ["ko", "Korean"], - ["lt", "Lithuanian"], - ["lv", "Latvian"], - ["ml", "Malayalam"], - ["mr", "Marathi"], - ["ms", "Malay"], - ["nl", "Dutch"], - ["no", "Norwegian"], - ["pl", "Polish"], - ["pt_BR", "Portuguese (Brazil)"], - ["pt_PT", "Portuguese (Portugal)"], - ["ro", "Romanian"], - ["ru", "Russian"], - ["sk", "Slovak"], - ["sl", "Slovenian"], - ["sr", "Serbian"], - ["sv", "Swedish"], - ["sw", "Swahili"], - ["ta", "Tamil"], - ["te", "Telugu"], - ["th", "Thai"], - ["tr", "Turkish"], - ["uk", "Ukrainian"], - ["vi", "Vietnamese"], - ["zh_CN", "Chinese (China)"], - ["zh_TW", "Chinese (Taiwan)"], -]); - -const AVAILABLE_LANGUAGE_CODES = [ - "bn", - "de", - "en", - "es", - "fr", - "hi", - "id", - "it", - "ja", - "ko", - "nl", - "pt_PT", - "ru", - "ta", - "th", - "tr", - "vi", - "zh_CN", -]; - -function getBrowserLanguage() { - const locale = chrome.i18n.getMessage("@@ui_locale") || navigator.language; - const exactMatch = AVAILABLE_LANGUAGE_CODES.find((code) => code === locale); - if (exactMatch) return exactMatch; - - return DEFAULT_LANGUAGE; -} diff --git a/scripts/loadColors.js b/scripts/loadColors.js deleted file mode 100644 index 1329511..0000000 --- a/scripts/loadColors.js +++ /dev/null @@ -1,20 +0,0 @@ -let g_accentStart; -let g_accentMid; -let g_accentEnd; -let g_highpassFilterColor; -let g_lowpassFilterColor; -let panelBg; -let axis; - -function loadColors() { - const css = getComputedStyle(document.documentElement); - g_accentStart = css.getPropertyValue("--accent-start").trim(); - g_accentMid = css.getPropertyValue("--accent-mid").trim(); - g_accentEnd = css.getPropertyValue("--accent-end").trim(); - g_highpassFilterColor = css.getPropertyValue("--highpass-filter").trim(); - g_lowpassFilterColor = css.getPropertyValue("--lowpass-filter").trim(); - panelBg = css.getPropertyValue("--panel-bg").trim(); - axis = css.getPropertyValue("--axis").trim(); -} - -loadColors(); diff --git a/scripts/localization.js b/scripts/localization.js deleted file mode 100644 index 101b8bb..0000000 --- a/scripts/localization.js +++ /dev/null @@ -1,161 +0,0 @@ -let g_currentLanguage = DEFAULT_LANGUAGE; -let g_currentMessages = {}; -let g_defaultMessages = {}; - -function getLocalizedMessage(messageName) { - return ( - g_currentMessages[messageName]?.message || - g_defaultMessages[messageName]?.message || - chrome.i18n.getMessage(messageName) || - messageName - ); -} - -const setTextContent = (id, messageName) => { - const element = document.getElementById(id); - if (element) element.textContent = getLocalizedMessage(messageName); -}; - -const setPlaceholder = (id, messageName) => { - const element = document.getElementById(id); - if (element) { - element.setAttribute("placeholder", getLocalizedMessage(messageName)); - } -}; - -const setTooltip = (id, messageName) => { - const element = document.getElementById(id); - if (!element) return; - - const message = getLocalizedMessage(messageName); - element.setAttribute("title", message); - element.setAttribute("alt", message); - element.setAttribute("aria-label", message); -}; - -const setFirstTextNodeContent = (id, messageName) => { - const element = document.getElementById(id); - if (!element) return; - - const textNode = Array.from(element.childNodes).find( - (node) => node.nodeType === Node.TEXT_NODE - ); - if (textNode) textNode.textContent = getLocalizedMessage(messageName); -}; - -async function loadLocaleMessages(locale) { - const response = await fetch( - chrome.runtime.getURL(`_locales/${locale}/messages.json`) - ); - if (!response.ok) return {}; - return response.json(); -} - -function populateLanguageSelect() { - const select = document.getElementById("language-select"); - if (!select) return; - - select.textContent = ""; - AVAILABLE_LANGUAGE_CODES.forEach((code) => { - const option = document.createElement("option"); - option.value = code; - option.textContent = CHROME_I18N_LOCALES.get(code) ?? code; - select.appendChild(option); - }); - select.value = g_currentLanguage; -} - -async function refreshLocalizedDynamicContent() { - await refreshAutostartSettingsPresetValueSelects(); - await renderWhitelist(); - const tabId = await getCurrentTabId(); - const result = await chrome.storage.local.get(["enabled." + tabId]); - setEnableBtnText(result["enabled." + tabId] ?? false); -} - -function applyLocalization() { - setTextContent("master-volume-label", "gain_slider_label"); - setTextContent("reset", "reset_button_label"); - setTextContent("change-eq", "enable_eq_button_label"); - setTextContent("presets-toggle", "empty_preset_name"); - setTextContent("none-item", "empty_preset_name"); - setTextContent("save-preset", "save_preset_button_label"); - setTextContent("import-presets", "import_presets_button_label"); - setTextContent("export-presets", "export_presets_button_label"); - setTextContent("enable-spectrum-label", "enable_spectrum_setting_option"); - setTextContent("settings-header", "settings_title"); - setTextContent("points-count-label", "points_count_setting_option"); - setTextContent("language-label", "language_setting_option"); - setTextContent("points-reset-title", "points_reset_title"); - setTextContent("points-reset-message", "points_reset_message"); - setTextContent("skip-reset-label", "points_reset_skip"); - setTextContent("points-reset-cancel", "cancel"); - setTextContent("points-reset-confirm", "ok"); - setTextContent("support-me", "support_me"); - setTextContent("theme-label", "theme_setting_option"); - setTextContent("shortcuts-settings-title", "shortcuts_settings_title"); - setTextContent("shortcut-mute-label", "shortcut_mute_label"); - setTextContent("shortcut-toggle-eq-label", "shortcut_toggle_eq_label"); - setTextContent("presets-settings-title", "presets_settings_title"); - setTextContent("community-settings-title", "community_settings_title"); - setFirstTextNodeContent( - "help-with-translation-label", - "help_with_translation_label" - ); - setFirstTextNodeContent("translators-label", "translators_label"); - setFirstTextNodeContent("source-code-label", "source_code_label"); - setTextContent("autostart-settings-title", "autostart_settings_title"); - setTextContent( - "autostart-settings-type-domain", - "autostart_rule_type_domain_label" - ); - setTextContent( - "autostart-settings-type-url", - "autostart_rule_type_url_label" - ); - setTextContent("autostart-settings-add-btn", "add"); - setTextContent("autostart-modal-title", "autostart_modal_title"); - setTextContent( - "autostart-modal-domain-label", - "autostart_rule_type_domain_label" - ); - setTextContent("autostart-modal-url-label", "autostart_rule_type_url_label"); - setTextContent( - "autostart-modal-preset-label", - "autostart_modal_preset_label" - ); - setTextContent("autostart-modal-cancel", "cancel"); - setTextContent("autostart-modal-confirm", "add"); - setTextContent("whitelist-empty", "autostart_whitelist_empty"); - - setPlaceholder("preset-name", "enter_preset_name"); - - setTooltip("settings-btn", "settings_button_tooltip"); - setTooltip("volume-mute", "volume_mute_button_tooltip"); - setTooltip("add-to-autostart-whitelist-btn", "add_to_autostart_tooltip"); - setTooltip("window-mod", "window_mode_button_tooltip"); -} - -async function setLanguage(language, options = {}) { - g_currentLanguage = language; - g_currentMessages = await loadLocaleMessages(g_currentLanguage); - document.documentElement.lang = g_currentLanguage.replace("_", "-"); - - applyLocalization(); - - populateLanguageSelect(); - - if (options.save) { - await chrome.storage.local.set({ [LANGUAGE_KEY]: g_currentLanguage }); - await refreshLocalizedDynamicContent(); - } -} - -async function initLocalization() { - g_defaultMessages = await loadLocaleMessages(DEFAULT_LANGUAGE); - const stored = await chrome.storage.local.get([LANGUAGE_KEY]); - const language = stored[LANGUAGE_KEY] ?? getBrowserLanguage(); - await setLanguage(language); -} - -const g_localizationReady = initLocalization(); diff --git a/scripts/popup.js b/scripts/popup.js deleted file mode 100644 index 52fc6d7..0000000 --- a/scripts/popup.js +++ /dev/null @@ -1,179 +0,0 @@ -function mainResize() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - resizeCanvas(); - drawFilter(); -} - -async function saveCurrentFilters(options = {}) { - const enableCurrentTab = options.enableCurrentTab ?? true; - const tabId = await getCurrentTabId(); - if (tabId == null || tabId == undefined) return; - - const newFilters = pointsToFilters(points); - const values = { - ["filters." + tabId]: newFilters, - ["filters"]: newFilters, - }; - if (!isToolkitWindow && enableCurrentTab) values["enabled." + tabId] = true; - return chrome.storage.local.set(values); -} - -async function getCurrentTabId() { - if (isToolkitWindow) { - if (g_toolkitActiveTabId != null) return g_toolkitActiveTabId; - - const stored = await chrome.storage.session.get( - TOOLKIT_WINDOW_ACTIVE_TAB_KEY - ); - g_toolkitActiveTabId = stored[TOOLKIT_WINDOW_ACTIVE_TAB_KEY] ?? null; - return g_toolkitActiveTabId; - } - - const queryOptions = { active: true, lastFocusedWindow: true }; - const [tab] = await chrome.tabs.query(queryOptions); - return tab?.id; -} - -function renderCaptureError(message) { - if (!captureErrorElem) return; - if (!message) { - captureErrorElem.style.display = "none"; - captureErrorElem.textContent = ""; - return; - } - - console.log(message); - captureErrorElem.textContent = getLocalizedMessage("capture_error_prefix"); - captureErrorElem.style.display = "block"; -} - -async function mainLoad() { - await g_localizationReady; - - const stored = await chrome.storage.local.get([ - POINT_COUNT_KEY, - THEME_KEY, - SKIP_POINTS_CONFIRM_KEY, - INSTALL_UPDATE_NOTICE_KEY, - ]); - g_currentTheme = stored[THEME_KEY] ?? DEFAULT_THEME; - applyTheme(g_currentTheme); - - if (await shouldShowToolkitWindowNotice()) { - showToolkitWindowNotice(); - return; - } - - ctx.clearRect(0, 0, canvas.width, canvas.height); - resizeCanvas(); - const savedPointCount = parseInt(stored.g_pointCount, 10); - if (!Number.isNaN(savedPointCount)) { - g_pointCount = clampPointCount(savedPointCount); - } - g_skipPointsResetConfirm = - stored[SKIP_POINTS_CONFIRM_KEY] === true || - stored[SKIP_POINTS_CONFIRM_KEY] === "true"; - updatePointCountSelect(g_pointCount); - - const id = await getCurrentTabId(); - const result = await chrome.storage.local.get([ - "filters", - "filters." + id, - "gain." + id, - "presets", - "presetNames", - "enabled." + id, - "mute." + id, - "enableSpectrum", - "captureError." + id, - ]); - let loadedFilters = null; - if ( - result["filters." + id] != null && - result["filters." + id] != undefined && - result["filters." + id].length != 0 - ) { - loadedFilters = result["filters." + id]; - setPoints(loadedFilters); - } else if ( - result["filters"] != null && - result["filters"] != undefined && - result["filters"].length != 0 - ) { - loadedFilters = result["filters"]; - setPoints(loadedFilters); - } else { - initPoints(); - } - - if (!loadedFilters || !hasCrossoverFilters(loadedFilters)) { - await saveCurrentFilters({ enableCurrentTab: false }); - } - - if (result["gain." + id] != null && result["gain." + id] != undefined) - document.getElementById("master-volume").value = result["gain." + id]; - - drawFilter(); - - setEnableBtnText(result["enabled." + id] ?? false); - setMuteBtnClass(result["mute." + id] ?? false); - - if (result["enableSpectrum"] === true) { - document.getElementById("enable-spectrum").checked = true; - } - - if (result.presetNames) { - result.presetNames.forEach((name) => { - addPresetToDropdown(name); - }); - } - - if (result["captureError." + id]) { - renderCaptureError(result["captureError." + id]); - } - - showInstallUpdateNotice(stored); - - await startToolkitTabCapture(); - await renderCapturedTabs(); -} - -window.addEventListener("resize", mainResize); -window.addEventListener("load", mainLoad); - -chrome.storage.onChanged.addListener(async (ps) => { - if (isToolkitWindow && ps[TOOLKIT_WINDOW_ACTIVE_TAB_KEY]) { - g_toolkitActiveTabId = ps[TOOLKIT_WINDOW_ACTIVE_TAB_KEY].newValue ?? null; - await loadToolkitTabSettings(g_toolkitActiveTabId); - await renderCapturedTabs(); - } - if (isToolkitWindow && ps[TOOLKIT_WINDOW_CAPTURE_STREAM_IDS_KEY]) { - await startToolkitTabCapture(); - await renderCapturedTabs(); - } - - const tabId = await getCurrentTabId(); - if (tabId == null || tabId == undefined) return; - if (ps["enabled." + tabId]) setEnableBtnText(ps["enabled." + tabId].newValue); - if (ps["mute." + tabId]) setMuteBtnClass(ps["mute." + tabId].newValue); - if (ps["captureError." + tabId]) - renderCaptureError(ps["captureError." + tabId].newValue); - - if (isToolkitWindow) { - refreshToolkitCaptureFilters(); - } -}); - -// ******************** -// Info modal for christmas theme -// ******************** -const infoModal = document.getElementById("info-modal"); -const infoBtn = document.getElementById("info-btn"); -const closeInfo = document.getElementById("close-info-modal"); - -infoBtn.onclick = function () { - infoModal.style.display = "block"; -}; -closeInfo.onclick = function () { - infoModal.style.display = "none"; -}; diff --git a/scripts/popupControls.js b/scripts/popupControls.js deleted file mode 100644 index b1088ea..0000000 --- a/scripts/popupControls.js +++ /dev/null @@ -1,115 +0,0 @@ -document.getElementById("change-eq").addEventListener("click", async () => { - if (isToolkitWindow) return; - - const tabId = await getCurrentTabId(); - chrome.storage.local.get(["enabled." + tabId]).then((result) => { - const newFilters = pointsToFilters(points); - const slider = document.getElementById("master-volume"); - chrome.storage.local.set({ - ["enabled." + tabId]: !result["enabled." + tabId], - ["filters." + tabId]: newFilters, - ["gain." + tabId]: slider.value, - }); - }); -}); - -document.getElementById("reset").addEventListener("click", async () => { - document.getElementById("master-volume").value = 0; - initPoints(); - mainResize(); - refreshToolkitCaptureFilters(); - - const tabId = await getCurrentTabId(); - chrome.storage.local.set({ - ["volume." + tabId]: 1, - ["gain." + tabId]: 0, - ["filters." + tabId]: pointsToFilters(points), - }); -}); - -const slider = document.getElementById("master-volume"); -slider.oninput = async () => { - const volume = dbToGain(slider.value); - applyToolkitCaptureSettings(); - const tabId = await getCurrentTabId(); - const values = { - ["volume." + tabId]: volume, - ["gain." + tabId]: slider.value, - }; - if (!isToolkitWindow) values["enabled." + tabId] = true; - chrome.storage.local.set(values); -}; - -document.getElementById("volume-mute").addEventListener("click", async () => { - const tabId = await getCurrentTabId(); - const enableReady = isToolkitWindow - ? Promise.resolve() - : chrome.storage.local.set({ ["enabled." + tabId]: true }); - enableReady.then(() => - chrome.storage.local.get(["mute." + tabId]).then((result) => { - chrome.storage.local.set({ - ["mute." + tabId]: !result["mute." + tabId], - }); - }) - ); -}); - -function setEnableBtnText(enabled) { - if (!enabled) - document.getElementById("change-eq").textContent = getLocalizedMessage( - "enable_eq_button_label" - ); - else - document.getElementById("change-eq").textContent = getLocalizedMessage( - "stop_eq_button_label" - ); -} - -function setMuteBtnClass(newValue) { - const elem = document.getElementById("volume-mute"); - if (newValue) elem.className = "volume-mute-active"; - else elem.className = "volume-mute"; - applyToolkitCaptureSettings(); -} - -document.getElementById("window-mod").addEventListener("click", async () => { - const tabId = await getCurrentTabId(); - chrome.runtime.sendMessage({ method: "enableWindowMode", tabId }); - window.close(); -}); - -async function toggleCurrentTabStorageValue(key, options = {}) { - const tabId = await getCurrentTabId(); - if (tabId == null) return; - - const storageKey = key + "." + tabId; - const values = await chrome.storage.local.get([storageKey]); - const nextValues = { - [storageKey]: !values[storageKey], - }; - if (options.enableTab && !isToolkitWindow) { - nextValues["enabled." + tabId] = true; - } - await chrome.storage.local.set(nextValues); -} - -document.addEventListener("keydown", async (event) => { - if (event.repeat || isEditableShortcutTarget(event.target)) return; - - if (matchesShortcut(event, g_shortcutSettings[SHORTCUT_ACTION_MUTE_NAME])) { - event.preventDefault(); - event.stopPropagation(); - await toggleCurrentTabStorageValue("mute", { enableTab: true }); - return; - } - - if ( - matchesShortcut(event, g_shortcutSettings[SHORTCUT_ACTION_TOGGLE_EQ_NAME]) - ) { - if (isToolkitWindow) return; - - event.preventDefault(); - event.stopPropagation(); - await toggleCurrentTabStorageValue("enabled"); - } -}); diff --git a/scripts/popupPresets.js b/scripts/popupPresets.js deleted file mode 100644 index 3c9f149..0000000 --- a/scripts/popupPresets.js +++ /dev/null @@ -1,105 +0,0 @@ -function addPresetToDropdown(name) { - const option = document.createElement("div"); - option.textContent = name; - option.setAttribute("data-value", name); - option.className = "dropdown-item"; - - const closeBtn = document.createElement("span"); - closeBtn.className = "close-btn"; - closeBtn.textContent = "\u00d7"; - closeBtn.setAttribute("data-value", name); - option.appendChild(closeBtn); - - document.getElementById("presets-menu").appendChild(option); -} - -document.getElementById("save-preset").addEventListener("click", async () => { - const nameInput = document.getElementById("preset-name"); - const name = nameInput.value; - - if (name.length == 0) return; - - let needAdd = true; - const tabId = await getCurrentTabId(); - chrome.storage.local - .get(["filters." + tabId, "presets", "presetNames"]) - .then((prefs) => { - const presets = prefs.presets ?? {}; - const presetNames = prefs.presetNames ?? []; - presets[name] = prefs["filters." + tabId]; - needAdd = !presetNames.includes(name); - if (needAdd) presetNames.push(name); - chrome.storage.local.set({ - presets: presets, - presetNames: presetNames, - }); - if (needAdd) addPresetToDropdown(name); - }); - - nameInput.value = ""; -}); - -const dropdown = document.getElementById("presets"); -const toggle = document.getElementById("presets-toggle"); -const menu = document.getElementById("presets-menu"); - -toggle.addEventListener("click", () => { - menu.style.display = menu.style.display === "block" ? "none" : "block"; -}); - -document.addEventListener("click", (e) => { - if (!dropdown.contains(e.target)) menu.style.display = "none"; -}); - -menu.addEventListener("click", (e) => { - const choice = e.target.getAttribute("data-value"); - if (choice == "none") { - toggle.textContent = getLocalizedMessage("empty_preset_name"); - menu.style.display = "none"; - return; - } - - if (e.target.classList.contains("close-btn")) { - chrome.storage.local - .get(["presets", "presetNames", "whitelist"]) - .then(async (prefs) => { - if (isPresetUsedInWhitelist(prefs.whitelist, choice)) { - alert(getLocalizedMessage("preset_delete_error")); - return; - } - - const item = e.target.parentElement; - item.remove(); - const presets = prefs.presets ?? {}; - const presetNames = (prefs.presetNames ?? []).filter( - (n) => n != choice - ); - delete presets[choice]; - - chrome.storage.local.set({ - presets: presets, - presetNames: presetNames, - }); - }); - } else if (e.target.classList.contains("dropdown-item")) { - toggle.textContent = - choice === "none" ? getLocalizedMessage("empty_preset_name") : choice; - chrome.storage.local.get(["presets"]).then(async (prefs) => { - if (!prefs.presets) return; - - const presets = prefs.presets; - setPoints(presets[choice]); - const filters = pointsToFilters(points); - const tabId = await getCurrentTabId(); - const values = { - ["filters." + tabId]: filters, - ["filters"]: filters, - }; - if (!isToolkitWindow) values["enabled." + tabId] = true; - chrome.storage.local.set(values); - mainResize(); - refreshToolkitCaptureFilters(); - }); - menu.style.display = "none"; - } -}); diff --git a/scripts/popupSettings.js b/scripts/popupSettings.js deleted file mode 100644 index da5aae6..0000000 --- a/scripts/popupSettings.js +++ /dev/null @@ -1,312 +0,0 @@ -// ******************** -// Settings modal -// ******************** -const settingsModal = document.getElementById("settings-modal"); -const settingsBtn = document.getElementById("settings-btn"); -const closeSettings = document.getElementById("close-settings"); - -settingsBtn.onclick = function () { - settingsModal.style.display = "block"; -}; -closeSettings.onclick = function () { - settingsModal.style.display = "none"; -}; -window.onclick = function (event) { - if (event.target === settingsModal) { - settingsModal.style.display = "none"; - } -}; - -// ******************** -// Theme settings -// ******************** -function saveTheme(theme) { - return chrome.storage.local.set({ [THEME_KEY]: theme }); -} - -function applyTheme(theme) { - const chosen = themes[theme] ?? themes.dark; - Object.entries(chosen).forEach(([key, value]) => { - document.documentElement.style.setProperty(`--${key}`, value); - }); - g_currentTheme = theme in themes ? theme : DEFAULT_THEME; - const select = document.getElementById("theme-select"); - if (select) select.value = g_currentTheme; - if (typeof loadColors === "function") loadColors(); - mainResize(); -} - -const themeSelect = document.getElementById("theme-select"); -if (themeSelect) { - themeSelect.value = g_currentTheme; - themeSelect.addEventListener("change", (e) => { - const theme = e.target.value || DEFAULT_THEME; - applyTheme(theme); - saveTheme(theme); - }); -} - -// ******************** -// EQ points count settings -// ******************** -function shouldSkipPointsResetConfirm() { - return g_skipPointsResetConfirm; -} - -function setSkipPointsResetConfirm(value) { - g_skipPointsResetConfirm = Boolean(value); - return chrome.storage.local.set({ - [SKIP_POINTS_CONFIRM_KEY]: g_skipPointsResetConfirm, - }); -} - -function updatePointCountSelect(count) { - const select = document.getElementById("points-count"); - if (select) select.value = clampPointCount(count).toString(); -} - -async function applyPointCountChange(newCount) { - await savePointCount(newCount); - initPoints(newCount); - mainResize(); - refreshToolkitCaptureFilters(); - await saveCurrentFilters(); -} - -const pointsCountSelect = document.getElementById("points-count"); -if (pointsCountSelect) { - pointsCountSelect.addEventListener("change", async (e) => { - const newCount = clampPointCount(parseInt(e.target.value, 10)); - if (Number.isNaN(newCount)) return; - if (shouldSkipPointsResetConfirm()) { - applyPointCountChange(newCount); - return; - } - g_pendingPointCount = newCount; - const skipCheckbox = document.getElementById("skip-reset-confirm"); - if (skipCheckbox) { - skipCheckbox.checked = shouldSkipPointsResetConfirm(); - } - const modalElem = document.getElementById("points-reset-modal"); - if (modalElem) modalElem.style.display = "block"; - }); -} - -// ******************** -// Points reset confirmation modal -// ******************** -const pointsResetModal = document.getElementById("points-reset-modal"); -const pointsResetConfirmBtn = document.getElementById("points-reset-confirm"); -const pointsResetCancelBtn = document.getElementById("points-reset-cancel"); -const skipResetCheckbox = document.getElementById("skip-reset-confirm"); - -function closePointsResetModal() { - if (pointsResetModal) pointsResetModal.style.display = "none"; -} - -if (pointsResetConfirmBtn) { - pointsResetConfirmBtn.addEventListener("click", async () => { - if (skipResetCheckbox) { - setSkipPointsResetConfirm(skipResetCheckbox.checked); - } - if (g_pendingPointCount != null) { - await applyPointCountChange(g_pendingPointCount); - } - g_pendingPointCount = null; - closePointsResetModal(); - }); -} - -if (pointsResetCancelBtn) { - pointsResetCancelBtn.addEventListener("click", () => { - updatePointCountSelect(g_pointCount); - g_pendingPointCount = null; - closePointsResetModal(); - }); -} - -if (pointsResetModal) { - pointsResetModal.addEventListener("click", (event) => { - if (event.target === pointsResetModal) { - updatePointCountSelect(g_pointCount); - g_pendingPointCount = null; - closePointsResetModal(); - } - }); -} - -// ******************** -// Presets import and export -// ******************** -document.getElementById("export-presets").addEventListener("click", () => { - chrome.storage.local.get(["presets", "presetNames"]).then((result) => { - const json = JSON.stringify(result); - saveTextAsFile(json, "eq_toolkit_presets.json"); - }); -}); - -function saveTextAsFile(text, filename = "file.txt") { - const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); - - const url = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - - a.click(); - - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - -const input = document.getElementById("import-input"); -document.getElementById("import-presets").addEventListener("click", () => { - input.click(); -}); - -input.addEventListener("change", (e) => { - const file = e.target.files[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = () => { - const presetsInfo = JSON.parse(reader.result); - chrome.storage.local.get(["presets", "presetNames"]).then((prefs) => { - const presets = prefs.presets ?? {}; - const presetNames = prefs.presetNames ?? []; - presetsInfo.presetNames.forEach((name) => { - const needAdd = !presetNames.includes(name); - if (needAdd) { - presetNames.push(name); - addPresetToDropdown(name); - presets[name] = presetsInfo.presets[name]; - } - }); - chrome.storage.local.set({ - presets: presets, - presetNames: presetNames, - }); - }); - }; - reader.readAsText(file, "utf-8"); -}); - -// ******************** -// Spectrum setting -// ******************** -document.getElementById("enable-spectrum").addEventListener("change", (e) => { - const value = e.target.checked; - - chrome.storage.local.set({ - enableSpectrum: value, - }); -}); - -// ******************** -// Language setting -// ******************** -const languageSelect = document.getElementById("language-select"); -if (languageSelect) { - languageSelect.addEventListener("change", (event) => { - setLanguage(event.target.value, { save: true }); - }); -} - -// ******************** -// Shortcuts settings -// ******************** -const shortcutInputs = { - [SHORTCUT_ACTION_MUTE_NAME]: document.getElementById("shortcut-mute"), - [SHORTCUT_ACTION_TOGGLE_EQ_NAME]: - document.getElementById("shortcut-toggle-eq"), -}; -const shortcutsError = document.getElementById("shortcuts-settings-error"); - -function setShortcutsError(messageName) { - if (!shortcutsError) return; - - if (!messageName) { - shortcutsError.style.display = "none"; - shortcutsError.textContent = ""; - return; - } - - shortcutsError.textContent = getLocalizedMessage(messageName); - shortcutsError.style.display = "block"; -} - -function renderShortcutInputs(invalidAction = null) { - Object.entries(shortcutInputs).forEach(([action, input]) => { - if (!input) return; - - input.value = formatShortcut(g_shortcutSettings[action]); - input.classList.toggle("invalid", action === invalidAction); - }); -} - -function startShortcutEdit(input) { - input.value = "?"; - input.classList.remove("invalid"); - setShortcutsError(null); -} - -async function saveShortcut(action, shortcut) { - const nextShortcuts = { - ...g_shortcutSettings, - [action]: shortcut, - }; - const validationError = validateShortcutConfig(nextShortcuts); - - if (validationError) { - const messageName = - validationError === "duplicate" - ? "shortcut_duplicate_error" - : "shortcut_validation_error"; - setShortcutsError(messageName); - renderShortcutInputs(action); - return false; - } - - g_shortcutSettings = resolveShortcuts(nextShortcuts); - await chrome.storage.local.set({ ["shortcuts"]: g_shortcutSettings }); - setShortcutsError(null); - renderShortcutInputs(); - return true; -} - -Object.entries(shortcutInputs).forEach(([action, input]) => { - if (!input) return; - - input.addEventListener("focus", () => { - startShortcutEdit(input); - }); - - input.addEventListener("blur", () => { - setShortcutsError(null); - renderShortcutInputs(); - }); - - input.addEventListener("keydown", async (event) => { - event.preventDefault(); - event.stopPropagation(); - - if (isModifierShortcutKey(event.key)) return; - - const shortcut = normalizeShortcutFromKeyboardEvent(event); - if (!shortcut) { - setShortcutsError("shortcut_validation_error"); - input.classList.add("invalid"); - return; - } - - const saved = await saveShortcut(action, shortcut); - if (!saved) input.value = "?"; - }); -}); - -chrome.storage.local.get(["shortcuts"]).then((stored) => { - g_shortcutSettings = resolveShortcuts(stored["shortcuts"]); - renderShortcutInputs(); -}); diff --git a/scripts/popupState.js b/scripts/popupState.js deleted file mode 100644 index bc4f5de..0000000 --- a/scripts/popupState.js +++ /dev/null @@ -1,31 +0,0 @@ -let g_dragIndex = null; -const canvas = document.getElementById("eq-canvas"); -const ctx = canvas.getContext("2d", { alpha: true }); -const infoTooltip = document.getElementById("info-tooltip"); -const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - -const THEME_KEY = "theme"; -const DEFAULT_THEME = "dark"; -const SKIP_POINTS_CONFIRM_KEY = "skipPointsResetConfirm"; -const POINT_COUNT_KEY = "pointCount"; -const INSTALL_UPDATE_NOTICE_KEY = "installUpdateNotice"; -const captureErrorElem = document.getElementById("capture-error"); - -const logMin = Math.log10(1); -const logMax = Math.log10(22000); -const MIN_POINT_COUNT = 5; -const MAX_POINT_COUNT = 9; -const DEFAULT_FILTER_Q = 0.5; -const MIN_FILTER_Q = 0.1; -const MAX_FILTER_Q = 10; -const DEFAULT_HIGHPASS_FREQ = 20; -const DEFAULT_LOWPASS_FREQ = 20000; - -let g_pointCount = MIN_POINT_COUNT; -let g_currentTheme = DEFAULT_THEME; -let g_skipPointsResetConfirm = false; -let g_pendingPointCount = null; -let g_dragMode = null; -let g_qDragStartY = 0; -let g_qDragStartValue = DEFAULT_FILTER_Q; -let g_shortcutSettings = resolveShortcuts(null); diff --git a/scripts/popupWhitelist.js b/scripts/popupWhitelist.js deleted file mode 100644 index e7af143..0000000 --- a/scripts/popupWhitelist.js +++ /dev/null @@ -1,221 +0,0 @@ -const addToWhitelistBtn = document.getElementById( - "add-to-autostart-whitelist-btn" -); -const autostartModal = document.getElementById("autostart-modal"); -const autostartCloseBtn = document.getElementById("autostart-modal-close"); -const autostartCancelBtn = document.getElementById("autostart-modal-cancel"); -const autostartConfirmBtn = document.getElementById("autostart-modal-confirm"); -const autostartPreset = document.getElementById("autostart-modal-preset"); -const autostartModalError = document.getElementById("autostart-modal-error"); -const autostartModalDomainValue = document.getElementById( - "autostart-modal-domain-value" -); -const autostartModalUrlValue = document.getElementById( - "autostart-modal-url-value" -); - -async function getActiveTab() { - const [tab] = await chrome.tabs.query({ - active: true, - lastFocusedWindow: true, - }); - return tab ?? null; -} - -function setAutostartSettingsError(element, messageName) { - if (!element) return; - element.textContent = messageName ? getLocalizedMessage(messageName) : ""; - element.style.display = messageName ? "block" : "none"; -} - -function fillPresetSelect(select, presetNames, selectedName = "") { - if (!select) return; - select.textContent = ""; - - const emptyOption = document.createElement("option"); - emptyOption.value = ""; - emptyOption.textContent = getLocalizedMessage( - "autostart_settings_select_preset_placeholder" - ); - select.appendChild(emptyOption); - - (presetNames ?? []).forEach((name) => { - const option = document.createElement("option"); - option.value = name; - option.textContent = name; - select.appendChild(option); - }); - - select.value = selectedName; -} - -async function refreshAutostartSettingsPresetValueSelects() { - const { presetNames } = await chrome.storage.local.get(["presetNames"]); - fillPresetSelect(autostartPreset, presetNames ?? []); - fillPresetSelect(autostartSettingsPresetValue, presetNames ?? []); -} - -function formatWhitelistEntry(entry) { - const typeLabel = - entry.type === "url" - ? getLocalizedMessage("autostart_rule_type_url_label") - : getLocalizedMessage("autostart_rule_type_domain_label"); - return `${typeLabel}: ${entry.value}`; -} - -async function saveWhitelistEntry(type, value, presetName, errorElement) { - const entry = createWhitelistEntry(type, value, presetName); - if (!entry) { - setAutostartSettingsError(errorElement, "autostart_validation_error"); - return false; - } - - const { whitelist } = await chrome.storage.local.get(["whitelist"]); - const entries = (whitelist ?? []).filter((item) => item.id !== entry.id); - entries.push(entry); - await chrome.storage.local.set({ ["whitelist"]: entries }); - setAutostartSettingsError(errorElement, ""); - return true; -} - -async function removeWhitelistEntry(id) { - const { whitelist } = await chrome.storage.local.get(["whitelist"]); - const entries = (whitelist ?? []).filter((entry) => entry.id !== id); - await chrome.storage.local.set({ ["whitelist"]: entries }); -} - -function closeWhitelistModal() { - autostartModal.style.display = "none"; -} - -if (addToWhitelistBtn) { - addToWhitelistBtn.addEventListener("click", async () => { - if (isToolkitWindow) return; - - const tab = await getActiveTab(); - if (autostartModalDomainValue) { - autostartModalDomainValue.textContent = getWhitelistDomain(tab?.url); - } - if (autostartModalUrlValue) { - autostartModalUrlValue.textContent = normalizeWhitelistUrl(tab?.url); - } - await refreshAutostartSettingsPresetValueSelects(); - setAutostartSettingsError(autostartModalError, ""); - autostartModal.style.display = "block"; - }); -} - -autostartCloseBtn.addEventListener("click", closeWhitelistModal); -autostartCancelBtn.addEventListener("click", closeWhitelistModal); - -autostartModal.addEventListener("click", (event) => { - if (event.target === autostartModal) closeWhitelistModal(); -}); - -autostartConfirmBtn.addEventListener("click", async () => { - const tab = await getActiveTab(); - const selectedType = document.querySelector( - "input[name='autostart-modal-add-type']:checked" - )?.value; - const saved = await saveWhitelistEntry( - selectedType, - tab?.url, - autostartPreset.value, - autostartModalError - ); - if (saved) closeWhitelistModal(); -}); - -// ******************** -// Autostart whitelist setting -// ******************** -const autostartSettingsList = document.getElementById( - "autostart-settings-list" -); -const autostartSettingsTypeValue = document.getElementById( - "autostart-settings-type" -); -const autostartSettingsUrlValue = document.getElementById( - "autostart-settings-add-value" -); -const autostartSettingsPresetValue = document.getElementById( - "autostart-settings-add-preset" -); -const autostartSettingsAddBtn = document.getElementById( - "autostart-settings-add-btn" -); -const autostartSettingsError = document.getElementById( - "autostart-settings-error" -); - -async function renderWhitelist() { - if (!autostartSettingsList) return; - - const { whitelist } = await chrome.storage.local.get(["whitelist"]); - const entries = whitelist ?? []; - autostartSettingsList.textContent = ""; - - if (entries.length === 0) { - const empty = document.createElement("div"); - empty.id = "whitelist-empty"; - empty.className = "whitelist-empty"; - empty.textContent = getLocalizedMessage("autostart_whitelist_empty"); - autostartSettingsList.appendChild(empty); - return; - } - - entries.forEach((entry) => { - const item = document.createElement("div"); - item.className = "whitelist-item"; - - const text = document.createElement("div"); - text.className = "whitelist-item-text"; - - const value = document.createElement("span"); - value.textContent = formatWhitelistEntry(entry); - - const preset = document.createElement("small"); - preset.textContent = `${getLocalizedMessage( - "autostart_modal_preset_label" - )}: ${entry.presetName}`; - - text.append(value, preset); - - const deleteBtn = document.createElement("button"); - deleteBtn.type = "button"; - deleteBtn.className = "whitelist-delete"; - deleteBtn.textContent = "\u00d7"; - deleteBtn.setAttribute("aria-label", getLocalizedMessage("delete")); - deleteBtn.addEventListener("click", () => removeWhitelistEntry(entry.id)); - - item.append(text, deleteBtn); - autostartSettingsList.appendChild(item); - }); -} - -autostartSettingsAddBtn.addEventListener("click", async () => { - const saved = await saveWhitelistEntry( - autostartSettingsTypeValue.value, - autostartSettingsUrlValue.value, - autostartSettingsPresetValue.value, - autostartSettingsError - ); - if (saved) autostartSettingsUrlValue.value = ""; -}); - -/** - * Other - */ -chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName !== "local") return; - if (changes.whitelist) renderWhitelist(); - if (changes.presetNames) refreshAutostartSettingsPresetValueSelects(); -}); - -async function initAutostartSettings() { - await g_localizationReady; - await refreshAutostartSettingsPresetValueSelects(); - await renderWhitelist(); -} - -initAutostartSettings(); diff --git a/scripts/shortcuts.js b/scripts/shortcuts.js deleted file mode 100644 index d2f055d..0000000 --- a/scripts/shortcuts.js +++ /dev/null @@ -1,142 +0,0 @@ -const SHORTCUT_ACTION_MUTE_NAME = "mute"; -const SHORTCUT_ACTION_TOGGLE_EQ_NAME = "toggleEq"; - -const DEFAULT_SHORTCUTS = Object.freeze({ - [SHORTCUT_ACTION_MUTE_NAME]: Object.freeze({ - alt: true, - ctrl: false, - shift: false, - meta: false, - key: "M", - }), - [SHORTCUT_ACTION_TOGGLE_EQ_NAME]: Object.freeze({ - alt: true, - ctrl: false, - shift: false, - meta: false, - key: "K", - }), -}); - -function normalizeShortcutKey(key) { - if (typeof key !== "string") return null; - - const value = key.trim(); - if (/^[a-z0-9]$/i.test(value)) return value.toUpperCase(); - - return null; -} - -function normalizeShortcutCode(code) { - if (typeof code !== "string") return null; - - if (/^Key[A-Z]$/.test(code)) return code.slice(3); - if (/^Digit[0-9]$/.test(code)) return code.slice(5); - - return null; -} - -function isModifierShortcutKey(key) { - return ["Alt", "Control", "Shift", "Meta"].includes(key); -} - -function isEditableShortcutTarget(target) { - if (!target) return false; - - const element = target instanceof Element ? target : target.parentElement; - if (!element) return false; - - return Boolean( - element.closest( - "input, textarea, select, [contenteditable=''], [contenteditable='true']" - ) - ); -} - -function formatShortcut(shortcut) { - if (!shortcut) return ""; - - const parts = []; - if (shortcut.ctrl) parts.push("Ctrl"); - if (shortcut.alt) parts.push("Alt"); - if (shortcut.shift) parts.push("Shift"); - if (shortcut.meta) parts.push("Meta"); - parts.push(shortcut.key); - return parts.join("+"); -} - -function areShortcutsEqual(first, second) { - return ( - first?.ctrl === second?.ctrl && - first?.alt === second?.alt && - first?.shift === second?.shift && - first?.meta === second?.meta && - first?.key === second?.key - ); -} - -function normalizeShortcut(value) { - if (!value) return null; - - const shortcut = { - ctrl: value.ctrl === true, - alt: value.alt === true, - shift: value.shift === true, - meta: value.meta === true, - key: value.key, - }; - - if (!shortcut.key) return null; - if (!shortcut.ctrl && !shortcut.alt && !shortcut.shift && !shortcut.meta) { - return null; - } - - return shortcut; -} - -function normalizeShortcutFromKeyboardEvent(event) { - if (!event || event.isComposing) return null; - const key = - normalizeShortcutCode(event.code) ?? normalizeShortcutKey(event.key); - - return normalizeShortcut({ - ctrl: event.ctrlKey, - alt: event.altKey, - shift: event.shiftKey, - meta: event.metaKey, - key, - }); -} - -function resolveShortcuts(storedShortcuts) { - const shortcuts = storedShortcuts ?? DEFAULT_SHORTCUTS; - return { - [SHORTCUT_ACTION_MUTE_NAME]: normalizeShortcut( - shortcuts[SHORTCUT_ACTION_MUTE_NAME] - ), - [SHORTCUT_ACTION_TOGGLE_EQ_NAME]: normalizeShortcut( - shortcuts[SHORTCUT_ACTION_TOGGLE_EQ_NAME] - ), - }; -} - -function validateShortcutConfig(shortcuts) { - const muteShortcut = normalizeShortcut( - shortcuts?.[SHORTCUT_ACTION_MUTE_NAME] - ); - const toggleEqShortcut = normalizeShortcut( - shortcuts?.[SHORTCUT_ACTION_TOGGLE_EQ_NAME] - ); - - if (!muteShortcut || !toggleEqShortcut) return "invalid"; - if (areShortcutsEqual(muteShortcut, toggleEqShortcut)) return "duplicate"; - - return null; -} - -function matchesShortcut(event, shortcut) { - const normalized = normalizeShortcutFromKeyboardEvent(event); - if (!normalized) return false; - - return areShortcutsEqual(normalized, shortcut); -} diff --git a/scripts/themes.js b/scripts/themes.js deleted file mode 100644 index 56a72cd..0000000 --- a/scripts/themes.js +++ /dev/null @@ -1,107 +0,0 @@ -const themes = { - dark: { - bg: "var(--dark-bg)", - "panel-bg": "var(--dark-panel-bg)", - "settings-bg": "var(--dark-settings-bg)", - "accent-start": "var(--dark-accent-start)", - "accent-mid": "var(--dark-accent-mid)", - "accent-end": "var(--dark-accent-end)", - "highpass-filter": "var(--dark-highpass-filter)", - "lowpass-filter": "var(--dark-lowpass-filter)", - text: "var(--dark-text)", - "text-header": "var(--dark-text-header)", - "slider-track-bg": "var(--dark-slider-track-bg)", - "slider-thumb": "var(--dark-slider-thumb)", - axis: "var(--dark-axis)", - border: "var(--dark-border)", - "surface-alt": "var(--dark-surface-alt)", - "text-placeholder": "var(--dark-text-placeholder)", - "text-volume-scale": "var(--dark-text-volume-scale)", - "hover-bg": "var(--dark-hover-bg)", - "active-bg": "var(--dark-active-bg)", - "menu-hover": "var(--dark-menu-hover)", - overlay: "var(--dark-overlay)", - "shadow-soft": "var(--dark-shadow-soft)", - "shadow-strong": "var(--dark-shadow-strong)", - donation: "var(--dark-donation)", - "slider-glow": "var(--dark-slider-glow)", - "reset-btn": "var(--dark-reset-btn)", - "reset-btn-hover": "var(--dark-reset-btn-hover)", - "reset-btn-active": "var(--dark-reset-btn-active)", - "btn-bg": "var(--dark-btn-bg)", - "svg-btn-filter": "var(--dark-svg-btn-filter)", - "info-btn-display": "var(--dark-info-btn-display)", - "notice-link": "var(--dark-notice-link)", - "notice-link-hover": "var(--dark-notice-link-hover)", - }, - light: { - bg: "var(--light-bg)", - "panel-bg": "var(--light-panel-bg)", - "settings-bg": "var(--light-settings-bg)", - "accent-start": "var(--light-accent-start)", - "accent-mid": "var(--light-accent-mid)", - "accent-end": "var(--light-accent-end)", - "highpass-filter": "var(--light-highpass-filter)", - "lowpass-filter": "var(--light-lowpass-filter)", - text: "var(--light-text)", - "text-header": "var(--light-text-header)", - "slider-track-bg": "var(--light-slider-track-bg)", - "slider-thumb": "var(--light-slider-thumb)", - axis: "var(--light-axis)", - border: "var(--light-border)", - "surface-alt": "var(--light-surface-alt)", - "text-placeholder": "var(--light-text-placeholder)", - "text-volume-scale": "var(--light-text-volume-scale)", - "hover-bg": "var(--light-hover-bg)", - "active-bg": "var(--light-active-bg)", - "menu-hover": "var(--light-menu-hover)", - overlay: "var(--light-overlay)", - "shadow-soft": "var(--light-shadow-soft)", - "shadow-strong": "var(--light-shadow-strong)", - donation: "var(--light-donation)", - "slider-glow": "var(--light-slider-glow)", - "reset-btn": "var(--light-reset-btn)", - "reset-btn-hover": "var(--light-reset-btn-hover)", - "reset-btn-active": "var(--light-reset-btn-active)", - "btn-bg": "var(--light-btn-bg)", - "svg-btn-filter": "var(--light-svg-btn-filter)", - "info-btn-display": "var(--light-info-btn-display)", - "notice-link": "var(--light-notice-link)", - "notice-link-hover": "var(--light-notice-link-hover)", - }, - ny: { - bg: "var(--ny-bg)", - "panel-bg": "var(--ny-panel-bg)", - "settings-bg": "var(--ny-settings-bg)", - "accent-start": "var(--ny-accent-start)", - "accent-mid": "var(--ny-accent-mid)", - "accent-end": "var(--ny-accent-end)", - "highpass-filter": "var(--ny-highpass-filter)", - "lowpass-filter": "var(--ny-lowpass-filter)", - text: "var(--ny-text)", - "text-header": "var(--ny-text-header)", - "slider-track-bg": "var(--ny-slider-track-bg)", - "slider-thumb": "var(--ny-slider-thumb)", - axis: "var(--ny-axis)", - border: "var(--ny-border)", - "surface-alt": "var(--ny-surface-alt)", - "text-placeholder": "var(--ny-text-placeholder)", - "text-volume-scale": "var(--ny-text-volume-scale)", - "hover-bg": "var(--ny-hover-bg)", - "active-bg": "var(--ny-active-bg)", - "menu-hover": "var(--ny-menu-hover)", - overlay: "var(--ny-overlay)", - "shadow-soft": "var(--ny-shadow-soft)", - "shadow-strong": "var(--ny-shadow-strong)", - donation: "var(--ny-donation)", - "slider-glow": "var(--ny-slider-glow)", - "reset-btn": "var(--ny-reset-btn)", - "reset-btn-hover": "var(--ny-reset-btn-hover)", - "reset-btn-active": "var(--ny-reset-btn-active)", - "btn-bg": "var(--ny-btn-bg)", - "svg-btn-filter": "var(--ny-svg-btn-filter)", - "info-btn-display": "var(--ny-info-btn-display)", - "notice-link": "var(--ny-notice-link)", - "notice-link-hover": "var(--ny-notice-link-hover)", - }, -}; diff --git a/scripts/whitelist.js b/scripts/whitelist.js deleted file mode 100644 index 1adef1d..0000000 --- a/scripts/whitelist.js +++ /dev/null @@ -1,71 +0,0 @@ -function normalizeWhitelistUrl(url) { - try { - const normalizedUrl = new URL(url); - normalizedUrl.hash = ""; - return normalizedUrl.href; - } catch (e) { - return url; - } -} - -function getWhitelistDomain(url) { - try { - return new URL(url).hostname.replace(/^www\./, ""); - } catch (e) { - return url.split("/")[0].replace(/^www\./, ""); - } -} - -function normalizeAutostartSettingsUrlValue(type, value) { - if (!value) return ""; - const rawValue = value.trim(); - - if (type === "domain") { - return getWhitelistDomain(rawValue); - } - - return normalizeWhitelistUrl(rawValue); -} - -function createWhitelistEntry(type, value, presetName) { - if (!value || !presetName) return null; - - const normalizedValue = normalizeAutostartSettingsUrlValue(type, value); - const normalizedPresetName = presetName.trim(); - - return { - id: `${type}:${normalizedValue}`, - type: type, - value: normalizedValue, - presetName: normalizedPresetName, - }; -} - -function findWhitelistMatch(entries, url) { - const normalizedUrl = normalizeWhitelistUrl(url); - const domain = getWhitelistDomain(url); - if (!normalizedUrl || !domain || !entries || entries.lenght == 0) return null; - - let urlRule; - let domainRule; - entries.forEach((entry) => { - if (entry.type === "url" && entry.value === normalizedUrl) { - urlRule = entry; - } - - if ( - entry.type === "domain" && - (domain === entry.value || domain.endsWith("." + entry.value)) - ) { - domainRule = entry; - } - }); - - if (urlRule) return urlRule; - - return domainRule; -} - -function isPresetUsedInWhitelist(entries, presetName) { - return (entries ?? []).some((entry) => entry.presetName === presetName); -} diff --git a/scripts/windowMod/windowMod.js b/scripts/windowMod/windowMod.js deleted file mode 100644 index 547c64c..0000000 --- a/scripts/windowMod/windowMod.js +++ /dev/null @@ -1,57 +0,0 @@ -const TOOLKIT_WINDOW_KEY = "toolkitWindowId"; -const TOOLKIT_WINDOW_TAB_IDS_KEY = "toolkitWindowTabIds"; -const TOOLKIT_WINDOW_ACTIVE_TAB_KEY = "toolkitWindowActiveTabId"; -const TOOLKIT_WINDOW_CAPTURE_STREAM_IDS_KEY = "toolkitWindowCaptureStreamIds"; - -const capturedTabsElem = document.getElementById("captured-tabs"); - -const isToolkitWindow = - new URLSearchParams(window.location.search).get("mode") === "window"; - -if (isToolkitWindow) { - document.body.classList.add("toolkit-window-body"); - const changeEqBtn = document.getElementById("change-eq"); - changeEqBtn.disabled = true; - changeEqBtn.classList.add("disabled"); -} - -let g_toolkitActiveTabId = null; -let g_toolkitCaptures = new Map(); - -// ******************** -// Window already open notification -// ******************** -async function shouldShowToolkitWindowNotice() { - if (isToolkitWindow) return false; - - const tabId = await getCurrentTabId(); - const stored = await chrome.storage.session.get([ - TOOLKIT_WINDOW_KEY, - TOOLKIT_WINDOW_TAB_IDS_KEY, - ]); - const toolkitWindowTabIds = Array.isArray(stored[TOOLKIT_WINDOW_TAB_IDS_KEY]) - ? stored[TOOLKIT_WINDOW_TAB_IDS_KEY] - : []; - return ( - stored[TOOLKIT_WINDOW_KEY] != null && toolkitWindowTabIds.includes(tabId) - ); -} - -function showToolkitWindowNotice() { - const message = - getLocalizedMessage("toolkit_window_already_open") || - "Equalizer is already open in a window"; - const notice = document.createElement("div"); - notice.className = "window-open-notice"; - notice.textContent = message; - - document.body.className = "window-open-notice-body"; - document.body.replaceChildren(notice); -} - -async function getToolkitCaptureStreamIds() { - const stored = await chrome.storage.session.get( - TOOLKIT_WINDOW_CAPTURE_STREAM_IDS_KEY - ); - return stored[TOOLKIT_WINDOW_CAPTURE_STREAM_IDS_KEY] ?? {}; -} diff --git a/scripts/windowMod/windowModCapturedTabs.js b/scripts/windowMod/windowModCapturedTabs.js deleted file mode 100644 index 8ff4ab4..0000000 --- a/scripts/windowMod/windowModCapturedTabs.js +++ /dev/null @@ -1,66 +0,0 @@ -async function renderCapturedTabs() { - if (!isToolkitWindow || !capturedTabsElem) return; - - const result = await getCapturedTabs(); - if (result?.tabs == null || !Array.isArray(result.tabs)) { - console.error("Invalid toolkit tabs response", result); - return; - } - - const tabs = result.tabs; - const activeTabId = result?.activeTabId ?? null; - capturedTabsElem.replaceChildren(); - - tabs.forEach((tab) => { - const item = document.createElement("button"); - item.type = "button"; - item.className = "captured-tab"; - if (tab.id === activeTabId) item.classList.add("active"); - item.dataset.tabId = tab.id; - item.title = tab.title || tab.url || String(tab.id); - - if (tab.favIconUrl) { - const icon = document.createElement("img"); - icon.className = "captured-tab-icon"; - icon.src = tab.favIconUrl; - icon.alt = ""; - item.appendChild(icon); - } - - const title = document.createElement("span"); - title.className = "captured-tab-title"; - title.textContent = tab.title || tab.url || `Tab ${tab.id}`; - item.appendChild(title); - - capturedTabsElem.appendChild(item); - }); -} - -function getCapturedTabs() { - return new Promise((resolve) => { - chrome.runtime.sendMessage({ method: "getCapturedTabs" }, (result) => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError.message); - resolve({ tabs: [], activeTabId: null }); - return; - } - resolve(result ?? { tabs: [], activeTabId: null }); - }); - }); -} - -if (capturedTabsElem) { - capturedTabsElem.addEventListener("click", async (event) => { - const item = event.target.closest(".captured-tab"); - if (!item) return; - - const tabId = Number.parseInt(item.dataset.tabId, 10); - if (Number.isNaN(tabId)) return; - - await chrome.storage.session.set({ - [TOOLKIT_WINDOW_ACTIVE_TAB_KEY]: tabId, - }); - await loadToolkitTabSettings(tabId); - await renderCapturedTabs(); - }); -} diff --git a/scripts/windowMod/windowModTabCapture.js b/scripts/windowMod/windowModTabCapture.js deleted file mode 100644 index 551a368..0000000 --- a/scripts/windowMod/windowModTabCapture.js +++ /dev/null @@ -1,222 +0,0 @@ -async function loadToolkitTabSettings(tabId) { - if (tabId == null) return; - g_toolkitActiveTabId = tabId; - - const result = await chrome.storage.local.get([ - "filters", - "filters." + tabId, - "gain." + tabId, - "enabled." + tabId, - "mute." + tabId, - "captureError." + tabId, - ]); - - if (result["filters." + tabId]?.length) { - setPoints(result["filters." + tabId]); - } else if (result.filters?.length) { - setPoints(result.filters); - } else { - initPoints(); - } - - const gain = result["gain." + tabId]; - if (gain != null && gain != undefined) { - document.getElementById("master-volume").value = gain; - } else { - document.getElementById("master-volume").value = 0; - } - - mainResize(); - setEnableBtnText(result["enabled." + tabId] ?? false); - setMuteBtnClass(result["mute." + tabId] ?? false); - - if (result["captureError." + tabId]) { - renderCaptureError(result["captureError." + tabId]); - } else { - renderCaptureError(null); - } - - refreshToolkitCaptureFilters(tabId); -} - -async function startToolkitTabCapture() { - if (!isToolkitWindow) return; - - const streamIds = await getToolkitCaptureStreamIds(); - const streamEntries = Object.entries(streamIds); - - try { - await audioCtx.resume(); - - const activeTabIds = new Set(streamEntries.map(([tabId]) => tabId)); - g_toolkitCaptures.forEach((capture, tabId) => { - if (activeTabIds.has(tabId)) return; - stopToolkitCaptureEntry(capture); - g_toolkitCaptures.delete(tabId); - }); - - await Promise.all( - streamEntries.map(async ([tabId, streamId]) => { - const existing = g_toolkitCaptures.get(tabId); - if (existing?.streamId === streamId) return; - - if (existing) { - stopToolkitCaptureEntry(existing); - g_toolkitCaptures.delete(tabId); - } - - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - mandatory: { - chromeMediaSource: "tab", - chromeMediaSourceId: streamId, - }, - }, - video: false, - }); - const source = audioCtx.createMediaStreamSource(stream); - const capture = { - streamId, - stream, - source, - preamp: null, - filters: [], - output: null, - filterSettings: [], - }; - g_toolkitCaptures.set(tabId, capture); - buildToolkitCaptureGraph(tabId); - chrome.storage.local.remove("captureError." + tabId); - }) - ); - - renderCaptureError(null); - } catch (e) { - const tabId = await getCurrentTabId(); - if (tabId != null) { - chrome.storage.local.set({ - ["captureError." + tabId]: e?.message ?? "Tab audio capture failed", - }); - } - renderCaptureError(e?.message ?? "Tab audio capture failed"); - } -} - -window.addEventListener("beforeunload", stopToolkitTabCapture); - -function buildToolkitCaptureGraph(tabId) { - const capture = g_toolkitCaptures.get(String(tabId)); - if (!capture?.source) return; - - disconnectToolkitCaptureGraph(capture); - - capture.preamp = audioCtx.createGain(); - capture.source.connect(capture.preamp); - - let previousNode = capture.preamp; - const filterSettings = getToolkitCaptureFilterSettings(tabId); - capture.filters = filterSettings.map((filter) => { - const biquadFilter = audioCtx.createBiquadFilter(); - biquadFilter.type = filter.type ?? "peaking"; - biquadFilter.Q.value = filter.q ?? 0.5; - biquadFilter.frequency.value = filter.freq; - biquadFilter.gain.value = filter.gain ?? 0; - previousNode.connect(biquadFilter); - previousNode = biquadFilter; - return biquadFilter; - }); - - capture.output = previousNode; - capture.output.connect(audioCtx.destination); - applyToolkitCaptureSettings(tabId); -} - -function applyToolkitCaptureSettings(tabId = g_toolkitActiveTabId) { - const capture = g_toolkitCaptures.get(String(tabId)); - if (!capture?.preamp) return; - - const slider = document.getElementById("master-volume"); - capture.preamp.gain.value = - document.getElementById("volume-mute").className === "volume-mute-active" - ? 0 - : dbToGain(slider.value); - - const filterSettings = getToolkitCaptureFilterSettings(tabId); - capture.filterSettings = filterSettings; - filterSettings.forEach((filter, index) => { - if (!capture.filters[index]) return; - capture.filters[index].type = filter.type ?? "peaking"; - capture.filters[index].frequency.value = filter.freq; - capture.filters[index].gain.value = filter.gain ?? 0; - capture.filters[index].Q.value = filter.q ?? 0.5; - }); -} - -function getToolkitCaptureFilterSettings(tabId) { - if (tabId === g_toolkitActiveTabId) return pointsToFilters(points); - - const capture = g_toolkitCaptures.get(String(tabId)); - if (capture?.filterSettings?.length) { - return capture.filterSettings; - } - - return pointsToFilters(points); -} - -function refreshToolkitCaptureFilters(tabId = g_toolkitActiveTabId) { - const capture = g_toolkitCaptures.get(String(tabId)); - if (!capture?.source) return; - - const filterSettings = getToolkitCaptureFilterSettings(tabId); - capture.filterSettings = filterSettings; - if (capture.filters.length !== filterSettings.length) { - buildToolkitCaptureGraph(tabId); - return; - } - - applyToolkitCaptureSettings(tabId); -} - -function stopToolkitTabCapture() { - g_toolkitCaptures.forEach((capture) => stopToolkitCaptureEntry(capture)); - g_toolkitCaptures.clear(); -} - -function stopToolkitCaptureEntry(capture) { - disconnectToolkitCaptureGraph(capture); - - if (capture.source) { - try { - capture.source.disconnect(); - } catch (e) {} - } - - if (capture.stream) { - capture.stream.getTracks().forEach((track) => track.stop()); - } -} - -function disconnectToolkitCaptureGraph(capture) { - if (!capture) return; - - if (capture.source) { - try { - capture.source.disconnect(); - } catch (e) {} - } - - if (capture.preamp) { - try { - capture.preamp.disconnect(); - } catch (e) {} - } - - capture.filters.forEach((filter) => { - try { - filter.disconnect(); - } catch (e) {} - }); - capture.preamp = null; - capture.filters = []; - capture.output = null; -} From 8d123b226ece168725a9c967b95be1a16e5ecff2 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sun, 5 Jul 2026 21:26:01 +0300 Subject: [PATCH 25/63] fix: tamil language has been returned --- package-lock.json | 2 +- src/domains/localization/languages.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9aab750..a8440d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "typescript-vite-domain-migration", + "name": "volume-equalizer-extensions", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/src/domains/localization/languages.ts b/src/domains/localization/languages.ts index 8ff797b..597ccf5 100644 --- a/src/domains/localization/languages.ts +++ b/src/domains/localization/languages.ts @@ -73,6 +73,7 @@ export const AVAILABLE_LANGUAGE_CODES = [ "nl", "pt_PT", "ru", + "ta", "th", "tr", "vi", From 70a26d35be3937cba22e5d72e7ea52d925899ef2 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Wed, 8 Jul 2026 18:33:08 +0300 Subject: [PATCH 26/63] ci: added test running --- .github/workflows/test.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..6411586 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: {} + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test From 752e0af5f423aff4569f30c32fb0ab54a2a44ced Mon Sep 17 00:00:00 2001 From: Maitrog Date: Wed, 8 Jul 2026 18:36:15 +0300 Subject: [PATCH 27/63] refactor: replace hardcoded gain storage key --- src/app/background/storageCleanup.ts | 2 +- src/infrastructure/chrome/storageKeys.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/background/storageCleanup.ts b/src/app/background/storageCleanup.ts index 5e869c4..4020ceb 100644 --- a/src/app/background/storageCleanup.ts +++ b/src/app/background/storageCleanup.ts @@ -4,7 +4,7 @@ const CONSTANT_STORAGE_KEYS = [ STORAGE_KEYS.PRESETS, STORAGE_KEYS.PRESET_NAMES, STORAGE_KEYS.AUTOSTART_RULES, - "gain", + STORAGE_KEYS.GAIN, STORAGE_KEYS.FILTERS, STORAGE_KEYS.ENABLE_SPECTRUM, STORAGE_KEYS.THEME, diff --git a/src/infrastructure/chrome/storageKeys.ts b/src/infrastructure/chrome/storageKeys.ts index abb505f..1bf0346 100644 --- a/src/infrastructure/chrome/storageKeys.ts +++ b/src/infrastructure/chrome/storageKeys.ts @@ -6,6 +6,7 @@ export const STORAGE_KEYS = { PRESETS: "presets", PRESET_NAMES: "presetNames", AUTOSTART_RULES: "whitelist", + GAIN: "gain", FILTERS: "filters", ENABLE_SPECTRUM: "enableSpectrum", THEME: "theme", From 0a2ba2f78978447ff07a1532f0455b0a000957a5 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Wed, 8 Jul 2026 19:24:58 +0300 Subject: [PATCH 28/63] feat: added spectrum visualization for window mod --- src/app/popup/createPopupApp.ts | 2 + .../createToolkitWindowController.test.ts | 338 ++++++++++++++++++ .../createToolkitWindowController.ts | 119 ++++++ 3 files changed, 459 insertions(+) create mode 100644 src/app/window-mode/createToolkitWindowController.test.ts diff --git a/src/app/popup/createPopupApp.ts b/src/app/popup/createPopupApp.ts index a7dd44e..b93d746 100644 --- a/src/app/popup/createPopupApp.ts +++ b/src/app/popup/createPopupApp.ts @@ -145,6 +145,8 @@ export const createPopupApp = ({ setGainValue, isMuted: () => elements.volumeMuteButton.className === "volume-mute-active", getMessage: localization.getMessage, + onSpectrumMeta: (meta) => spectrumRenderer.setMeta(meta), + onSpectrumFrame: (buffer) => spectrumRenderer.scheduleDraw(buffer), }); const getCurrentTabId = (): Promise => { diff --git a/src/app/window-mode/createToolkitWindowController.test.ts b/src/app/window-mode/createToolkitWindowController.test.ts new file mode 100644 index 0000000..d9712a2 --- /dev/null +++ b/src/app/window-mode/createToolkitWindowController.test.ts @@ -0,0 +1,338 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; +import { createToolkitWindowController } from "./createToolkitWindowController"; + +class FakeClassList { + add = vi.fn(); +} + +class FakeElement { + classList = new FakeClassList(); + className = ""; + disabled = false; + addEventListener = vi.fn(); + replaceChildren = vi.fn(); +} + +class FakeMediaStream { + getTracks(): Array<{ stop(): void }> { + return [{ stop: vi.fn() }]; + } +} + +class FakeAudioNode { + connections: unknown[] = []; + + connect(destination: unknown): unknown { + this.connections.push(destination); + return destination; + } + + disconnect(): void { + this.connections = []; + } +} + +class FakeGainNode extends FakeAudioNode { + gain = { value: 1 }; +} + +class FakeBiquadFilterNode extends FakeAudioNode { + type: BiquadFilterType = "peaking"; + gain = { value: 0 }; + frequency = { value: 0 }; + Q = { value: 0 }; +} + +class FakeAnalyserNode extends FakeAudioNode { + fftSize = 2048; + minDecibels = -100; + maxDecibels = -30; + frequencyBinCount = 8; + + getFloatFrequencyData(buffer: Float32Array): void { + buffer.fill(-37); + } +} + +class FakeAudioContext { + sampleRate = 44100; + destination = new FakeAudioNode(); + createdAnalyser: FakeAnalyserNode | null = null; + + resume = vi.fn(() => Promise.resolve()); + + createMediaStreamSource(): FakeAudioNode { + return new FakeAudioNode(); + } + + createGain(): FakeGainNode { + return new FakeGainNode(); + } + + createBiquadFilter(): FakeBiquadFilterNode { + return new FakeBiquadFilterNode(); + } + + createAnalyser(): FakeAnalyserNode { + this.createdAnalyser = new FakeAnalyserNode(); + return this.createdAnalyser; + } +} + +const createChromeStorage = () => { + const localValues: Record = { + [STORAGE_KEYS.ENABLE_SPECTRUM]: true, + }; + const sessionValues: Record = { + [STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]: 123, + [STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS]: { 123: "stream-123" }, + }; + + const getValues = ( + store: Record, + keys: string | string[] | Record, + ): Record => { + if (typeof keys === "string") { + return { [keys]: store[keys] }; + } + if (Array.isArray(keys)) { + return Object.fromEntries(keys.map((key) => [key, store[key]])); + } + + return Object.fromEntries( + Object.entries(keys).map(([key, fallback]) => [ + key, + store[key] ?? fallback, + ]), + ); + }; + + return { + local: { + get: vi.fn((keys) => Promise.resolve(getValues(localValues, keys))), + set: vi.fn((values: Record) => { + Object.assign(localValues, values); + return Promise.resolve(); + }), + remove: vi.fn((keys: string | string[]) => { + (Array.isArray(keys) ? keys : [keys]).forEach((key) => { + delete localValues[key]; + }); + return Promise.resolve(); + }), + }, + session: { + get: vi.fn((keys) => Promise.resolve(getValues(sessionValues, keys))), + set: vi.fn((values: Record) => { + Object.assign(sessionValues, values); + return Promise.resolve(); + }), + }, + }; +}; + +const createController = ( + deps: Partial[0]> = {}, +) => { + const audioContext = new FakeAudioContext(); + + const controller = createToolkitWindowController({ + body: new FakeElement() as unknown as HTMLElement, + capturedTabs: new FakeElement() as unknown as HTMLElement, + changeEqButton: new FakeElement() as unknown as HTMLButtonElement, + audioContext: audioContext as unknown as AudioContext, + equalizerState: {} as Parameters< + typeof createToolkitWindowController + >[0]["equalizerState"], + getDimensions: () => ({ canvasWidth: 500, canvasHeight: 200 }), + getPointCount: () => Promise.resolve(3), + getFilters: () => [ + { type: "peaking", freq: 1000, gain: 0, q: 0.5 }, + ], + setFilters: vi.fn(), + initPoints: vi.fn(), + resize: vi.fn(), + setEnableButtonText: vi.fn(), + setMuteButtonClass: vi.fn(), + renderCaptureError: vi.fn(), + getGainValue: () => 0, + setGainValue: vi.fn(), + isMuted: () => false, + getMessage: (messageName) => messageName, + ...deps, + }); + + return { controller, audioContext }; +}; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("createToolkitWindowController spectrum", () => { + test("emits spectrum meta and frames from the active captured tab", async () => { + const spectrumMeta: unknown[] = []; + const spectrumFrames: Array = []; + const storage = createChromeStorage(); + + vi.stubGlobal("window", { + location: { search: "?mode=window" }, + addEventListener: vi.fn(), + }); + vi.stubGlobal("document", { + createElement: () => new FakeElement(), + }); + vi.stubGlobal("navigator", { + mediaDevices: { + getUserMedia: vi.fn(() => Promise.resolve(new FakeMediaStream())), + }, + }); + vi.stubGlobal("chrome", { storage }); + vi.stubGlobal("setInterval", vi.fn((callback: () => void) => { + callback(); + return 1; + })); + vi.stubGlobal("clearInterval", vi.fn()); + + const { controller, audioContext } = createController({ + onSpectrumMeta: (meta) => spectrumMeta.push(meta), + onSpectrumFrame: (buffer) => spectrumFrames.push(buffer), + }); + + await controller.startTabCapture(); + + expect(audioContext.createdAnalyser).not.toBeNull(); + expect(spectrumMeta).toContainEqual({ + type: "meta", + sampleRate: 44100, + fftSize: 2048, + minDb: -100, + maxDb: -30, + frequencyBinCount: 8, + }); + expect(spectrumFrames.at(-1)?.[0]).toBe(-37); + }); + + test("clears spectrum frames when spectrum is disabled", async () => { + const spectrumFrames: Array = []; + const storage = createChromeStorage(); + + vi.stubGlobal("window", { + location: { search: "?mode=window" }, + addEventListener: vi.fn(), + }); + vi.stubGlobal("document", { + createElement: () => new FakeElement(), + }); + vi.stubGlobal("navigator", { + mediaDevices: { + getUserMedia: vi.fn(() => Promise.resolve(new FakeMediaStream())), + }, + }); + vi.stubGlobal("chrome", { storage }); + vi.stubGlobal("setInterval", vi.fn((callback: () => void) => { + callback(); + return 1; + })); + vi.stubGlobal("clearInterval", vi.fn()); + + const { controller } = createController({ + onSpectrumFrame: (buffer) => spectrumFrames.push(buffer), + }); + + await controller.startTabCapture(); + expect(spectrumFrames.at(-1)?.[0]).toBe(-37); + + await controller.handleStorageChange({ + [STORAGE_KEYS.ENABLE_SPECTRUM]: { + oldValue: true, + newValue: false, + }, + }); + + expect(clearInterval).toHaveBeenCalledWith(1); + expect(spectrumFrames.at(-1)).toBeNull(); + }); + + test("keeps the active spectrum timer running when capture settings refresh", async () => { + const storage = createChromeStorage(); + + vi.stubGlobal("window", { + location: { search: "?mode=window" }, + addEventListener: vi.fn(), + }); + vi.stubGlobal("document", { + createElement: () => new FakeElement(), + }); + vi.stubGlobal("navigator", { + mediaDevices: { + getUserMedia: vi.fn(() => Promise.resolve(new FakeMediaStream())), + }, + }); + vi.stubGlobal("chrome", { storage }); + vi.stubGlobal("setInterval", vi.fn((callback: () => void) => { + callback(); + return 1; + })); + vi.stubGlobal("clearInterval", vi.fn()); + + const { controller } = createController(); + + await controller.startTabCapture(); + vi.mocked(setInterval).mockClear(); + vi.mocked(clearInterval).mockClear(); + + controller.refreshCaptureFilters(123); + + expect(setInterval).not.toHaveBeenCalled(); + expect(clearInterval).not.toHaveBeenCalled(); + }); + + test("restarts spectrum when filter count changes rebuild the active graph", async () => { + const storage = createChromeStorage(); + let filters = [ + { type: "peaking" as const, freq: 1000, gain: 0, q: 0.5 }, + ]; + + vi.stubGlobal("window", { + location: { search: "?mode=window" }, + addEventListener: vi.fn(), + }); + vi.stubGlobal("document", { + createElement: () => new FakeElement(), + }); + vi.stubGlobal("navigator", { + mediaDevices: { + getUserMedia: vi.fn(() => Promise.resolve(new FakeMediaStream())), + }, + }); + vi.stubGlobal("chrome", { storage }); + vi.stubGlobal("setInterval", vi.fn((callback: () => void) => { + callback(); + return 1; + })); + vi.stubGlobal("clearInterval", vi.fn()); + + const { controller, audioContext } = createController({ + getFilters: () => filters, + }); + + await controller.startTabCapture(); + const firstAnalyser = audioContext.createdAnalyser; + vi.mocked(setInterval).mockClear(); + vi.mocked(clearInterval).mockClear(); + + filters = [ + { type: "peaking", freq: 500, gain: 0, q: 0.5 }, + { type: "peaking", freq: 1000, gain: 0, q: 0.5 }, + ]; + controller.refreshCaptureFilters(123); + + expect(audioContext.createdAnalyser).not.toBe(firstAnalyser); + expect(clearInterval).toHaveBeenCalledWith(1); + expect(setInterval).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/window-mode/createToolkitWindowController.ts b/src/app/window-mode/createToolkitWindowController.ts index 3769314..a033c47 100644 --- a/src/app/window-mode/createToolkitWindowController.ts +++ b/src/app/window-mode/createToolkitWindowController.ts @@ -16,9 +16,19 @@ interface ToolkitCapture { preamp: GainNode | null; filters: BiquadFilterNode[]; output: AudioNode | null; + analyser: AnalyserNode | null; filterSettings: EqualizerPersistedFilter[]; } +export interface ToolkitSpectrumMeta { + type: "meta"; + sampleRate: number; + fftSize: number; + minDb: number; + maxDb: number; + frequencyBinCount: number; +} + interface ToolkitBiquadInput { freq: number; gain: number; @@ -85,11 +95,17 @@ export const createToolkitWindowController = (deps: { setGainValue(value: number): void; isMuted(): boolean; getMessage(messageName: string): string; + onSpectrumMeta?(meta: ToolkitSpectrumMeta): void; + onSpectrumFrame?(buffer: Float32Array | null): void; }): ToolkitWindowController => { const isToolkitWindow = new URLSearchParams(window.location.search).get("mode") === "window"; let activeTabId: number | null = null; const captures = new Map(); let capturedTabsView: CapturedTabsView | null = null; + let spectrumEnabled = false; + let spectrumTimer: ReturnType | null = null; + let spectrumTabId: string | null = null; + let spectrumAnalyser: AnalyserNode | null = null; if (isToolkitWindow) { deps.body.classList.add("toolkit-window-body"); @@ -159,6 +175,16 @@ export const createToolkitWindowController = (deps: { ); }; + const stopSpectrum = (): void => { + if (spectrumTimer) { + clearInterval(spectrumTimer); + spectrumTimer = null; + } + spectrumTabId = null; + spectrumAnalyser = null; + deps.onSpectrumFrame?.(null); + }; + const disconnectCaptureGraph = (capture: ToolkitCapture): void => { try { capture.source.disconnect(); @@ -184,6 +210,7 @@ export const createToolkitWindowController = (deps: { capture.preamp = null; capture.filters = []; capture.output = null; + capture.analyser = null; }; const stopCaptureEntry = (capture: ToolkitCapture): void => { @@ -242,6 +269,10 @@ export const createToolkitWindowController = (deps: { capture.output = previousNode; capture.output.connect(deps.audioContext.destination); applyCaptureSettings(tabId); + + if (spectrumEnabled && Number(tabId) === activeTabId) { + startSpectrum(tabId); + } }; const refreshCaptureFilters = ( @@ -258,8 +289,73 @@ export const createToolkitWindowController = (deps: { } applyCaptureSettings(tabId); + + if (spectrumEnabled && Number(tabId) === activeTabId) { + startSpectrum(tabId); + } }; + const ensureSpectrumAnalyser = (capture: ToolkitCapture): AnalyserNode | null => { + if (!capture.output) return null; + if (capture.analyser) return capture.analyser; + + const analyser = deps.audioContext.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0.5; + capture.output.connect(analyser); + capture.analyser = analyser; + return analyser; + }; + + function startSpectrum(tabId: number | string | null = activeTabId): void { + if (!isToolkitWindow || !spectrumEnabled || tabId == null) { + stopSpectrum(); + return; + } + + const capture = captures.get(String(tabId)); + if (!capture?.output) { + stopSpectrum(); + return; + } + + const analyser = ensureSpectrumAnalyser(capture); + if (!analyser) { + stopSpectrum(); + return; + } + + const nextSpectrumTabId = String(tabId); + if ( + spectrumTimer && + spectrumTabId === nextSpectrumTabId && + spectrumAnalyser === analyser + ) { + return; + } + + if (spectrumTimer) { + clearInterval(spectrumTimer); + } + + spectrumTabId = nextSpectrumTabId; + spectrumAnalyser = analyser; + deps.onSpectrumMeta?.({ + type: "meta", + sampleRate: deps.audioContext.sampleRate, + fftSize: analyser.fftSize, + minDb: analyser.minDecibels, + maxDb: analyser.maxDecibels, + frequencyBinCount: analyser.frequencyBinCount, + }); + + spectrumTimer = setInterval(() => { + const buffer = new Float32Array(analyser.frequencyBinCount); + analyser.getFloatFrequencyData(buffer); + deps.onSpectrumFrame?.(buffer); + }, 50); + } + const loadTabSettings = async (tabId: number | null): Promise => { if (tabId == null) return; activeTabId = tabId; @@ -306,6 +402,12 @@ export const createToolkitWindowController = (deps: { const startTabCapture = async (): Promise => { if (!isToolkitWindow) return; + activeTabId = await getCurrentTabId(); + const spectrumSettings = await chrome.storage.local.get([ + STORAGE_KEYS.ENABLE_SPECTRUM, + ]); + spectrumEnabled = spectrumSettings[STORAGE_KEYS.ENABLE_SPECTRUM] === true; + const streamIds = await getCaptureStreamIds(); const streamEntries = Object.entries(streamIds); @@ -346,6 +448,7 @@ export const createToolkitWindowController = (deps: { preamp: null, filters: [], output: null, + analyser: null, filterSettings: [], }; captures.set(tabId, capture); @@ -355,6 +458,11 @@ export const createToolkitWindowController = (deps: { ); deps.renderCaptureError(null); + if (spectrumEnabled) { + startSpectrum(activeTabId); + } else { + stopSpectrum(); + } } catch (e) { const tabId = await getCurrentTabId(); const message = e instanceof Error ? e.message : "Tab audio capture failed"; @@ -378,6 +486,7 @@ export const createToolkitWindowController = (deps: { }; const stopTabCapture = (): void => { + stopSpectrum(); captures.forEach((capture) => stopCaptureEntry(capture)); captures.clear(); }; @@ -403,12 +512,22 @@ export const createToolkitWindowController = (deps: { | undefined) ?? null; await loadTabSettings(activeTabId); await renderCapturedTabs(); + startSpectrum(activeTabId); } if (isToolkitWindow && changes[STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS]) { await startTabCapture(); await renderCapturedTabs(); } + + if (isToolkitWindow && changes[STORAGE_KEYS.ENABLE_SPECTRUM]) { + spectrumEnabled = changes[STORAGE_KEYS.ENABLE_SPECTRUM].newValue === true; + if (spectrumEnabled) { + startSpectrum(activeTabId); + } else { + stopSpectrum(); + } + } }, }; }; From ab501b1aae1fe6b8d738ba37d7a7d5d545e104d4 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Wed, 8 Jul 2026 20:18:23 +0300 Subject: [PATCH 29/63] feat: added the ability to stop audio capturing on a specific tab --- resources/styles/popup.css | 30 +++++++++ .../createToolkitWindowController.test.ts | 31 ++++++++++ .../createToolkitWindowController.ts | 52 ++++++++++++++++ src/ui/popup/capturedTabsView.ts | 61 ++++++++++++++++--- 4 files changed, 165 insertions(+), 9 deletions(-) diff --git a/resources/styles/popup.css b/resources/styles/popup.css index 7a4741f..fa90745 100644 --- a/resources/styles/popup.css +++ b/resources/styles/popup.css @@ -239,6 +239,11 @@ body { box-sizing: border-box; } +.captured-tab:focus-visible { + outline: 1px solid var(--accent-mid); + outline-offset: 2px; +} + .captured-tab:hover { background: var(--hover-bg); } @@ -255,11 +260,36 @@ body { } .captured-tab-title { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.captured-tab-stop { + flex: 0 0 18px; + width: 18px; + height: 18px; + padding: 0; + border-radius: 50%; + font-size: 13px; + font-weight: 700; + line-height: 18px; + text-align: center; + background: transparent; + color: var(--text-volume-scale); +} + +.captured-tab-stop::before { + content: "\00d7"; +} + +.captured-tab-stop:hover, +.captured-tab-stop:focus-visible { + background: var(--hover-bg); + color: var(--text); +} + .equalizer-curve-container { width: 100%; max-width: 800px; diff --git a/src/app/window-mode/createToolkitWindowController.test.ts b/src/app/window-mode/createToolkitWindowController.test.ts index d9712a2..c91e828 100644 --- a/src/app/window-mode/createToolkitWindowController.test.ts +++ b/src/app/window-mode/createToolkitWindowController.test.ts @@ -130,6 +130,7 @@ const createChromeStorage = () => { return Promise.resolve(); }), }, + sessionValues, }; }; @@ -335,4 +336,34 @@ describe("createToolkitWindowController spectrum", () => { expect(clearInterval).toHaveBeenCalledWith(1); expect(setInterval).toHaveBeenCalledTimes(1); }); + + test("removes one captured tab from window capture storage", async () => { + const storage = createChromeStorage(); + storage.sessionValues[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS] = [123, 456]; + storage.sessionValues[STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS] = { + 123: "stream-123", + 456: "stream-456", + }; + + vi.stubGlobal("window", { + location: { search: "?mode=window" }, + addEventListener: vi.fn(), + }); + vi.stubGlobal("document", { + createElement: () => new FakeElement(), + }); + vi.stubGlobal("chrome", { storage }); + + const { controller } = createController(); + + await controller.stopCapturedTabCapture(123); + + expect(storage.session.set).toHaveBeenCalledWith({ + [STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS]: [456], + [STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]: 456, + [STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS]: { + 456: "stream-456", + }, + }); + }); }); diff --git a/src/app/window-mode/createToolkitWindowController.ts b/src/app/window-mode/createToolkitWindowController.ts index a033c47..d27e95f 100644 --- a/src/app/window-mode/createToolkitWindowController.ts +++ b/src/app/window-mode/createToolkitWindowController.ts @@ -72,6 +72,7 @@ export interface ToolkitWindowController { renderCapturedTabs(): Promise; refreshCaptureFilters(tabId?: number | null): void; applyCaptureSettings(tabId?: number | null): void; + stopCapturedTabCapture(tabId: number): Promise; stopTabCapture(): void; handleStorageChange(changes: Record): Promise; } @@ -479,6 +480,9 @@ export const createToolkitWindowController = (deps: { root: deps.capturedTabs, isToolkitWindow, onSelectTab: loadTabSettings, + onStopCapture: async (tabId) => { + await stopCapturedTabCapture(tabId); + }, }); const renderCapturedTabs = async (): Promise => { @@ -491,6 +495,53 @@ export const createToolkitWindowController = (deps: { captures.clear(); }; + const stopCapturedTabCapture = async (tabId: number): Promise => { + if (!isToolkitWindow) return; + + const capture = captures.get(String(tabId)); + if (capture) { + stopCaptureEntry(capture); + captures.delete(String(tabId)); + } + + const stored = await chrome.storage.session.get([ + STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS, + STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID, + STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS, + ]); + const remainingTabIds = Array.isArray(stored[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS]) + ? (stored[STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS] as number[]).filter( + (storedTabId) => storedTabId !== tabId, + ) + : []; + const streamIds = { + ...((stored[STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS] as + | Record + | undefined) ?? {}), + }; + delete streamIds[tabId]; + + const storedActiveTabId = + (stored[STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID] as number | undefined) ?? + activeTabId; + activeTabId = + storedActiveTabId === tabId + ? remainingTabIds[0] ?? null + : storedActiveTabId ?? null; + + if (activeTabId == null || activeTabId === tabId) { + stopSpectrum(); + } else { + startSpectrum(activeTabId); + } + + await chrome.storage.session.set({ + [STORAGE_KEYS.TOOLKIT_WINDOW_TAB_IDS]: remainingTabIds, + [STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]: activeTabId, + [STORAGE_KEYS.TOOLKIT_WINDOW_CAPTURE_STREAM_IDS]: streamIds, + }); + }; + window.addEventListener("beforeunload", stopTabCapture); return { @@ -503,6 +554,7 @@ export const createToolkitWindowController = (deps: { renderCapturedTabs, refreshCaptureFilters, applyCaptureSettings, + stopCapturedTabCapture, stopTabCapture, handleStorageChange: async (changes) => { if (isToolkitWindow && changes[STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]) { diff --git a/src/ui/popup/capturedTabsView.ts b/src/ui/popup/capturedTabsView.ts index 946e18c..b667141 100644 --- a/src/ui/popup/capturedTabsView.ts +++ b/src/ui/popup/capturedTabsView.ts @@ -29,6 +29,7 @@ export const createCapturedTabsView = (deps: { root: HTMLElement; isToolkitWindow: boolean; onSelectTab(tabId: number): Promise; + onStopCapture(tabId: number): Promise; }): CapturedTabsView => { const render = async (): Promise => { if (!deps.isToolkitWindow) return; @@ -41,9 +42,10 @@ export const createCapturedTabsView = (deps: { deps.root.replaceChildren(); result.tabs.forEach((tab) => { - const item = document.createElement("button"); - item.type = "button"; + const item = document.createElement("div"); item.className = "captured-tab"; + item.role = "button"; + item.tabIndex = 0; if (tab.id === result.activeTabId) item.classList.add("active"); item.dataset.tabId = String(tab.id); item.title = tab.title || tab.url || String(tab.id); @@ -61,10 +63,39 @@ export const createCapturedTabsView = (deps: { title.textContent = tab.title || tab.url || `Tab ${tab.id}`; item.appendChild(title); + const stopButton = document.createElement("button"); + stopButton.type = "button"; + stopButton.className = "captured-tab-stop"; + item.appendChild(stopButton); + deps.root.appendChild(item); }); }; + const getTabId = (item: HTMLElement): number | null => { + const tabId = Number.parseInt(item.dataset.tabId ?? "", 10); + return Number.isNaN(tabId) ? null : tabId; + }; + + const selectTab = async (item: HTMLElement): Promise => { + const tabId = getTabId(item); + if (tabId == null) return; + + await chrome.storage.session.set({ + [STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]: tabId, + }); + await deps.onSelectTab(tabId); + await render(); + }; + + const stopCapture = async (item: HTMLElement): Promise => { + const tabId = getTabId(item); + if (tabId == null) return; + + await deps.onStopCapture(tabId); + await render(); + }; + deps.root.addEventListener("click", (event) => { void (async () => { if (!(event.target instanceof Element)) return; @@ -72,14 +103,26 @@ export const createCapturedTabsView = (deps: { const item = event.target.closest(".captured-tab"); if (!item) return; - const tabId = Number.parseInt(item.dataset.tabId ?? "", 10); - if (Number.isNaN(tabId)) return; + if (event.target.closest(".captured-tab-stop")) { + await stopCapture(item); + return; + } + + await selectTab(item); + })(); + }); + + deps.root.addEventListener("keydown", (event) => { + void (async () => { + if (!(event.target instanceof HTMLElement)) return; + if (event.target.closest(".captured-tab-stop")) return; + if (event.key !== "Enter" && event.key !== " ") return; + + const item = event.target.closest(".captured-tab"); + if (!item) return; - await chrome.storage.session.set({ - [STORAGE_KEYS.TOOLKIT_WINDOW_ACTIVE_TAB_ID]: tabId, - }); - await deps.onSelectTab(tabId); - await render(); + event.preventDefault(); + await selectTab(item); })(); }); From 661be12f2f69eef1bf94845c0108c636fa3149d7 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Thu, 9 Jul 2026 10:45:03 +0300 Subject: [PATCH 30/63] refactor: changed gain calculation from db --- src/domains/equalizer/equalizerMath.test.ts | 5 ----- src/domains/equalizer/equalizerMath.ts | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/domains/equalizer/equalizerMath.test.ts b/src/domains/equalizer/equalizerMath.test.ts index 2014481..e595044 100644 --- a/src/domains/equalizer/equalizerMath.test.ts +++ b/src/domains/equalizer/equalizerMath.test.ts @@ -34,9 +34,4 @@ describe("equalizerMath", () => { expect(yToDb(100, 200)).toBe(0); expect(yToDb(20, 200)).toBe(25); }); - - test("converts decibels to gain", () => { - expect(dbToGain(6)).toBe(3); - expect(dbToGain(-3)).toBe(0.5); - }); }); diff --git a/src/domains/equalizer/equalizerMath.ts b/src/domains/equalizer/equalizerMath.ts index 70fc879..7f3dac6 100644 --- a/src/domains/equalizer/equalizerMath.ts +++ b/src/domains/equalizer/equalizerMath.ts @@ -52,8 +52,8 @@ export const yToDb = (y: number, canvasHeight: number): number => { export const dbToGain = (db: number): number => { if (db >= 0) { - return 1 + db / 3; + return 1 * Math.pow(1.12, db); } - return 1.5 / Math.abs(db); + return 1 / Math.pow(1.12, Math.abs(db)); }; From 6fc99facc76e78fec2fce7d71911acbae6ace27f Mon Sep 17 00:00:00 2001 From: Maitrog Date: Thu, 9 Jul 2026 10:47:21 +0300 Subject: [PATCH 31/63] doc: removed reference to google analytics from PRIVACY.md --- PRIVACY.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 9d7e735..9e9e444 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -12,9 +12,6 @@ All user settings are stored locally in your browser. No data is stored on exter ## Cookies We may use cookies to enhance user experience, store preferences, and improve functionality. You can disable cookies in your browser settings. -## Analytics -We use Google Analytics to analyze usage trends and improve our services. - ## Security We take strong security measures to protect user data from unauthorized access and ensure data integrity. From 2bc16c012f4d9273b978aac9b5c519b8b4fb63e1 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Thu, 9 Jul 2026 18:50:32 +0300 Subject: [PATCH 32/63] fix: resolve issue that translation wasn't being applied for translators-label --- src/domains/localization/localizationService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/domains/localization/localizationService.ts b/src/domains/localization/localizationService.ts index 24da180..eecb4ba 100644 --- a/src/domains/localization/localizationService.ts +++ b/src/domains/localization/localizationService.ts @@ -64,6 +64,7 @@ const setFirstTextNodeContent = ( id: string, messageName: string, getMessage: (messageName: string) => string, + suffix = "", ): void => { const element = root.getElementById(id); if (!element) return; @@ -71,7 +72,7 @@ const setFirstTextNodeContent = ( const textNode = Array.from(element.childNodes).find( (node) => node.nodeType === 3, ); - if (textNode) textNode.textContent = getMessage(messageName); + if (textNode) textNode.textContent = `${getMessage(messageName)}${suffix}`; }; const loadLocaleMessages = async (locale: string): Promise => { @@ -117,7 +118,9 @@ export const createLocalizationService = (): LocalizationService => { setElementTextContent(root, "shortcut-mute-label", "shortcut_mute_label", getMessage); setElementTextContent(root, "shortcut-toggle-eq-label", "shortcut_toggle_eq_label", getMessage); setElementTextContent(root, "presets-settings-title", "presets_settings_title", getMessage); + setElementTextContent(root, "hide-default-presets-label", "hide_default_presets_label", getMessage); setElementTextContent(root, "community-settings-title", "community_settings_title", getMessage); + setFirstTextNodeContent(root, "translators-label", "translators_label", getMessage, " "); setFirstTextNodeContent(root, "help-with-translation-label", "help_with_translation_label", getMessage); setFirstTextNodeContent(root, "source-code-label", "source_code_label", getMessage); setElementTextContent(root, "autostart-settings-title", "autostart_settings_title", getMessage); From 1e040db019a812fffe05fe7ae56639cbbbee8a00 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Thu, 9 Jul 2026 19:28:16 +0300 Subject: [PATCH 33/63] feat: added default audio presets with the ability to hide them --- _locales/bn/messages.json | 3 + _locales/de/messages.json | 3 + _locales/en/messages.json | 5 +- _locales/es/messages.json | 3 + _locales/fr/messages.json | 3 + _locales/hi/messages.json | 3 + _locales/id/messages.json | 3 + _locales/it/messages.json | 3 + _locales/ja/messages.json | 3 + _locales/ko/messages.json | 3 + _locales/nl/messages.json | 3 + _locales/pt_PT/messages.json | 3 + _locales/ru/messages.json | 3 + _locales/ta/messages.json | 5 +- _locales/th/messages.json | 3 + _locales/tr/messages.json | 3 + _locales/vi/messages.json | 3 + _locales/zh_CN/messages.json | 3 + popup.html | 6 +- resources/styles/popup.css | 4 +- src/app/background/autostartOnTab.test.ts | 36 +++++++ src/app/background/autostartOnTab.ts | 7 +- src/app/popup/createPopupApp.ts | 27 ++++- src/domains/presets/defaultPresets.test.ts | 39 ++++++++ src/domains/presets/defaultPresets.ts | 111 +++++++++++++++++++++ src/infrastructure/chrome/storageKeys.ts | 1 + src/infrastructure/dom/popupElements.ts | 12 +++ src/ui/popup/autostartView.ts | 21 +++- src/ui/popup/presetsView.ts | 50 ++++++++-- src/ui/popup/settingsView.ts | 20 +++- 30 files changed, 367 insertions(+), 25 deletions(-) create mode 100644 src/app/background/autostartOnTab.test.ts create mode 100644 src/domains/presets/defaultPresets.test.ts create mode 100644 src/domains/presets/defaultPresets.ts diff --git a/_locales/bn/messages.json b/_locales/bn/messages.json index e14ca57..3943ed5 100644 --- a/_locales/bn/messages.json +++ b/_locales/bn/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "প্রিসেট" }, + "hide_default_presets_label": { + "message": "ডিফল্ট প্রিসেট লুকান" + }, "community_settings_title": { "message": "কমিউনিটি" }, diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 4347f34..0fde0a2 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Voreinstellungen" }, + "hide_default_presets_label": { + "message": "Standard-Presets ausblenden" + }, "community_settings_title": { "message": "Community" }, diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 79de247..b6e2d90 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Presets" }, + "hide_default_presets_label": { + "message": "Hide default presets" + }, "community_settings_title": { "message": "Community" }, @@ -146,4 +149,4 @@ "shortcut_duplicate_error": { "message": "This shortcut is already used by another command." } -} +} \ No newline at end of file diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 4444ac2..5d932b5 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Preajustes" }, + "hide_default_presets_label": { + "message": "Ocultar preajustes predeterminados" + }, "community_settings_title": { "message": "Comunidad" }, diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 7247d43..91990bd 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Préréglages" }, + "hide_default_presets_label": { + "message": "Masquer les préréglages par défaut" + }, "community_settings_title": { "message": "Communauté" }, diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index 3995874..b1e6081 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "प्रीसेट" }, + "hide_default_presets_label": { + "message": "डिफ़ॉल्ट प्रीसेट छिपाएं" + }, "community_settings_title": { "message": "समुदाय" }, diff --git a/_locales/id/messages.json b/_locales/id/messages.json index 9205b4c..0439fa8 100644 --- a/_locales/id/messages.json +++ b/_locales/id/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Preset" }, + "hide_default_presets_label": { + "message": "Sembunyikan preset bawaan" + }, "community_settings_title": { "message": "Komunitas" }, diff --git a/_locales/it/messages.json b/_locales/it/messages.json index 577487e..1faa09b 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Presets" }, + "hide_default_presets_label": { + "message": "Nascondi preset predefiniti" + }, "community_settings_title": { "message": "Community" }, diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index d57b418..1c26caf 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "プリセット" }, + "hide_default_presets_label": { + "message": "デフォルトプリセットを非表示" + }, "community_settings_title": { "message": "コミュニティ" }, diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index c40917d..0de3046 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "프리셋" }, + "hide_default_presets_label": { + "message": "기본 프리셋 숨기기" + }, "community_settings_title": { "message": "커뮤니티" }, diff --git a/_locales/nl/messages.json b/_locales/nl/messages.json index 9f64973..28410c4 100644 --- a/_locales/nl/messages.json +++ b/_locales/nl/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Voorinstellingen" }, + "hide_default_presets_label": { + "message": "Standaardpresets verbergen" + }, "community_settings_title": { "message": "Community" }, diff --git a/_locales/pt_PT/messages.json b/_locales/pt_PT/messages.json index 01368ed..d384e2d 100644 --- a/_locales/pt_PT/messages.json +++ b/_locales/pt_PT/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Predefinições" }, + "hide_default_presets_label": { + "message": "Ocultar predefinições padrão" + }, "community_settings_title": { "message": "Comunidade" }, diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index c094ed9..a043bc2 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Пресеты" }, + "hide_default_presets_label": { + "message": "Скрыть стандартные пресеты" + }, "community_settings_title": { "message": "Сообщество" }, diff --git a/_locales/ta/messages.json b/_locales/ta/messages.json index 5ef30cb..633879e 100644 --- a/_locales/ta/messages.json +++ b/_locales/ta/messages.json @@ -29,6 +29,9 @@ "presets_settings_title": { "message": "முன்னமைவுகள்" }, + "hide_default_presets_label": { + "message": "இயல்புநிலை முன்னமைவுகளை மறை" + }, "community_settings_title": { "message": "சமூகம்" }, @@ -146,4 +149,4 @@ "extName": { "message": "EQ Toolkit - ஆடியோ சமநிலைப்படுத்தி" } -} \ No newline at end of file +} diff --git a/_locales/th/messages.json b/_locales/th/messages.json index fa5f363..901c175 100644 --- a/_locales/th/messages.json +++ b/_locales/th/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "พรีเซ็ต" }, + "hide_default_presets_label": { + "message": "ซ่อนพรีเซ็ตเริ่มต้น" + }, "community_settings_title": { "message": "ชุมชน" }, diff --git a/_locales/tr/messages.json b/_locales/tr/messages.json index ce5d81a..aba26cc 100644 --- a/_locales/tr/messages.json +++ b/_locales/tr/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Ön ayarlar" }, + "hide_default_presets_label": { + "message": "Varsayılan ön ayarları gizle" + }, "community_settings_title": { "message": "Topluluk" }, diff --git a/_locales/vi/messages.json b/_locales/vi/messages.json index 3892755..2cba9b0 100644 --- a/_locales/vi/messages.json +++ b/_locales/vi/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "Cấu hình sẵn" }, + "hide_default_presets_label": { + "message": "Ẩn cấu hình sẵn mặc định" + }, "community_settings_title": { "message": "Cộng đồng" }, diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 87cc945..d40abb5 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -32,6 +32,9 @@ "presets_settings_title": { "message": "预设" }, + "hide_default_presets_label": { + "message": "隐藏默认预设" + }, "community_settings_title": { "message": "社区" }, diff --git a/popup.html b/popup.html index eef1169..08bdf8d 100644 --- a/popup.html +++ b/popup.html @@ -58,7 +58,7 @@

Presets

+
diff --git a/resources/styles/popup.css b/resources/styles/popup.css index fa90745..b54f6af 100644 --- a/resources/styles/popup.css +++ b/resources/styles/popup.css @@ -510,6 +510,8 @@ button:disabled:active { max-height: 200px; overflow-y: auto; z-index: 10; + scrollbar-width: thin; + scrollbar-color: var(--text-placeholder) transparent; } .dropdown-item { @@ -654,7 +656,6 @@ button:disabled:active { display: flex; align-items: center; gap: 8px; - margin-top: 15px; cursor: pointer; } @@ -898,6 +899,7 @@ button:disabled:active { display: flex; justify-content: space-between; gap: 10px; + margin-top: 8px; } .settings-actions button { diff --git a/src/app/background/autostartOnTab.test.ts b/src/app/background/autostartOnTab.test.ts new file mode 100644 index 0000000..16534b0 --- /dev/null +++ b/src/app/background/autostartOnTab.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test, vi } from "vitest"; + +import { DEFAULT_PRESETS } from "../../domains/presets/defaultPresets"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; +import { applyAutostartForTab } from "./autostartOnTab"; + +describe("applyAutostartForTab", () => { + test("applies a default preset matched by whitelist", async () => { + const set = vi.fn(); + vi.stubGlobal("chrome", { + storage: { + local: { + get: vi.fn().mockResolvedValue({ + [STORAGE_KEYS.AUTOSTART_RULES]: [ + { + type: "domain", + value: "example.com", + presetName: DEFAULT_PRESETS[0].name, + }, + ], + [STORAGE_KEYS.PRESETS]: {}, + }), + set, + }, + }, + }); + + await applyAutostartForTab(123, "https://example.com/watch"); + + expect(set).toHaveBeenCalledWith({ + [STORAGE_KEYS.tabFilters(123)]: DEFAULT_PRESETS[0].filters, + [STORAGE_KEYS.FILTERS]: DEFAULT_PRESETS[0].filters, + [STORAGE_KEYS.tabEnabled(123)]: true, + }); + }); +}); diff --git a/src/app/background/autostartOnTab.ts b/src/app/background/autostartOnTab.ts index 53bbe13..aa1ae03 100644 --- a/src/app/background/autostartOnTab.ts +++ b/src/app/background/autostartOnTab.ts @@ -1,12 +1,11 @@ import { findWhitelistMatch } from "../../domains/autostart/autostartRules"; +import { resolvePresetFilters, type PresetStorage } from "../../domains/presets/defaultPresets"; import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; export interface ApplyAutostartOptions { resetWhenNoMatch?: boolean; } -type PresetStore = Record; - export const applyAutostartForTab = async ( tabId: number | undefined, url: string | undefined, @@ -29,8 +28,8 @@ export const applyAutostartForTab = async ( return; } - const presets = stored[STORAGE_KEYS.PRESETS] as PresetStore | undefined; - const preset = presets?.[entry.presetName]; + const presets = stored[STORAGE_KEYS.PRESETS] as PresetStorage | undefined; + const preset = resolvePresetFilters(entry.presetName, presets); if (!Array.isArray(preset) || preset.length === 0) { await chrome.storage.local.set({ [STORAGE_KEYS.tabEnabled(tabId)]: false }); return; diff --git a/src/app/popup/createPopupApp.ts b/src/app/popup/createPopupApp.ts index b93d746..2d9311e 100644 --- a/src/app/popup/createPopupApp.ts +++ b/src/app/popup/createPopupApp.ts @@ -205,9 +205,24 @@ export const createPopupApp = ({ await chrome.storage.local.set(nextValues); }; + const refreshPresetDropdown = async (): Promise => { + const stored = await chrome.storage.local.get([ + STORAGE_KEYS.PRESET_NAMES, + STORAGE_KEYS.HIDE_DEFAULT_PRESETS, + ]); + presetsView.renderPresetNames( + (stored[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[], + { + includeDefaultPresets: + stored[STORAGE_KEYS.HIDE_DEFAULT_PRESETS] !== true, + }, + ); + }; + const refreshDynamicContent = async (): Promise => { await autostartView.renderWhitelist(); await autostartView.refreshPresetSelects(); + await refreshPresetDropdown(); controlsView.setEnableButtonText(false); }; @@ -348,6 +363,7 @@ export const createPopupApp = ({ importPresetsButton: elements.importPresetsButton, importInput: elements.importInput, enableSpectrum: elements.enableSpectrum, + hideDefaultPresets: elements.hideDefaultPresets, languageSelect: elements.languageSelect, shortcutMute: elements.shortcutMute, shortcutToggleEq: elements.shortcutToggleEq, @@ -477,6 +493,7 @@ export const createPopupApp = ({ STORAGE_KEYS.tabGain(tabId), STORAGE_KEYS.PRESETS, STORAGE_KEYS.PRESET_NAMES, + STORAGE_KEYS.HIDE_DEFAULT_PRESETS, STORAGE_KEYS.tabEnabled(tabId), STORAGE_KEYS.tabMute(tabId), STORAGE_KEYS.ENABLE_SPECTRUM, @@ -514,9 +531,13 @@ export const createPopupApp = ({ elements.enableSpectrum.checked = true; } - ((result[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[]).forEach((name) => { - presetsView.addPresetToDropdown(name); - }); + presetsView.renderPresetNames( + (result[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[], + { + includeDefaultPresets: + result[STORAGE_KEYS.HIDE_DEFAULT_PRESETS] !== true, + }, + ); renderCaptureError( typeof result[STORAGE_KEYS.tabCaptureError(tabId)] === "string" diff --git a/src/domains/presets/defaultPresets.test.ts b/src/domains/presets/defaultPresets.test.ts new file mode 100644 index 0000000..626fe8e --- /dev/null +++ b/src/domains/presets/defaultPresets.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; + +import { + DEFAULT_PRESETS, + getAvailablePresetNames, + isDefaultPresetName, + resolvePresetFilters, +} from "./defaultPresets"; + +describe("default presets", () => { + test("keeps user preset names before default preset names", () => { + const names = getAvailablePresetNames(["Custom", DEFAULT_PRESETS[0].name]); + + expect(names[0]).toBe("Custom"); + expect(names.slice(1)).toEqual( + DEFAULT_PRESETS.map((preset) => preset.name), + ); + expect(names.filter((name) => name === DEFAULT_PRESETS[0].name)).toHaveLength(1); + }); + + test("hides default preset names when requested", () => { + expect( + getAvailablePresetNames(["Custom", DEFAULT_PRESETS[0].name], { + includeDefaultPresets: false, + }), + ).toEqual(["Custom"]); + }); + + test("resolves default presets without user storage", () => { + const preset = DEFAULT_PRESETS[0]; + + expect(resolvePresetFilters(preset.name, {})).toEqual(preset.filters); + }); + + test("marks default presets as immutable", () => { + expect(isDefaultPresetName(DEFAULT_PRESETS[0].name)).toBe(true); + expect(isDefaultPresetName("Custom")).toBe(false); + }); +}); diff --git a/src/domains/presets/defaultPresets.ts b/src/domains/presets/defaultPresets.ts new file mode 100644 index 0000000..48befb3 --- /dev/null +++ b/src/domains/presets/defaultPresets.ts @@ -0,0 +1,111 @@ +import type { EqualizerPersistedFilter } from "../equalizer/equalizerState"; + +export interface DefaultPreset { + name: string; + filters: EqualizerPersistedFilter[]; +} + +export type PresetStorage = Record; + +const createPresetFilters = ( + gains: [number, number, number, number, number, number, number, number, number], +): EqualizerPersistedFilter[] => { + const freqs = [64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; + + return [ + { type: "highpass", freq: 20, gain: 0, q: 0.5 }, + ...freqs.map((freq, index): EqualizerPersistedFilter => { + return { + type: "peaking", + freq, + gain: gains[index], + q: 0.5, + }; + }), + { type: "lowpass", freq: 20000, gain: 0, q: 0.5 }, + ]; +}; + +export const DEFAULT_PRESETS: DefaultPreset[] = [ + { + name: "Bass Boost", + filters: createPresetFilters([13, 10, 7, 3, 0, 0, 0, 0, 0]), + }, + { + name: "Acoustic", + filters: createPresetFilters([15, 10, 4, 7, 7, 10, 12, 10, 5]), + }, + { + name: "Bass Reducer", + filters: createPresetFilters([-12, -10, -8, -5, 0, 0, 7, 10, 12]), + }, + { + name: "Classical", + filters: createPresetFilters([12, 10, 8, -5, -5, 0, 7, 10, 12]), + }, + { + name: "Deep", + filters: createPresetFilters([12, 5, 3, 10, 8, 5, -6, -12, -15]), + }, + { + name: "Electronic", + filters: createPresetFilters([13, 4, 0, -6, 6, 3, 4, 13, 15]), + }, + { + name: "Hip Hop", + filters: createPresetFilters([14, 4, 10, -4, -3, 4, -2, 6, 10]), + }, + { + name: "Jazz", + filters: createPresetFilters([10, 4, 6, -5, -5, 0, 4, 10, 13]), + }, + { + name: "Latin", + filters: createPresetFilters([5, 0, 0, -5, -5, -5, 0, 10, 15]), + }, + { + name: "Lounge", + filters: createPresetFilters([-8, -2, 4, 13, 4, 0, -5, 6, 3]), + }, + { + name: "Piano", + filters: createPresetFilters([6, 0, 9, 10, 5, 11, 15, 10, 11]), + }, + { + name: "Pop", + filters: createPresetFilters([-4, 0, 6, 15, 13, 6, 0, -3, -5]), + }, + { + name: "Rock", + filters: createPresetFilters([13, 10, 4, -1, -2, 1, 8, 11, 15]), + }, +]; + +const defaultPresetMap = new Map( + DEFAULT_PRESETS.map((preset) => [preset.name, preset.filters]), +); + +export const isDefaultPresetName = (name: string): boolean => { + return defaultPresetMap.has(name); +}; + +export const getAvailablePresetNames = ( + userPresetNames: string[], + options: { includeDefaultPresets?: boolean } = {}, +): string[] => { + const defaultNames = DEFAULT_PRESETS.map((preset) => preset.name); + const userNames = userPresetNames.filter((name) => !isDefaultPresetName(name)); + + if (options.includeDefaultPresets === false) { + return userNames; + } + + return [...userNames, ...defaultNames]; +}; + +export const resolvePresetFilters = ( + name: string, + userPresets: PresetStorage | undefined, +): EqualizerPersistedFilter[] | undefined => { + return defaultPresetMap.get(name) ?? userPresets?.[name]; +}; diff --git a/src/infrastructure/chrome/storageKeys.ts b/src/infrastructure/chrome/storageKeys.ts index 1bf0346..67e89ed 100644 --- a/src/infrastructure/chrome/storageKeys.ts +++ b/src/infrastructure/chrome/storageKeys.ts @@ -5,6 +5,7 @@ type DynamicStorageKeyPart = number | string; export const STORAGE_KEYS = { PRESETS: "presets", PRESET_NAMES: "presetNames", + HIDE_DEFAULT_PRESETS: "hideDefaultPresets", AUTOSTART_RULES: "whitelist", GAIN: "gain", FILTERS: "filters", diff --git a/src/infrastructure/dom/popupElements.ts b/src/infrastructure/dom/popupElements.ts index 0d992e8..cd9e3ad 100644 --- a/src/infrastructure/dom/popupElements.ts +++ b/src/infrastructure/dom/popupElements.ts @@ -52,6 +52,8 @@ export interface PopupElements { autostartSettingsAddButton: HTMLButtonElement; autostartSettingsError: HTMLDivElement; presetsSettingsTitle: HTMLHeadingElement; + hideDefaultPresetsLabel: HTMLElement; + hideDefaultPresets: HTMLInputElement; importInput: HTMLInputElement; importPresetsButton: HTMLButtonElement; exportPresetsButton: HTMLButtonElement; @@ -212,6 +214,16 @@ export const getPopupElements = (document: Document): PopupElements => ({ "presets-settings-title", HTMLHeadingElement, ), + hideDefaultPresetsLabel: getRequiredElement( + document, + "hide-default-presets-label", + HTMLElement, + ), + hideDefaultPresets: getRequiredElement( + document, + "hide-default-presets", + HTMLInputElement, + ), importInput: getRequiredElement(document, "import-input", HTMLInputElement), importPresetsButton: getRequiredElement( document, diff --git a/src/ui/popup/autostartView.ts b/src/ui/popup/autostartView.ts index c981732..ec67273 100644 --- a/src/ui/popup/autostartView.ts +++ b/src/ui/popup/autostartView.ts @@ -5,6 +5,7 @@ import { type AutostartEntryType, type AutostartWhitelistEntry, } from "../../domains/autostart/autostartRules"; +import { getAvailablePresetNames } from "../../domains/presets/defaultPresets"; import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; export interface AutostartView { @@ -70,8 +71,17 @@ export const createAutostartView = (deps: { }; const refreshPresetSelects = async (): Promise => { - const stored = await chrome.storage.local.get([STORAGE_KEYS.PRESET_NAMES]); - const presetNames = (stored[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[]; + const stored = await chrome.storage.local.get([ + STORAGE_KEYS.PRESET_NAMES, + STORAGE_KEYS.HIDE_DEFAULT_PRESETS, + ]); + const presetNames = getAvailablePresetNames( + (stored[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[], + { + includeDefaultPresets: + stored[STORAGE_KEYS.HIDE_DEFAULT_PRESETS] !== true, + }, + ); fillPresetSelect(deps.modalPreset, presetNames); fillPresetSelect(deps.settingsAddPreset, presetNames); }; @@ -215,7 +225,12 @@ export const createAutostartView = (deps: { chrome.storage.onChanged.addListener((changes, areaName) => { if (areaName !== "local") return; if (changes[STORAGE_KEYS.AUTOSTART_RULES]) void renderWhitelist(); - if (changes[STORAGE_KEYS.PRESET_NAMES]) void refreshPresetSelects(); + if ( + changes[STORAGE_KEYS.PRESET_NAMES] || + changes[STORAGE_KEYS.HIDE_DEFAULT_PRESETS] + ) { + void refreshPresetSelects(); + } }); return { diff --git a/src/ui/popup/presetsView.ts b/src/ui/popup/presetsView.ts index 0e9f774..dd9faab 100644 --- a/src/ui/popup/presetsView.ts +++ b/src/ui/popup/presetsView.ts @@ -1,13 +1,21 @@ import { isPresetUsedInWhitelist } from "../../domains/autostart/autostartRules"; import type { EqualizerPersistedFilter } from "../../domains/equalizer/equalizerState"; +import { + getAvailablePresetNames, + isDefaultPresetName, + resolvePresetFilters, + type PresetStorage, +} from "../../domains/presets/defaultPresets"; import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; export interface PresetsView { - addPresetToDropdown(name: string): void; + addPresetToDropdown(name: string, options?: { deletable?: boolean }): void; + renderPresetNames( + userPresetNames: string[], + options?: { includeDefaultPresets?: boolean }, + ): void; } -type PresetStorage = Record; - export const createPresetsView = (deps: { dropdown: HTMLElement; toggle: HTMLElement; @@ -23,25 +31,44 @@ export const createPresetsView = (deps: { redraw(): void; refreshToolkitCaptureFilters(): void; }): PresetsView => { - const addPresetToDropdown = (name: string): void => { + const addPresetToDropdown = ( + name: string, + options: { deletable?: boolean } = {}, + ): void => { const option = document.createElement("div"); option.textContent = name; option.setAttribute("data-value", name); option.className = "dropdown-item"; - const closeButton = document.createElement("span"); - closeButton.className = "close-btn"; - closeButton.textContent = "\u00d7"; - closeButton.setAttribute("data-value", name); - option.appendChild(closeButton); + if (options.deletable ?? true) { + const closeButton = document.createElement("span"); + closeButton.className = "close-btn"; + closeButton.textContent = "\u00d7"; + closeButton.setAttribute("data-value", name); + option.appendChild(closeButton); + } deps.menu.appendChild(option); }; + const renderPresetNames = ( + userPresetNames: string[], + options: { includeDefaultPresets?: boolean } = {}, + ): void => { + Array.from(deps.menu.querySelectorAll(".dropdown-item")).forEach((item) => { + if (item.getAttribute("data-value") !== "none") item.remove(); + }); + + getAvailablePresetNames(userPresetNames, options).forEach((name) => { + addPresetToDropdown(name, { deletable: !isDefaultPresetName(name) }); + }); + }; + deps.saveButton.addEventListener("click", () => { void (async () => { const name = deps.nameInput.value; if (name.length === 0) return; + if (isDefaultPresetName(name)) return; const tabId = await deps.getCurrentTabId(); if (tabId == null) return; @@ -92,6 +119,8 @@ export const createPresetsView = (deps: { } if (event.target.classList.contains("close-btn")) { + if (isDefaultPresetName(choice)) return; + const prefs = await chrome.storage.local.get([ STORAGE_KEYS.PRESETS, STORAGE_KEYS.PRESET_NAMES, @@ -123,7 +152,7 @@ export const createPresetsView = (deps: { choice === "none" ? deps.getMessage("empty_preset_name") : choice; const prefs = await chrome.storage.local.get([STORAGE_KEYS.PRESETS]); const presets = prefs[STORAGE_KEYS.PRESETS] as PresetStorage | undefined; - const filters = presets?.[choice]; + const filters = resolvePresetFilters(choice, presets); if (!filters) return; deps.setCurrentFilters(filters); @@ -136,5 +165,6 @@ export const createPresetsView = (deps: { return { addPresetToDropdown, + renderPresetNames, }; }; diff --git a/src/ui/popup/settingsView.ts b/src/ui/popup/settingsView.ts index e1ecc91..98dadd0 100644 --- a/src/ui/popup/settingsView.ts +++ b/src/ui/popup/settingsView.ts @@ -13,6 +13,7 @@ import { validatePopupShortcutConfig, } from "./popupShortcuts"; import { themes, type ThemeName } from "../../domains/theme/themes"; +import { isDefaultPresetName } from "../../domains/presets/defaultPresets"; import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; const DEFAULT_THEME: ThemeName = "dark"; @@ -54,6 +55,7 @@ export const createSettingsView = (deps: { importPresetsButton: HTMLButtonElement; importInput: HTMLInputElement; enableSpectrum: HTMLInputElement; + hideDefaultPresets: HTMLInputElement; languageSelect: HTMLSelectElement; shortcutMute: HTMLInputElement; shortcutToggleEq: HTMLInputElement; @@ -281,6 +283,8 @@ export const createSettingsView = (deps: { const presetNames = [...((prefs[STORAGE_KEYS.PRESET_NAMES] ?? []) as string[])]; (presetsInfo.presetNames ?? []).forEach((name) => { + if (isDefaultPresetName(name)) return; + const needAdd = !presetNames.includes(name); if (!needAdd) return; @@ -304,6 +308,15 @@ export const createSettingsView = (deps: { }); }); + deps.hideDefaultPresets.addEventListener("change", () => { + void (async () => { + await chrome.storage.local.set({ + [STORAGE_KEYS.HIDE_DEFAULT_PRESETS]: deps.hideDefaultPresets.checked, + }); + await deps.refreshDynamicContent(); + })(); + }); + deps.languageSelect.addEventListener("change", () => { void deps.localization.setLanguage(deps.languageSelect.value, { save: true, @@ -345,7 +358,12 @@ export const createSettingsView = (deps: { return { init: async () => { - const stored = await chrome.storage.local.get([STORAGE_KEYS.SHORTCUTS]); + const stored = await chrome.storage.local.get([ + STORAGE_KEYS.SHORTCUTS, + STORAGE_KEYS.HIDE_DEFAULT_PRESETS, + ]); + deps.hideDefaultPresets.checked = + stored[STORAGE_KEYS.HIDE_DEFAULT_PRESETS] === true; shortcutSettings = resolvePopupShortcuts( stored[STORAGE_KEYS.SHORTCUTS] as Partial, ); From 6801b5eb7928279c8a34c983a5762a586e717274 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 10 Jul 2026 11:25:38 +0300 Subject: [PATCH 34/63] chore: updated localization's stings --- _locales/bn/messages.json | 4 ++-- _locales/de/messages.json | 4 ++-- _locales/en/messages.json | 6 +++--- _locales/es/messages.json | 4 ++-- _locales/fr/messages.json | 4 ++-- _locales/hi/messages.json | 4 ++-- _locales/id/messages.json | 4 ++-- _locales/it/messages.json | 4 ++-- _locales/ja/messages.json | 4 ++-- _locales/ko/messages.json | 4 ++-- _locales/nl/messages.json | 4 ++-- _locales/pt_PT/messages.json | 4 ++-- _locales/ru/messages.json | 4 ++-- _locales/ta/messages.json | 4 ++-- _locales/th/messages.json | 4 ++-- _locales/tr/messages.json | 4 ++-- _locales/vi/messages.json | 4 ++-- _locales/zh_CN/messages.json | 4 ++-- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/_locales/bn/messages.json b/_locales/bn/messages.json index 3943ed5..ea341ce 100644 --- a/_locales/bn/messages.json +++ b/_locales/bn/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - অডিও ইকুয়ালাইজার" }, "extDescription": { - "message": "EQ Toolkit অডিও নিয়ন্ত্রণের জন্য একটি বিনামূল্যের ও ওপেন-সোর্স এক্সটেনশন।" + "message": "যেকোনো ট্যাবের অডিও ইকুয়ালাইজার ও ভলিউম বুস্টার: বেস বাড়ান, সাউন্ড টিউন করুন, প্রিসেট সেভ করুন।" }, "gain_slider_label": { "message": "পরিবর্ধন:" @@ -54,7 +54,7 @@ "message": "প্রিসেট রপ্তানি করুন" }, "enable_spectrum_setting_option": { - "message": "স্পেকট্রাম ভিজুয়ালাইজেশন সক্রিয় করুন, পৃষ্ঠা পুনরায় লোড করতে হবে (উচ্চ CPU ব্যবহার)" + "message": "স্পেকট্রাম ভিজুয়ালাইজেশন সক্রিয় করুন" }, "points_count_setting_option": { "message": "EQ পয়েন্টের সংখ্যা" diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 0fde0a2..3fd3d6e 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Audio-Equalizer" }, "extDescription": { - "message": "EQ Toolkit ist eine kostenlose Open-Source-Erweiterung zur Audiosteuerung." + "message": "Audio-Equalizer und Lautstärke-Booster für jeden Tab: Bass verstärken, Sound anpassen, Presets speichern." }, "gain_slider_label": { "message": "Verstärkung:" @@ -54,7 +54,7 @@ "message": "Voreinstellungen exportieren" }, "enable_spectrum_setting_option": { - "message": "Spektrumvisualisierung aktivieren, Seite muss neu geladen werden (hohe CPU-Last)" + "message": "Spektrumvisualisierung aktivieren" }, "points_count_setting_option": { "message": "Anzahl der EQ-Punkte" diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b6e2d90..1134336 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Audio equalizer" }, "extDescription": { - "message": "EQ Toolkit is a free and open-source extension that provides audio control." + "message": "Audio equalizer and volume booster for any tab: boost bass, tune sound, save presets, mute tabs, use shortcuts and auto-start." }, "gain_slider_label": { "message": "Gain:" @@ -54,7 +54,7 @@ "message": "Export" }, "enable_spectrum_setting_option": { - "message": "Enable spectrum visualisation, need to restart page (high CPU load)" + "message": "Enable spectrum visualisation" }, "points_count_setting_option": { "message": "Number of EQ points" @@ -149,4 +149,4 @@ "shortcut_duplicate_error": { "message": "This shortcut is already used by another command." } -} \ No newline at end of file +} diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 5d932b5..52eae17 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Ecualizador de audio" }, "extDescription": { - "message": "EQ Toolkit es una extensión gratuita y de código abierto para controlar el audio." + "message": "Ecualizador de audio y amplificador de volumen para cualquier pestaña: potencia graves, ajusta sonido y guarda presets." }, "gain_slider_label": { "message": "Amplificación:" @@ -54,7 +54,7 @@ "message": "Exportar preajustes" }, "enable_spectrum_setting_option": { - "message": "Activar la visualización del espectro; es necesario recargar la página (alto uso de CPU)" + "message": "Activar la visualización del espectro" }, "points_count_setting_option": { "message": "Número de puntos de EQ" diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 91990bd..7a8ea92 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Égaliseur audio" }, "extDescription": { - "message": "EQ Toolkit est une extension gratuite et open source pour contrôler l’audio." + "message": "Égaliseur audio et amplificateur de volume pour tout onglet : boostez les basses, réglez le son et sauvez vos presets." }, "gain_slider_label": { "message": "Amplification:" @@ -54,7 +54,7 @@ "message": "Exporter des préréglages" }, "enable_spectrum_setting_option": { - "message": "Activer la visualisation du spectre; il faut recharger la page (utilisation élevée du CPU)" + "message": "Activer la visualisation du spectre" }, "points_count_setting_option": { "message": "Nombre de points d'EQ" diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index b1e6081..8a55613 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - ऑडियो इक्वलाइज़र" }, "extDescription": { - "message": "EQ Toolkit ऑडियो नियंत्रण प्रदान करने वाला एक मुफ़्त और ओपन-सोर्स एक्सटेंशन है।" + "message": "किसी भी टैब के लिए ऑडियो इक्वलाइज़र और वॉल्यूम बूस्टर: बास बढ़ाएँ, ध्वनि ट्यून करें, प्रीसेट सहेजें।" }, "gain_slider_label": { "message": "विस्तारण:" @@ -54,7 +54,7 @@ "message": "प्रीसेट निर्यात करें" }, "enable_spectrum_setting_option": { - "message": "स्पेक्ट्रम दृश्यांकन सक्षम करें, पेज को फिर से लोड करना होगा (उच्च CPU लोड)" + "message": "स्पेक्ट्रम दृश्यांकन सक्षम करें" }, "points_count_setting_option": { "message": "EQ बिंदुओं की संख्या" diff --git a/_locales/id/messages.json b/_locales/id/messages.json index 0439fa8..d80ca59 100644 --- a/_locales/id/messages.json +++ b/_locales/id/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Equalizer audio" }, "extDescription": { - "message": "EQ Toolkit adalah ekstensi gratis dan sumber terbuka untuk mengontrol audio." + "message": "Equalizer audio dan penguat volume untuk tab apa pun: boost bass, atur suara, simpan preset, mute tab." }, "gain_slider_label": { "message": "Amplifikasi:" @@ -54,7 +54,7 @@ "message": "Ekspor preset" }, "enable_spectrum_setting_option": { - "message": "Aktifkan visualisasi spektrum, perlu memuat ulang halaman (beban CPU tinggi)" + "message": "Aktifkan visualisasi spektrum" }, "points_count_setting_option": { "message": "Jumlah titik EQ" diff --git a/_locales/it/messages.json b/_locales/it/messages.json index 1faa09b..162f055 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Equalizzatore audio" }, "extDescription": { - "message": "EQ Toolkit è un'estensione gratuita e open source per controllare l'audio." + "message": "Equalizzatore audio e booster volume per ogni scheda: potenzia i bassi, regola il suono e salva preset." }, "gain_slider_label": { "message": "Amplificazione:" @@ -54,7 +54,7 @@ "message": "Esporta preset" }, "enable_spectrum_setting_option": { - "message": "Abilita la visualizzazione dello spettro, è necessario ricaricare la pagina (carico CPU elevato)" + "message": "Abilita la visualizzazione dello spettro" }, "points_count_setting_option": { "message": "Numero di punti EQ" diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index 1c26caf..c3b2dca 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - オーディオイコライザー" }, "extDescription": { - "message": "EQ Toolkit は、音声をコントロールできる無料のオープンソース拡張機能です。" + "message": "任意のタブで使える音声イコライザーと音量ブースター。低音強化、音質調整、プリセット保存、ミュート、ショートカットに対応。" }, "gain_slider_label": { "message": "増幅" @@ -54,7 +54,7 @@ "message": "プリセットをエクスポート" }, "enable_spectrum_setting_option": { - "message": "スペクトラム可視化を有効にする(ページの再読み込みが必要・CPU負荷が高い)" + "message": "スペクトラム可視化を有効にする" }, "points_count_setting_option": { "message": "EQポイント数" diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 0de3046..72ac854 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - 오디오 이퀄라이저" }, "extDescription": { - "message": "EQ Toolkit은 오디오 제어 기능을 제공하는 무료 오픈 소스 확장 프로그램입니다." + "message": "모든 탭용 오디오 이퀄라이저와 볼륨 부스터: 베이스 강화, 사운드 조정, 프리셋 저장, 탭 음소거." }, "gain_slider_label": { "message": "확대:" @@ -54,7 +54,7 @@ "message": "프리셋 내보내기" }, "enable_spectrum_setting_option": { - "message": "스펙트럼 시각화 활성화, 페이지를 다시 로드해야 함(높은 CPU 부하)" + "message": "스펙트럼 시각화 활성화" }, "points_count_setting_option": { "message": "EQ 포인트 수" diff --git a/_locales/nl/messages.json b/_locales/nl/messages.json index 28410c4..8c88f5d 100644 --- a/_locales/nl/messages.json +++ b/_locales/nl/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Audio-equalizer" }, "extDescription": { - "message": "EQ Toolkit is een gratis open-source-extensie voor audiobediening." + "message": "Audio-equalizer en volumebooster voor elk tabblad: boost bass, stem geluid af, sla presets op en demp tabs." }, "gain_slider_label": { "message": "Versterking:" @@ -54,7 +54,7 @@ "message": "Voorinstellingen exporteren" }, "enable_spectrum_setting_option": { - "message": "Spectrumvisualisatie inschakelen; pagina opnieuw laden vereist (hoge CPU-belasting)" + "message": "Spectrumvisualisatie inschakelen" }, "points_count_setting_option": { "message": "Aantal EQ-punten" diff --git a/_locales/pt_PT/messages.json b/_locales/pt_PT/messages.json index d384e2d..43ecce1 100644 --- a/_locales/pt_PT/messages.json +++ b/_locales/pt_PT/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Equalizador de áudio" }, "extDescription": { - "message": "O EQ Toolkit é uma extensão gratuita e de código aberto que fornece controlo de áudio." + "message": "Equalizador de áudio e amplificador de volume para qualquer separador: aumente graves, ajuste som e guarde predefinições." }, "gain_slider_label": { "message": "Amplificação:" @@ -54,7 +54,7 @@ "message": "Exportar" }, "enable_spectrum_setting_option": { - "message": "Ativar a visualização do espectro, é necessário recarregar a página (elevada carga de CPU)" + "message": "Ativar a visualização do espectro" }, "points_count_setting_option": { "message": "Número de pontos de EQ" diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index a043bc2..c439979 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Аудио эквалайзер" }, "extDescription": { - "message": "EQ Toolkit — бесплатное расширение с открытым исходным кодом для управления звуком." + "message": "Эквалайзер и усилитель громкости для любой вкладки: бас буст, управление звуком, пресеты, отключение звука, хоткеи и автозапуск." }, "gain_slider_label": { "message": "Усиление:" @@ -54,7 +54,7 @@ "message": "Экспорт" }, "enable_spectrum_setting_option": { - "message": "Включить отображение АЧХ, требуется перезагрузка страницы (high CPU load)" + "message": "Включить отображение АЧХ" }, "points_count_setting_option": { "message": "Количество точек эквалайзера" diff --git a/_locales/ta/messages.json b/_locales/ta/messages.json index 633879e..0d40832 100644 --- a/_locales/ta/messages.json +++ b/_locales/ta/messages.json @@ -1,6 +1,6 @@ { "extDescription": { - "message": "சம கருவித்தொகுப்பு என்பது ஒலி கட்டுப்பாட்டை வழங்கும் கட்டற்ற மற்றும் திறந்த மூல நீட்டிப்பாகும்." + "message": "எந்த தாவலுக்கும் ஆடியோ ஈக்வலைசர் மற்றும் ஒலி பூஸ்டர்: பேஸ் உயர்த்து, ஒலி அமை, முன்னமைவுகளை சேமி." }, "gain_slider_label": { "message": "பெருக்கம்:" @@ -51,7 +51,7 @@ "message": "ஏற்றுமதி" }, "enable_spectrum_setting_option": { - "message": "வானவில் காட்சிப்படுத்தலை இயக்கு, பக்கத்தை மறுதொடக்கம் செய்ய வேண்டும் (அதிக சிபியு சுமை)" + "message": "வானவில் காட்சிப்படுத்தலை இயக்கு" }, "points_count_setting_option": { "message": "சமனாக்கி புள்ளிகளின் எண்ணிக்கை" diff --git a/_locales/th/messages.json b/_locales/th/messages.json index 901c175..944055c 100644 --- a/_locales/th/messages.json +++ b/_locales/th/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - ตัวปรับสมดุลเสียง" }, "extDescription": { - "message": "EQ Toolkit เป็นส่วนขยายฟรีและโอเพนซอร์สสำหรับควบคุมเสียง" + "message": "อีควอไลเซอร์และตัวเพิ่มเสียงสำหรับทุกแท็บ: เพิ่มเบส ปรับเสียง บันทึกพรีเซ็ต ปิดเสียงแท็บ" }, "gain_slider_label": { "message": "การขยายเสียง:" @@ -54,7 +54,7 @@ "message": "ส่งออกพรีเซ็ต" }, "enable_spectrum_setting_option": { - "message": "เปิดใช้งานการแสดงผลสเปกตรัม ต้องโหลดหน้าใหม่ (ใช้ CPU สูง)" + "message": "เปิดใช้งานการแสดงผลสเปกตรัม" }, "points_count_setting_option": { "message": "จำนวนจุด EQ" diff --git a/_locales/tr/messages.json b/_locales/tr/messages.json index aba26cc..202d690 100644 --- a/_locales/tr/messages.json +++ b/_locales/tr/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Ses eşitleyici" }, "extDescription": { - "message": "EQ Toolkit, ses kontrolü sağlayan ücretsiz ve açık kaynaklı bir uzantıdır." + "message": "Her sekme için ses ekolayzırı ve ses yükseltici: bası güçlendir, sesi ayarla, preset kaydet, sekmeleri sustur." }, "gain_slider_label": { "message": "Amplifikasyon:" @@ -54,7 +54,7 @@ "message": "Ön ayarları dışa aktar" }, "enable_spectrum_setting_option": { - "message": "Spektrum görselleştirmesini etkinleştir; sayfayı yeniden yüklemek gerekir (yüksek CPU kullanımı)" + "message": "Spektrum görselleştirmesini etkinleştir" }, "points_count_setting_option": { "message": "EQ nokta sayısı" diff --git a/_locales/vi/messages.json b/_locales/vi/messages.json index 2cba9b0..83f5493 100644 --- a/_locales/vi/messages.json +++ b/_locales/vi/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - Bộ cân bằng âm thanh" }, "extDescription": { - "message": "EQ Toolkit là tiện ích mở rộng miễn phí và mã nguồn mở để điều khiển âm thanh." + "message": "Bộ cân bằng âm thanh và tăng âm lượng cho mọi thẻ: tăng bass, chỉnh âm, lưu preset, tắt tiếng thẻ." }, "gain_slider_label": { "message": "Khuếch đại:" @@ -54,7 +54,7 @@ "message": "Xuất preset" }, "enable_spectrum_setting_option": { - "message": "Bật hiển thị phổ, cần tải lại trang (tốn CPU cao)" + "message": "Bật hiển thị phổ" }, "points_count_setting_option": { "message": "Số điểm EQ" diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index d40abb5..dda986e 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -3,7 +3,7 @@ "message": "EQ Toolkit - 音频均衡器" }, "extDescription": { - "message": "EQ Toolkit 是一个免费开源的音频控制扩展程序。" + "message": "适用于任意标签页的音频均衡器和音量增强器:增强低音、调音、保存预设、静音标签页、使用快捷键和自动启动。" }, "gain_slider_label": { "message": "放大" @@ -54,7 +54,7 @@ "message": "导出预设" }, "enable_spectrum_setting_option": { - "message": "启用频谱可视化,需要重新加载页面(CPU 负载较高)" + "message": "启用频谱可视化" }, "points_count_setting_option": { "message": "EQ点数" From 4a7da5e62d1a6d12257cf706385211be8142582d Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 10 Jul 2026 17:02:31 +0300 Subject: [PATCH 35/63] chore: updated .gitignore --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b9ed5c4..67ff30d 100644 --- a/.gitignore +++ b/.gitignore @@ -133,4 +133,6 @@ dist .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz -.pnp.* \ No newline at end of file +.pnp.* +**/docs +**/.worktrees \ No newline at end of file From a05182cbea2bd80a9c6c6875cebdb6e23574f7f0 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 11 Jul 2026 11:58:21 +0300 Subject: [PATCH 36/63] chore: updated .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 67ff30d..e00a59a 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,5 @@ dist .yarn/install-state.gz .pnp.* **/docs -**/.worktrees \ No newline at end of file +**/.worktrees +**/.superpowers \ No newline at end of file From 693d29f7996bf4dfb6c58f1bf6399b6cf71e598d Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sat, 11 Jul 2026 12:07:32 +0300 Subject: [PATCH 37/63] refactor(redesign): added enable/disable icon button --- popup.html | 1 + resources/images/on-off-button.svg | 24 +++++++++++++++++++ resources/styles/popup.css | 12 +++++++--- .../createToolkitWindowController.test.ts | 2 +- .../createToolkitWindowController.ts | 3 +-- .../localization/localizationService.ts | 1 - src/infrastructure/dom/popupElements.ts | 4 ++-- src/ui/popup/controlsView.ts | 2 +- 8 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 resources/images/on-off-button.svg diff --git a/popup.html b/popup.html index 08bdf8d..d20f4d3 100644 --- a/popup.html +++ b/popup.html @@ -16,6 +16,7 @@ + Support me diff --git a/resources/images/on-off-button.svg b/resources/images/on-off-button.svg new file mode 100644 index 0000000..4f9f8e1 --- /dev/null +++ b/resources/images/on-off-button.svg @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/resources/styles/popup.css b/resources/styles/popup.css index b54f6af..3ab520c 100644 --- a/resources/styles/popup.css +++ b/resources/styles/popup.css @@ -142,7 +142,7 @@ body { position: relative; - width: 500px; + width: 600px; margin: 0 auto; font-family: Arial, sans-serif; display: flex; @@ -534,13 +534,14 @@ button:disabled:active { .volume-mute, .volume-mute-active { - width: 27px; + width: 31px; cursor: pointer; } .volume-mute, .settings-btn, .window-mod, +.change-eq, .add-to-autostart-whitelist-btn { filter: var(--svg-btn-filter); } @@ -556,7 +557,12 @@ button:disabled:active { } .window-mod { - width: 22px; + width: 25px; + cursor: pointer; +} + +.change-eq { + width: 23px; cursor: pointer; } diff --git a/src/app/window-mode/createToolkitWindowController.test.ts b/src/app/window-mode/createToolkitWindowController.test.ts index c91e828..373652a 100644 --- a/src/app/window-mode/createToolkitWindowController.test.ts +++ b/src/app/window-mode/createToolkitWindowController.test.ts @@ -142,7 +142,7 @@ const createController = ( const controller = createToolkitWindowController({ body: new FakeElement() as unknown as HTMLElement, capturedTabs: new FakeElement() as unknown as HTMLElement, - changeEqButton: new FakeElement() as unknown as HTMLButtonElement, + changeEqButton: new FakeElement() as unknown as HTMLImageElement, audioContext: audioContext as unknown as AudioContext, equalizerState: {} as Parameters< typeof createToolkitWindowController diff --git a/src/app/window-mode/createToolkitWindowController.ts b/src/app/window-mode/createToolkitWindowController.ts index d27e95f..4baf377 100644 --- a/src/app/window-mode/createToolkitWindowController.ts +++ b/src/app/window-mode/createToolkitWindowController.ts @@ -80,7 +80,7 @@ export interface ToolkitWindowController { export const createToolkitWindowController = (deps: { body: HTMLElement; capturedTabs: HTMLElement; - changeEqButton: HTMLButtonElement; + changeEqButton: HTMLImageElement; audioContext: AudioContext; equalizerState: EqualizerState; getDimensions(): { canvasWidth: number; canvasHeight: number }; @@ -110,7 +110,6 @@ export const createToolkitWindowController = (deps: { if (isToolkitWindow) { deps.body.classList.add("toolkit-window-body"); - deps.changeEqButton.disabled = true; deps.changeEqButton.classList.add("disabled"); } diff --git a/src/domains/localization/localizationService.ts b/src/domains/localization/localizationService.ts index eecb4ba..222555e 100644 --- a/src/domains/localization/localizationService.ts +++ b/src/domains/localization/localizationService.ts @@ -97,7 +97,6 @@ export const createLocalizationService = (): LocalizationService => { const applyLocalization = (root: Document = document): void => { setElementTextContent(root, "master-volume-label", "gain_slider_label", getMessage); setElementTextContent(root, "reset", "reset_button_label", getMessage); - setElementTextContent(root, "change-eq", "enable_eq_button_label", getMessage); setElementTextContent(root, "presets-toggle", "empty_preset_name", getMessage); setElementTextContent(root, "none-item", "empty_preset_name", getMessage); setElementTextContent(root, "save-preset", "save_preset_button_label", getMessage); diff --git a/src/infrastructure/dom/popupElements.ts b/src/infrastructure/dom/popupElements.ts index cd9e3ad..03fb22f 100644 --- a/src/infrastructure/dom/popupElements.ts +++ b/src/infrastructure/dom/popupElements.ts @@ -15,7 +15,7 @@ export interface PopupElements { masterVolumeLabel: HTMLLabelElement; masterVolume: HTMLInputElement; resetButton: HTMLButtonElement; - changeEqButton: HTMLButtonElement; + changeEqButton: HTMLImageElement; presets: HTMLDivElement; presetsToggle: HTMLDivElement; presetsMenu: HTMLDivElement; @@ -116,7 +116,7 @@ export const getPopupElements = (document: Document): PopupElements => ({ changeEqButton: getRequiredElement( document, "change-eq", - HTMLButtonElement, + HTMLImageElement, ), presets: getRequiredElement(document, "presets", HTMLDivElement), presetsToggle: getRequiredElement(document, "presets-toggle", HTMLDivElement), diff --git a/src/ui/popup/controlsView.ts b/src/ui/popup/controlsView.ts index 88c6123..37da51a 100644 --- a/src/ui/popup/controlsView.ts +++ b/src/ui/popup/controlsView.ts @@ -4,7 +4,7 @@ export interface ControlsView { } export const createControlsView = (deps: { - changeEqButton: HTMLButtonElement; + changeEqButton: HTMLImageElement; resetButton: HTMLButtonElement; masterVolume: HTMLInputElement; volumeMuteButton: HTMLElement; From 086347fb483beb5783730dbdcfb7521a72b5c701 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Tue, 14 Jul 2026 21:16:29 +0300 Subject: [PATCH 38/63] feat(redesign): updated ux, added modal window for saving preset --- _locales/bn/messages.json | 27 +++- _locales/de/messages.json | 27 +++- _locales/en/messages.json | 27 +++- _locales/es/messages.json | 27 +++- _locales/fr/messages.json | 29 ++++- _locales/hi/messages.json | 27 +++- _locales/id/messages.json | 27 +++- _locales/it/messages.json | 27 +++- _locales/ja/messages.json | 27 +++- _locales/ko/messages.json | 27 +++- _locales/nl/messages.json | 27 +++- _locales/pt_PT/messages.json | 29 ++++- _locales/ru/messages.json | 27 +++- _locales/ta/messages.json | 29 ++++- _locales/th/messages.json | 27 +++- _locales/tr/messages.json | 27 +++- _locales/vi/messages.json | 27 +++- _locales/zh_CN/messages.json | 27 +++- popup.html | 79 +++++++----- resources/styles/popup.css | 104 ++++++++++----- src/app/popup/createPopupApp.ts | 19 ++- src/app/popup/presetSaveModalMarkup.test.ts | 14 +++ .../localization/localizationService.ts | 9 +- .../presets/presetNameValidation.test.ts | 26 ++++ src/domains/presets/presetNameValidation.ts | 16 +++ src/infrastructure/dom/popupElements.ts | 24 +++- src/ui/popup/controlsView.test.ts | 42 +++++++ src/ui/popup/controlsView.ts | 7 +- src/ui/popup/presetsView.test.ts | 119 ++++++++++++++++++ src/ui/popup/presetsView.ts | 55 ++++++-- tools/validateLocalizationLengths.js | 41 +++--- 31 files changed, 880 insertions(+), 167 deletions(-) create mode 100644 src/app/popup/presetSaveModalMarkup.test.ts create mode 100644 src/domains/presets/presetNameValidation.test.ts create mode 100644 src/domains/presets/presetNameValidation.ts create mode 100644 src/ui/popup/controlsView.test.ts create mode 100644 src/ui/popup/presetsView.test.ts diff --git a/_locales/bn/messages.json b/_locales/bn/messages.json index ea341ce..3b9c408 100644 --- a/_locales/bn/messages.json +++ b/_locales/bn/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "প্রিসেট সংরক্ষণ করুন" }, - "enter_preset_name": { - "message": "নাম লিখুন" - }, "settings_title": { "message": "সেটিংস" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "এই শর্টকাটটি ইতিমধ্যে অন্য কমান্ডে ব্যবহৃত হচ্ছে।" + }, + "global_controls_title": { + "message": "সামগ্রিক নিয়ন্ত্রণ" + }, + "preset_controls_title": { + "message": "প্রিসেট নিয়ন্ত্রণ" + }, + "save": { + "message": "সংরক্ষণ করুন" + }, + "save_preset_modal_title": { + "message": "প্রিসেট সংরক্ষণ করুন" + }, + "preset_name_label": { + "message": "প্রিসেটের নাম" + }, + "preset_name_empty_error": { + "message": "প্রিসেটের নাম লিখুন।" + }, + "preset_name_reserved_error": { + "message": "এই নামটি একটি অন্তর্নির্মিত প্রিসেটের জন্য সংরক্ষিত।" + }, + "preset_name_duplicate_error": { + "message": "এই নামে একটি প্রিসেট ইতিমধ্যে বিদ্যমান।" } } diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 3fd3d6e..134d0ae 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "Preset speichern" }, - "enter_preset_name": { - "message": "Name eingeben" - }, "settings_title": { "message": "Einstellungen" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Dieses Tastenkürzel wird bereits von einem anderen Befehl verwendet." + }, + "global_controls_title": { + "message": "Globale Steuerelemente" + }, + "preset_controls_title": { + "message": "Voreinstellungen" + }, + "save": { + "message": "Speichern" + }, + "save_preset_modal_title": { + "message": "Voreinstellung speichern" + }, + "preset_name_label": { + "message": "Name der Voreinstellung" + }, + "preset_name_empty_error": { + "message": "Geben Sie einen Namen für die Voreinstellung ein." + }, + "preset_name_reserved_error": { + "message": "Dieser Name ist für eine integrierte Voreinstellung reserviert." + }, + "preset_name_duplicate_error": { + "message": "Eine Voreinstellung mit diesem Namen existiert bereits." } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 1134336..44e3b5e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "Save preset" }, - "enter_preset_name": { - "message": "Enter name" - }, "settings_title": { "message": "Settings" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "This shortcut is already used by another command." + }, + "global_controls_title": { + "message": "Global Controls" + }, + "preset_controls_title": { + "message": "Preset Controls" + }, + "save": { + "message": "Save" + }, + "save_preset_modal_title": { + "message": "Save preset" + }, + "preset_name_label": { + "message": "Preset name" + }, + "preset_name_empty_error": { + "message": "Enter a preset name." + }, + "preset_name_reserved_error": { + "message": "This name is reserved for a built-in preset." + }, + "preset_name_duplicate_error": { + "message": "A preset with this name already exists." } } diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 52eae17..2dbf932 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "Guardar preset" }, - "enter_preset_name": { - "message": "Ingrese nombre" - }, "settings_title": { "message": "Configuración" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Este atajo ya lo usa otro comando." + }, + "global_controls_title": { + "message": "Controles globales" + }, + "preset_controls_title": { + "message": "Controles de preajustes" + }, + "save": { + "message": "Guardar" + }, + "save_preset_modal_title": { + "message": "Guardar preajuste" + }, + "preset_name_label": { + "message": "Nombre del preajuste" + }, + "preset_name_empty_error": { + "message": "Introduce un nombre para el preajuste." + }, + "preset_name_reserved_error": { + "message": "Este nombre está reservado para un preajuste integrado." + }, + "preset_name_duplicate_error": { + "message": "Ya existe un preajuste con este nombre." } } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 7a8ea92..1796b85 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -21,10 +21,7 @@ "message": "Aucun" }, "save_preset_button_label": { - "message": "Enregistrer le préréglage" - }, - "enter_preset_name": { - "message": "Entrez le nom" + "message": "Enregistrer" }, "settings_title": { "message": "Paramètres" @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Ce raccourci est déjà utilisé par une autre commande." + }, + "global_controls_title": { + "message": "Commandes globales" + }, + "preset_controls_title": { + "message": "Commandes des préréglages" + }, + "save": { + "message": "Enregistrer" + }, + "save_preset_modal_title": { + "message": "Enregistrer le préréglage" + }, + "preset_name_label": { + "message": "Nom du préréglage" + }, + "preset_name_empty_error": { + "message": "Saisissez un nom de préréglage." + }, + "preset_name_reserved_error": { + "message": "Ce nom est réservé à un préréglage intégré." + }, + "preset_name_duplicate_error": { + "message": "Un préréglage portant ce nom existe déjà." } } diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index 8a55613..f49012c 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "प्रसेट सहेजें" }, - "enter_preset_name": { - "message": "नाम दर्ज करें" - }, "settings_title": { "message": "सेटिंग्स" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "यह शॉर्टकट पहले से ही किसी अन्य कमांड द्वारा उपयोग किया जा रहा है।" + }, + "global_controls_title": { + "message": "वैश्विक नियंत्रण" + }, + "preset_controls_title": { + "message": "प्रीसेट नियंत्रण" + }, + "save": { + "message": "सहेजें" + }, + "save_preset_modal_title": { + "message": "प्रीसेट सहेजें" + }, + "preset_name_label": { + "message": "प्रीसेट का नाम" + }, + "preset_name_empty_error": { + "message": "प्रीसेट का नाम दर्ज करें।" + }, + "preset_name_reserved_error": { + "message": "यह नाम अंतर्निहित प्रीसेट के लिए आरक्षित है।" + }, + "preset_name_duplicate_error": { + "message": "इस नाम का प्रीसेट पहले से मौजूद है।" } } diff --git a/_locales/id/messages.json b/_locales/id/messages.json index d80ca59..95d0eec 100644 --- a/_locales/id/messages.json +++ b/_locales/id/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "Simpan preset" }, - "enter_preset_name": { - "message": "Masukkan nama" - }, "settings_title": { "message": "Pengaturan" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Pintasan ini sudah digunakan oleh perintah lain." + }, + "global_controls_title": { + "message": "Kontrol global" + }, + "preset_controls_title": { + "message": "Kontrol preset" + }, + "save": { + "message": "Simpan" + }, + "save_preset_modal_title": { + "message": "Simpan preset" + }, + "preset_name_label": { + "message": "Nama preset" + }, + "preset_name_empty_error": { + "message": "Masukkan nama preset." + }, + "preset_name_reserved_error": { + "message": "Nama ini disediakan untuk preset bawaan." + }, + "preset_name_duplicate_error": { + "message": "Preset dengan nama ini sudah ada." } } diff --git a/_locales/it/messages.json b/_locales/it/messages.json index 162f055..617d84a 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "Salva preset" }, - "enter_preset_name": { - "message": "Inserisci nome" - }, "settings_title": { "message": "Impostazioni" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Questa scorciatoia è già usata da un altro comando." + }, + "global_controls_title": { + "message": "Controlli globali" + }, + "preset_controls_title": { + "message": "Controlli delle preimpostazioni" + }, + "save": { + "message": "Salva" + }, + "save_preset_modal_title": { + "message": "Salva preimpostazione" + }, + "preset_name_label": { + "message": "Nome preimpostazione" + }, + "preset_name_empty_error": { + "message": "Inserisci un nome per la preimpostazione." + }, + "preset_name_reserved_error": { + "message": "Questo nome è riservato a una preimpostazione integrata." + }, + "preset_name_duplicate_error": { + "message": "Esiste già una preimpostazione con questo nome." } } diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index c3b2dca..b267b6f 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "設定を保存" }, - "enter_preset_name": { - "message": "名前を入力" - }, "settings_title": { "message": "設定" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "このショートカットは別のコマンドですでに使用されています。" + }, + "global_controls_title": { + "message": "グローバルコントロール" + }, + "preset_controls_title": { + "message": "プリセットコントロール" + }, + "save": { + "message": "保存" + }, + "save_preset_modal_title": { + "message": "プリセットを保存" + }, + "preset_name_label": { + "message": "プリセット名" + }, + "preset_name_empty_error": { + "message": "プリセット名を入力してください。" + }, + "preset_name_reserved_error": { + "message": "この名前は組み込みプリセット用に予約されています。" + }, + "preset_name_duplicate_error": { + "message": "この名前のプリセットは既に存在します。" } } diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 72ac854..8991549 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "프리셋 저장" }, - "enter_preset_name": { - "message": "이름 입력" - }, "settings_title": { "message": "설정" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "이 단축키는 이미 다른 명령에서 사용 중입니다." + }, + "global_controls_title": { + "message": "전역 컨트롤" + }, + "preset_controls_title": { + "message": "프리셋 컨트롤" + }, + "save": { + "message": "저장" + }, + "save_preset_modal_title": { + "message": "프리셋 저장" + }, + "preset_name_label": { + "message": "프리셋 이름" + }, + "preset_name_empty_error": { + "message": "프리셋 이름을 입력하세요." + }, + "preset_name_reserved_error": { + "message": "이 이름은 기본 제공 프리셋용으로 예약되어 있습니다." + }, + "preset_name_duplicate_error": { + "message": "이 이름의 프리셋이 이미 있습니다." } } diff --git a/_locales/nl/messages.json b/_locales/nl/messages.json index 8c88f5d..7c828c5 100644 --- a/_locales/nl/messages.json +++ b/_locales/nl/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "Preset opslaan" }, - "enter_preset_name": { - "message": "Voer naam in" - }, "settings_title": { "message": "Instellingen" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Deze sneltoets wordt al door een andere opdracht gebruikt." + }, + "global_controls_title": { + "message": "Algemene bediening" + }, + "preset_controls_title": { + "message": "Voorinstellingsbediening" + }, + "save": { + "message": "Opslaan" + }, + "save_preset_modal_title": { + "message": "Voorinstelling opslaan" + }, + "preset_name_label": { + "message": "Naam voorinstelling" + }, + "preset_name_empty_error": { + "message": "Voer een naam voor de voorinstelling in." + }, + "preset_name_reserved_error": { + "message": "Deze naam is gereserveerd voor een ingebouwde voorinstelling." + }, + "preset_name_duplicate_error": { + "message": "Er bestaat al een voorinstelling met deze naam." } } diff --git a/_locales/pt_PT/messages.json b/_locales/pt_PT/messages.json index 43ecce1..0c38331 100644 --- a/_locales/pt_PT/messages.json +++ b/_locales/pt_PT/messages.json @@ -21,10 +21,7 @@ "message": "Nenhum" }, "save_preset_button_label": { - "message": "Guardar predefinição" - }, - "enter_preset_name": { - "message": "Insira o nome" + "message": "Guardar" }, "settings_title": { "message": "Definições" @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Este atalho já está a ser utilizado por outro comando." + }, + "global_controls_title": { + "message": "Controlos globais" + }, + "preset_controls_title": { + "message": "Controlos de predefinições" + }, + "save": { + "message": "Guardar" + }, + "save_preset_modal_title": { + "message": "Guardar predefinição" + }, + "preset_name_label": { + "message": "Nome da predefinição" + }, + "preset_name_empty_error": { + "message": "Introduza um nome para a predefinição." + }, + "preset_name_reserved_error": { + "message": "Este nome está reservado para uma predefinição incorporada." + }, + "preset_name_duplicate_error": { + "message": "Já existe uma predefinição com este nome." } } diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index c439979..499de3a 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "Сохранить пресет" }, - "enter_preset_name": { - "message": "Введите название" - }, "settings_title": { "message": "Настройки" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Это сочетание уже используется другой командой." + }, + "global_controls_title": { + "message": "Общие настройки" + }, + "preset_controls_title": { + "message": "Управление пресетами" + }, + "save": { + "message": "Сохранить" + }, + "save_preset_modal_title": { + "message": "Сохранить пресет" + }, + "preset_name_label": { + "message": "Название пресета" + }, + "preset_name_empty_error": { + "message": "Введите название пресета." + }, + "preset_name_reserved_error": { + "message": "Это название зарезервировано для встроенного пресета." + }, + "preset_name_duplicate_error": { + "message": "Пресет с таким названием уже существует." } } diff --git a/_locales/ta/messages.json b/_locales/ta/messages.json index 0d40832..2ae14c6 100644 --- a/_locales/ta/messages.json +++ b/_locales/ta/messages.json @@ -18,10 +18,7 @@ "message": "ஒன்றுமில்" }, "save_preset_button_label": { - "message": "அமைவைச் சேமி" - }, - "enter_preset_name": { - "message": "பெயரை உள்ளிடவும்" + "message": "சேமி" }, "settings_title": { "message": "அமைப்புகள்" @@ -148,5 +145,29 @@ }, "extName": { "message": "EQ Toolkit - ஆடியோ சமநிலைப்படுத்தி" + }, + "global_controls_title": { + "message": "உலகளாவிய கட்டுப்பாடுகள்" + }, + "preset_controls_title": { + "message": "முன்னமைவுக் கட்டுப்பாடுகள்" + }, + "save": { + "message": "சேமி" + }, + "save_preset_modal_title": { + "message": "முன்னமைவைச் சேமி" + }, + "preset_name_label": { + "message": "முன்னமைவின் பெயர்" + }, + "preset_name_empty_error": { + "message": "முன்னமைவின் பெயரை உள்ளிடவும்." + }, + "preset_name_reserved_error": { + "message": "இந்தப் பெயர் உள்ளமைந்த முன்னமைவுக்காக ஒதுக்கப்பட்டுள்ளது." + }, + "preset_name_duplicate_error": { + "message": "இந்தப் பெயரில் ஒரு முன்னமைவு ஏற்கனவே உள்ளது." } } diff --git a/_locales/th/messages.json b/_locales/th/messages.json index 944055c..11cf8e2 100644 --- a/_locales/th/messages.json +++ b/_locales/th/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "บันทึกพรีเซ็ต" }, - "enter_preset_name": { - "message": "ใส่ชื่อ" - }, "settings_title": { "message": "การตั้งค่า" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "ปุ่มลัดนี้ถูกใช้โดยคำสั่งอื่นแล้ว" + }, + "global_controls_title": { + "message": "การควบคุมส่วนกลาง" + }, + "preset_controls_title": { + "message": "การควบคุมค่าที่ตั้งไว้" + }, + "save": { + "message": "บันทึก" + }, + "save_preset_modal_title": { + "message": "บันทึกค่าที่ตั้งไว้" + }, + "preset_name_label": { + "message": "ชื่อค่าที่ตั้งไว้" + }, + "preset_name_empty_error": { + "message": "ป้อนชื่อค่าที่ตั้งไว้" + }, + "preset_name_reserved_error": { + "message": "ชื่อนี้สงวนไว้สำหรับค่าที่ตั้งไว้ในตัว" + }, + "preset_name_duplicate_error": { + "message": "มีค่าที่ตั้งไว้ชื่อนี้อยู่แล้ว" } } diff --git a/_locales/tr/messages.json b/_locales/tr/messages.json index 202d690..b16c6fe 100644 --- a/_locales/tr/messages.json +++ b/_locales/tr/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "Ön ayarı kaydet" }, - "enter_preset_name": { - "message": "Ad girin" - }, "settings_title": { "message": "Ayarlar" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Bu kısayol zaten başka bir komut tarafından kullanılıyor." + }, + "global_controls_title": { + "message": "Genel kontroller" + }, + "preset_controls_title": { + "message": "Ön ayar kontrolleri" + }, + "save": { + "message": "Kaydet" + }, + "save_preset_modal_title": { + "message": "Ön ayarı kaydet" + }, + "preset_name_label": { + "message": "Ön ayar adı" + }, + "preset_name_empty_error": { + "message": "Bir ön ayar adı girin." + }, + "preset_name_reserved_error": { + "message": "Bu ad yerleşik bir ön ayar için ayrılmıştır." + }, + "preset_name_duplicate_error": { + "message": "Bu ada sahip bir ön ayar zaten var." } } diff --git a/_locales/vi/messages.json b/_locales/vi/messages.json index 83f5493..ab5322d 100644 --- a/_locales/vi/messages.json +++ b/_locales/vi/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "Lưu thiết lập trước" }, - "enter_preset_name": { - "message": "Nhập tên" - }, "settings_title": { "message": "Cài đặt" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "Phím tắt này đã được lệnh khác sử dụng." + }, + "global_controls_title": { + "message": "Điều khiển chung" + }, + "preset_controls_title": { + "message": "Điều khiển cài đặt sẵn" + }, + "save": { + "message": "Lưu" + }, + "save_preset_modal_title": { + "message": "Lưu cài đặt sẵn" + }, + "preset_name_label": { + "message": "Tên cài đặt sẵn" + }, + "preset_name_empty_error": { + "message": "Nhập tên cài đặt sẵn." + }, + "preset_name_reserved_error": { + "message": "Tên này được dành riêng cho cài đặt sẵn tích hợp." + }, + "preset_name_duplicate_error": { + "message": "Đã có cài đặt sẵn với tên này." } } diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index dda986e..48a5f8f 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -23,9 +23,6 @@ "save_preset_button_label": { "message": "保存预设" }, - "enter_preset_name": { - "message": "输入名称" - }, "settings_title": { "message": "设置" }, @@ -148,5 +145,29 @@ }, "shortcut_duplicate_error": { "message": "此快捷键已被另一个命令使用。" + }, + "global_controls_title": { + "message": "全局控制" + }, + "preset_controls_title": { + "message": "预设控制" + }, + "save": { + "message": "保存" + }, + "save_preset_modal_title": { + "message": "保存预设" + }, + "preset_name_label": { + "message": "预设名称" + }, + "preset_name_empty_error": { + "message": "请输入预设名称。" + }, + "preset_name_reserved_error": { + "message": "此名称为内置预设保留。" + }, + "preset_name_duplicate_error": { + "message": "已存在同名预设。" } } diff --git a/popup.html b/popup.html index d20f4d3..9ab6047 100644 --- a/popup.html +++ b/popup.html @@ -11,15 +11,16 @@
- EQ Toolkit - - - - - - - Support me - +
+ EQ Toolkit + + + +
+
+ + +
@@ -29,30 +30,32 @@
-
-
- - -
- -24 - -12 - 0 - 12 - 24 +
+
+

Global Controls

+
+
+
+ + 0.0 dB +
+ +
+
-
- - -
-
-
+ + - - diff --git a/resources/styles/popup.css b/resources/styles/popup.css index 7aa715f..a55c91a 100644 --- a/resources/styles/popup.css +++ b/resources/styles/popup.css @@ -69,41 +69,6 @@ --light-notice-link: #7b1ed2; --light-notice-link-hover: #561495; - /* Christmas theme palette */ - --ny-bg: #27355a; - --ny-panel-bg: url("/resources/images/ny_panel_bg_300.jpg"); - --ny-settings-bg: #f7f0de; - --ny-surface-alt: #f3e5c5; - --ny-accent-start: #f1d9a8; - --ny-accent-mid: #e4b654; - --ny-accent-end: #f1d9a8; - --ny-highpass-filter: #e4b654; - --ny-lowpass-filter: #e4b654; - --ny-text: #1f2745; - --ny-text-header: #e4b654; - --ny-text-placeholder: rgba(31, 39, 69, 0.45); - --ny-text-volume-scale: #f1d9a8; - --ny-slider-track-bg: #f1d9a8; - --ny-slider-thumb: #e4b654; - --ny-slider-glow: rgba(228, 182, 84, 0.7); - --ny-axis: rgba(31, 39, 69, 0.7); - --ny-border: rgba(228, 182, 84, 0.45); - --ny-hover-bg: #e7d7b2; - --ny-active-bg: #f3e5c5; - --ny-menu-hover: #e7d7b2; - --ny-overlay: rgba(13, 18, 36, 0.8); - --ny-shadow-soft: rgba(0, 0, 0, 0.18); - --ny-shadow-strong: rgba(0, 0, 0, 0.35); - --ny-donation: #e4b654; - --ny-reset-btn: #e4b654cc; - --ny-reset-btn-hover: #e4b654e6; - --ny-reset-btn-active: #e4b654; - --ny-btn-bg: #f3e5c5; - --ny-svg-btn-filter: brightness(0) saturate(100%) invert(47%) sepia(77%) saturate(477%) hue-rotate(3deg) brightness(112%) contrast(90%); - --ny-info-btn-display: block; - --ny-notice-link: #9b3c2c; - --ny-notice-link-hover: #6f2b20; - /* Active theme bindings */ --bg: var(--dark-bg); --panel-bg: var(--dark-panel-bg); @@ -189,7 +154,6 @@ body { } .header-right { - padding-right: 10px; justify-content: end ; } diff --git a/src/app/popup/createPopupApp.ts b/src/app/popup/createPopupApp.ts index d3a2276..16f33b3 100644 --- a/src/app/popup/createPopupApp.ts +++ b/src/app/popup/createPopupApp.ts @@ -395,13 +395,6 @@ export const createPopupApp = ({ isToolkitWindow: toolkitController.isToolkitWindow, }); - elements.infoButton.addEventListener("click", () => { - elements.infoModal.style.display = "block"; - }); - elements.closeInfoModal.addEventListener("click", () => { - elements.infoModal.style.display = "none"; - }); - document.addEventListener("keydown", (event) => { void (async () => { if (event.repeat || isPopupEditableShortcutTarget(event.target)) return; diff --git a/src/domains/theme/themes.ts b/src/domains/theme/themes.ts index 68943eb..5b2b39c 100644 --- a/src/domains/theme/themes.ts +++ b/src/domains/theme/themes.ts @@ -1,4 +1,4 @@ -export const THEME_NAMES = ["dark", "light", "ny"] as const; +export const THEME_NAMES = ["dark", "light"] as const; export type ThemeName = (typeof THEME_NAMES)[number]; @@ -74,40 +74,5 @@ export const themes: Record = { "info-btn-display": "var(--light-info-btn-display)", "notice-link": "var(--light-notice-link)", "notice-link-hover": "var(--light-notice-link-hover)", - }, - ny: { - bg: "var(--ny-bg)", - "panel-bg": "var(--ny-panel-bg)", - "settings-bg": "var(--ny-settings-bg)", - "accent-start": "var(--ny-accent-start)", - "accent-mid": "var(--ny-accent-mid)", - "accent-end": "var(--ny-accent-end)", - "highpass-filter": "var(--ny-highpass-filter)", - "lowpass-filter": "var(--ny-lowpass-filter)", - text: "var(--ny-text)", - "text-header": "var(--ny-text-header)", - "slider-track-bg": "var(--ny-slider-track-bg)", - "slider-thumb": "var(--ny-slider-thumb)", - axis: "var(--ny-axis)", - border: "var(--ny-border)", - "surface-alt": "var(--ny-surface-alt)", - "text-placeholder": "var(--ny-text-placeholder)", - "text-volume-scale": "var(--ny-text-volume-scale)", - "hover-bg": "var(--ny-hover-bg)", - "active-bg": "var(--ny-active-bg)", - "menu-hover": "var(--ny-menu-hover)", - overlay: "var(--ny-overlay)", - "shadow-soft": "var(--ny-shadow-soft)", - "shadow-strong": "var(--ny-shadow-strong)", - donation: "var(--ny-donation)", - "slider-glow": "var(--ny-slider-glow)", - "reset-btn": "var(--ny-reset-btn)", - "reset-btn-hover": "var(--ny-reset-btn-hover)", - "reset-btn-active": "var(--ny-reset-btn-active)", - "btn-bg": "var(--ny-btn-bg)", - "svg-btn-filter": "var(--ny-svg-btn-filter)", - "info-btn-display": "var(--ny-info-btn-display)", - "notice-link": "var(--ny-notice-link)", - "notice-link-hover": "var(--ny-notice-link-hover)", - }, + } }; diff --git a/src/infrastructure/dom/popupElements.ts b/src/infrastructure/dom/popupElements.ts index 57da5b1..4c4e1da 100644 --- a/src/infrastructure/dom/popupElements.ts +++ b/src/infrastructure/dom/popupElements.ts @@ -39,7 +39,6 @@ export interface PopupElements { themeSelect: HTMLSelectElement; themeDarkOption: HTMLOptionElement; themeLightOption: HTMLOptionElement; - themeNyOption: HTMLOptionElement; languageLabel: HTMLSpanElement; languageSelect: HTMLSelectElement; shortcutsSettingsTitle: HTMLHeadingElement; @@ -87,9 +86,6 @@ export interface PopupElements { pointsResetConfirm: HTMLButtonElement; installUpdateNoticeModal: HTMLDivElement; installUpdateNoticeClose: HTMLButtonElement; - infoModal: HTMLDivElement; - closeInfoModal: HTMLSpanElement; - infoModalMessage: HTMLParagraphElement; } export const getPopupElements = (document: Document): PopupElements => ({ @@ -159,7 +155,6 @@ export const getPopupElements = (document: Document): PopupElements => ({ themeSelect: getRequiredElement(document, "theme-select", HTMLSelectElement), themeDarkOption: getRequiredElement(document, "theme-dark-option", HTMLOptionElement), themeLightOption: getRequiredElement(document, "theme-light-option", HTMLOptionElement), - themeNyOption: getRequiredElement(document, "theme-ny-option", HTMLOptionElement), languageLabel: getRequiredElement(document, "language-label", HTMLSpanElement), languageSelect: getRequiredElement(document, "language-select", HTMLSelectElement), shortcutsSettingsTitle: getRequiredElement( @@ -355,11 +350,4 @@ export const getPopupElements = (document: Document): PopupElements => ({ "install-update-notice-close", HTMLButtonElement, ), - infoModal: getRequiredElement(document, "info-modal", HTMLDivElement), - closeInfoModal: getRequiredElement(document, "close-info-modal", HTMLSpanElement), - infoModalMessage: getRequiredElement( - document, - "info-modal-message", - HTMLParagraphElement, - ), }); From 52b808b5add8aa2ae10847cd0d8ced31cf79de21 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Wed, 15 Jul 2026 11:40:11 +0300 Subject: [PATCH 40/63] fix: added glow effect for enable button --- resources/styles/popup.css | 4 ++++ src/ui/popup/controlsView.ts | 4 +--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/styles/popup.css b/resources/styles/popup.css index a55c91a..e0a74b3 100644 --- a/resources/styles/popup.css +++ b/resources/styles/popup.css @@ -568,6 +568,10 @@ button:disabled:active { cursor: pointer; } +.change-eq-active { + filter: brightness(0) saturate(100%) invert(19%) sepia(99%) saturate(7473%) hue-rotate(277deg) brightness(99%) contrast(126%) drop-shadow(0 0 8px var(--accent-mid)); +} + .info-btn { width: 20px; cursor: pointer; diff --git a/src/ui/popup/controlsView.ts b/src/ui/popup/controlsView.ts index b95d1ed..e9e33e8 100644 --- a/src/ui/popup/controlsView.ts +++ b/src/ui/popup/controlsView.ts @@ -46,9 +46,7 @@ export const createControlsView = (deps: { return { setEnableButtonText: (enabled) => { - deps.changeEqButton.textContent = deps.getMessage( - enabled ? "stop_eq_button_label" : "enable_eq_button_label", - ); + deps.changeEqButton.classList.toggle("change-eq-active", enabled); }, setMuteButtonClass: (muted) => { From fe7d3dbe53c746c0c5442faf167583b566db450b Mon Sep 17 00:00:00 2001 From: Maitrog Date: Wed, 15 Jul 2026 11:53:35 +0300 Subject: [PATCH 41/63] fix: donation link has been reterned --- _locales/bn/messages.json | 2 +- _locales/de/messages.json | 2 +- _locales/en/messages.json | 2 +- _locales/es/messages.json | 2 +- _locales/fr/messages.json | 2 +- _locales/hi/messages.json | 2 +- _locales/id/messages.json | 2 +- _locales/it/messages.json | 2 +- _locales/ja/messages.json | 2 +- _locales/ko/messages.json | 2 +- _locales/nl/messages.json | 2 +- _locales/pt_PT/messages.json | 2 +- _locales/ru/messages.json | 2 +- _locales/ta/messages.json | 2 +- _locales/th/messages.json | 2 +- _locales/tr/messages.json | 2 +- _locales/vi/messages.json | 2 +- _locales/zh_CN/messages.json | 2 +- popup.html | 10 ++++++++++ src/domains/localization/localizationService.ts | 1 + 20 files changed, 29 insertions(+), 18 deletions(-) diff --git a/_locales/bn/messages.json b/_locales/bn/messages.json index 3b9c408..8efe0c6 100644 --- a/_locales/bn/messages.json +++ b/_locales/bn/messages.json @@ -81,7 +81,7 @@ "message": "ইকুয়ালাইজার ইতিমধ্যে একটি উইন্ডোতে খোলা আছে" }, "support_me": { - "message": "আমাকে সমর্থন করুন" + "message": "আমাকে সমর্থন করুন:" }, "settings_button_tooltip": { "message": "সেটিংস" diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 134d0ae..15dbff2 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -81,7 +81,7 @@ "message": "Der Equalizer ist bereits in einem Fenster geöffnet" }, "support_me": { - "message": "Unterstütze mich" + "message": "Unterstütze mich:" }, "settings_button_tooltip": { "message": "Einstellungen" diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 44e3b5e..b27d80c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -81,7 +81,7 @@ "message": "Equalizer is already open in a window" }, "support_me": { - "message": "Support me" + "message": "Support me:" }, "settings_button_tooltip": { "message": "Settings" diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 2dbf932..07e036d 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -81,7 +81,7 @@ "message": "El ecualizador ya está abierto en una ventana" }, "support_me": { - "message": "Apóyame" + "message": "Apóyame:" }, "settings_button_tooltip": { "message": "Configuración" diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 1796b85..8c68718 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -81,7 +81,7 @@ "message": "L'égaliseur est déjà ouvert dans une fenêtre" }, "support_me": { - "message": "Soutenez-moi" + "message": "Soutenez-moi:" }, "settings_button_tooltip": { "message": "Paramètres" diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index f49012c..06b7d52 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -81,7 +81,7 @@ "message": "इक्वलाइज़र पहले से ही एक विंडो में खुला है" }, "support_me": { - "message": "मेरा समर्थन करें" + "message": "मेरा समर्थन करें:" }, "settings_button_tooltip": { "message": "सेटिंग्स" diff --git a/_locales/id/messages.json b/_locales/id/messages.json index 95d0eec..461f05a 100644 --- a/_locales/id/messages.json +++ b/_locales/id/messages.json @@ -81,7 +81,7 @@ "message": "Equalizer sudah terbuka di jendela" }, "support_me": { - "message": "Dukung saya" + "message": "Dukung saya:" }, "settings_button_tooltip": { "message": "Pengaturan" diff --git a/_locales/it/messages.json b/_locales/it/messages.json index 617d84a..ea13d3e 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -81,7 +81,7 @@ "message": "L'equalizzatore è già aperto in una finestra" }, "support_me": { - "message": "Sostienimi" + "message": "Sostienimi:" }, "settings_button_tooltip": { "message": "Impostazioni" diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index b267b6f..25db6a3 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -81,7 +81,7 @@ "message": "イコライザーはすでにウィンドウで開いています" }, "support_me": { - "message": "私を支援する" + "message": "私を支援する:" }, "settings_button_tooltip": { "message": "設定" diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 8991549..7ec2f09 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -81,7 +81,7 @@ "message": "이퀄라이저가 이미 창에서 열려 있습니다" }, "support_me": { - "message": "나를 후원하기" + "message": "나를 후원하기:" }, "settings_button_tooltip": { "message": "설정" diff --git a/_locales/nl/messages.json b/_locales/nl/messages.json index 7c828c5..7208c44 100644 --- a/_locales/nl/messages.json +++ b/_locales/nl/messages.json @@ -81,7 +81,7 @@ "message": "De equalizer is al geopend in een venster" }, "support_me": { - "message": "Steun mij" + "message": "Steun mij:" }, "settings_button_tooltip": { "message": "Instellingen" diff --git a/_locales/pt_PT/messages.json b/_locales/pt_PT/messages.json index 0c38331..a8639b0 100644 --- a/_locales/pt_PT/messages.json +++ b/_locales/pt_PT/messages.json @@ -81,7 +81,7 @@ "message": "O equalizador já está aberto numa janela" }, "support_me": { - "message": "Apoie-me" + "message": "Apoie-me:" }, "settings_button_tooltip": { "message": "Definições" diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index 499de3a..064cd58 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -81,7 +81,7 @@ "message": "Эквалайзер уже открыт в оконном режиме" }, "support_me": { - "message": "Поддержать" + "message": "Поддержать меня:" }, "settings_button_tooltip": { "message": "Настройки" diff --git a/_locales/ta/messages.json b/_locales/ta/messages.json index 2ae14c6..af9583e 100644 --- a/_locales/ta/messages.json +++ b/_locales/ta/messages.json @@ -78,7 +78,7 @@ "message": "சமனாக்கி ஏற்கனவே ஒரு சாளரத்தில் திறக்கப்பட்டுள்ளது" }, "support_me": { - "message": "என்னை ஆதரிக்கவும்" + "message": "என்னை ஆதரிக்கவும்:" }, "settings_button_tooltip": { "message": "அமைப்புகள்" diff --git a/_locales/th/messages.json b/_locales/th/messages.json index 11cf8e2..8a19d3c 100644 --- a/_locales/th/messages.json +++ b/_locales/th/messages.json @@ -81,7 +81,7 @@ "message": "อีควอไลเซอร์เปิดอยู่ในหน้าต่างแล้ว" }, "support_me": { - "message": "สนับสนุนฉัน" + "message": "สนับสนุนฉัน:" }, "settings_button_tooltip": { "message": "การตั้งค่า" diff --git a/_locales/tr/messages.json b/_locales/tr/messages.json index b16c6fe..eb9ffc2 100644 --- a/_locales/tr/messages.json +++ b/_locales/tr/messages.json @@ -81,7 +81,7 @@ "message": "Ekolayzer zaten bir pencerede açık" }, "support_me": { - "message": "Beni destekle" + "message": "Beni destekle:" }, "settings_button_tooltip": { "message": "Ayarlar" diff --git a/_locales/vi/messages.json b/_locales/vi/messages.json index ab5322d..53d6898 100644 --- a/_locales/vi/messages.json +++ b/_locales/vi/messages.json @@ -81,7 +81,7 @@ "message": "Bộ cân bằng đã được mở trong một cửa sổ" }, "support_me": { - "message": "Hỗ trợ tôi" + "message": "Hỗ trợ tôi:" }, "settings_button_tooltip": { "message": "Cài đặt" diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 48a5f8f..7360ddd 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -81,7 +81,7 @@ "message": "均衡器已在窗口中打开" }, "support_me": { - "message": "支持我" + "message": "支持我:" }, "settings_button_tooltip": { "message": "设置" diff --git a/popup.html b/popup.html index f7b8e44..a3adc2e 100644 --- a/popup.html +++ b/popup.html @@ -89,6 +89,8 @@

Settings

Language
+ +

Shortcuts

@@ -101,6 +103,8 @@

Shortcuts

+ +

Whitelist

@@ -129,8 +133,14 @@

Presets

+ +

Community

+
+ Support me: + Donation +
Translators: AntonioOliveira2, diff --git a/src/domains/localization/localizationService.ts b/src/domains/localization/localizationService.ts index 6494137..5e7b53f 100644 --- a/src/domains/localization/localizationService.ts +++ b/src/domains/localization/localizationService.ts @@ -125,6 +125,7 @@ export const createLocalizationService = (): LocalizationService => { setElementTextContent(root, "hide-default-presets-label", "hide_default_presets_label", getMessage); setElementTextContent(root, "community-settings-title", "community_settings_title", getMessage); setFirstTextNodeContent(root, "translators-label", "translators_label", getMessage, " "); + setFirstTextNodeContent(root, "donation-label", "support_me", getMessage, " "); setFirstTextNodeContent(root, "help-with-translation-label", "help_with_translation_label", getMessage); setFirstTextNodeContent(root, "source-code-label", "source_code_label", getMessage); setElementTextContent(root, "autostart-settings-title", "autostart_settings_title", getMessage); From 24015cfe7068d48638c6728f0b6225136c76ed9a Mon Sep 17 00:00:00 2001 From: Maitrog Date: Thu, 16 Jul 2026 10:38:13 +0300 Subject: [PATCH 42/63] refactor: clean up --- resources/images/donation_144.png | Bin 3963 -> 0 bytes resources/images/ny_panel_bg_300.jpg | Bin 29982 -> 0 bytes resources/styles/popup.css | 173 +++++++--------- src/app/content-isolated/contentIsolated.ts | 58 ++---- src/app/popup/createPopupApp.ts | 18 +- .../createToolkitWindowController.ts | 34 +--- src/domains/theme/themes.ts | 78 -------- .../storage/storageNormalizers.test.ts | 19 -- .../storage/storageNormalizers.ts | 18 -- src/ui/popup/popupShortcuts.ts | 187 ------------------ src/ui/popup/settingsView.ts | 67 +++---- 11 files changed, 129 insertions(+), 523 deletions(-) delete mode 100644 resources/images/donation_144.png delete mode 100644 resources/images/ny_panel_bg_300.jpg delete mode 100644 src/domains/theme/themes.ts delete mode 100644 src/infrastructure/storage/storageNormalizers.test.ts delete mode 100644 src/infrastructure/storage/storageNormalizers.ts delete mode 100644 src/ui/popup/popupShortcuts.ts diff --git a/resources/images/donation_144.png b/resources/images/donation_144.png deleted file mode 100644 index 867b47bd0298760bcd9a0d4d46bc799985fa0fe8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3963 zcmZ`+c{J4B|NqRG8O+!Tk1S)UY$ID9B*s2u8Dnc~kE~;fiIKIMNs>romz1%Necwtn zq>${A5Tkg=l1Y!Hw0x&Oe}DYWJ@>xO`~5zz`?|~f-1oUDSUXEzxC9&k0A4GM8IFzA zKb;H49@i{Bbg}^wXl!c?08RH0KQ2PqHqsY^vju>-lK?=z2>{>OF7i46M5zP7hBpA{ z=L3KkvG9q50XqQmId5qO{QlEu-SjNBhm&MwYtG4pfO#arH{&D{0Dw=@%FNg)W^Vgo zY?`OD)PPaL!_i6q{4n%!aeUcqrK}H6s)j_##7($bO3E)|0gOP^WV51K`AkW(VkM7{ zhhi_+NiM=M{7rGOac|rdmy=G?q)D6B!%ynlz5(9@ete8xA|7o;j6GgEID63jc&W_l zcg)w%-D_7r?-YKWw@YGF@9%+0;6RWVm=5wRZqTy*56%LJT8-Sakk#DTgVuY>vd>g0 zfjUHJBYZvOCmBVvVg~uy$dG-EMR;hCM{puH%eEh$1E;T%BPj#Lx&k9yKdugd#2orc zi-pO@DIJVy#BtAJkiL|z&c10|qa7#=$nEdRcw)jq0;{XAU7e$l9vDfi7Y+p}?jaFA zM&sI8s4i!xB;g*f0|qf|$4$+dqr73nGScz|k{|?8b)&f~8>10u&z-~h5>&ORJ531b zE(P*P)(t5|JK0?vY49K1zlKjsO@zf!AS`^C#lf>BQ0 zQ#dt!p-ySMM~#U{oSS38_CB}+ ze0At|ozv1{gWSNM^OCUZe@l@i)P1UP7;15*giwlPH|15{lpd@x|LW$4QZ2-N;WQUl z@C341I*xI;n=I@2?mkmGW(0~2K3@$TZ|pk|#c`X>Q{x&=rJa5zf_EwchY2J#qCE$7 z3|p&hgb~xUk-`06e|_9kw$YpagiDfnsBW#3r8#vGn~W)>)KtEpKv2=MxJ)uGtiI7u#&>uPz+dwPktFk|nz z?2W2Os&_D9&)1LAUXS;>p3V;pM6&8g(EDC8FfZNU?SP#44Y$Wh!T;>&vd%Gy%*b`! zkOTS)ypcGV23>C@X*8j^4FDO-8xQbnzmLd~9rbv_TAqope#fg2meuwbqVsJf8Lc}!0iaarh*v`s4dt-ZU<%D1Svk9i# z{eKp*qKYJX%jHn{X+v{R7nLL&Rqj=CO7ON9_UVE`^RL$mJ2GC6Ro>~gJI<;uT*W^Z zw-|R6_$hdQwDl$Z78IS*n)zu!z=)cYAoKx7DVPDrUAHzSohBwv`Uf-8Yh^|eR2Ro8 zhtRL&1mKmyAiNqBUFw@;L3!JJO&5Yb2j_1>#IM~~LFYPdW#e4di!iJ+tzM=&T=%oZ zXndrKM&nRWM4j?wH-d7Iq?FM+F1`nePl2cs;g_>X`8bt5=1f5a7tmJf=)Ny;3)rJg z2@|+D16=?VNitP#MLDX}aZo*NS)}5PQ!rQMUlq+yzLT#^%>n-D#L61L^F^yJsQ z&&}$1#`zS4s8;5!XpI@M`ip7jbVH+ZNaAsTzundWSaCJ!isxCs35W%7A|s}V@2D#U zLNz{bOm7-H`WOnl*k7UY@tiz`Zd^^j{_WA#bca7k6u z*b|D99NI3u7T5(<799dX**NFR3z91!j0$yQ#hM=}LxwfhAumnykh~U^(=`SSfFLOD zp+PZ7oBu#wLRAa8f2ywOZz!gwJatdx-vWonc@X5VQ#x$Kf==xv!KWhp@RlDuBcE6{ z?W|2z>aF)9lZlc?>{;}iHRDY<2(n?KgZi#P*C)Z1G6P)Ky%1#+Gxaj_(CW>wiX0R* zQYl9Ztz=L5TmKPO_>@nzbnUqh1UXp{dCFkc~*`Uy@_v|J5jLH&^|B=;g-uFIxj>I%f;uSRFYroAh4sEa&UwTkU z6HdLnZ@Q8_CZdD+!OmaUr>A->XeIB%V4e`Na%aQGcHhl_d|Y+8_r1Qi@p$A?S!msJ z=URHB6yqTSI!L-w*{=3mE+|2I0`We@PW<^Wcol>xt$VL-G4!8h-TT)wXNCvJ;egTF zxM7{u3N6o7_45vL1AQ9y$zb2S1$|ay%R2W$hB7Pr%^42NFqrp4Q`di<93vG_AQ<;; zOR{CKCVxtd7FO0BPS6|hsVIJO+1_ZVMAkr+x+BsqAZ0mYm-fNiO3_z|C`;UuHS{vR z@{bYt(cMlm?0%1SpSWqvVaFX9)ylP*T?1M8l^9B`8@TXU)X46{Z2^?=0C+F)X!gkN zVk#FuHOjS_k13UYeJXWy`YNV80li~FmzY%au+MdW7nt%<*BHVJV{4 zBsS(|aEhEgKNqXl98rllXvJ>U%Lbv0?cV5lEq8^)^gJ~Tq1pZSUCs@>lI8G@|BBc8 zG$LMvg<`6fpN|!b{0@|@CX4sB7Y>}F&({VRLC3x@Mi|NCXEzq&w^AY0yi?lA|1P&9 zAN3RN&H!U524~J`y|lm+X=8G3Dxz99L1^BpS~b1*S>k@@E0}uRhuq&;?@h4R?;9 zCzB|9LcQD+ZYJr|^{ng5;VZl`i-{K=b-3|@cYPM_1#%5>(xgf0(am8CKUJ81{Vnl| zcfm#wFP^KyN-?8APb$h_?{W;+4=*nnvZ#n&b{HChT-0=nw^2KIQ9ARoHj}+w0{PVk^Yg# zS1*d$OpBol`xJO`8)#95xf(1dt!uTp7ZN3px1HH3m^wzouV-hA6kn=O!LbtHdi0;f z=fiK8@#eHh9S_iMig(~dn4H<;x*UcULYMzSvfZWklvaL&;wxSC*JwJVr{kB_3y-kk z<;-t9M5%VxG*)}%W_~ie#)w+aHj~1f@jge?V+F6Dj)r1X zin}mY-IlqQk4O;g z1kaOZH53#gj;YgDm!mzSB`b8fzGk4dZM;p}8V;hKH(FrJy~yR5$zR{rS2ScWrMF#= zB(m@M5!vsKbZz}dYRA96jE0)4-DAGKVhCMsqIBtLihVg=e~ zj5q^*NruD6hozH+5xE9EQ224Kew?t9L zu8h6WXtKj_TPXsqHJ*}~^NKDAdGvs+v3WNcfp()z<9y{fY3jQb( z5Shwr{7SxdCXOiCJ1F1?Y*u=W;SCFQ!x59;}gA*WoYdO6aqma1|UEID>8 z)8Q=JFO>kjbV|3&A!|_kOIdGQ^TMzSqbT!wz_($E-D(MpbI@U-9$fRb6vK19^>g^s z85k4CT}1O@xMU12O#^vxf_xII)th_^!d{BMG1lz7r4gc@bUZ(lk=hKqJ~&LG`kf zx<{X*>I<-9NKO7lNZ|u=Ft_~|ktwhgoCfknX-3`6V~IF60R>~d_eOs3PjarQz3Ox+ zK8B#ZknD7Lo;v6g%i-@)!@2_<2k-Nb2S6SrD}8jcEzSqdz;J)dtp(*2r<}}GLRUGZ zzto-e#b8Oo*(~iWA6=oq=5WoQi5P6_O*;264jSJgupU-c%xJ;(O{Lvwf^C>3prete zzWw35@Q?%885iem$!#CGc|FD|`TBxit81#LYwD}3ZyVzM{tw|&xL;5}?Eg_GYaR7%rLd_Q;EH8XMON2uhX-3w5?#ChD q;)EQYogVCe-7neCIZVdTtkr-GF+tL<$(bCs1Yl)uXI5|GedB*H2le9s diff --git a/resources/images/ny_panel_bg_300.jpg b/resources/images/ny_panel_bg_300.jpg deleted file mode 100644 index 6fa10feb533b8eef5ee5dab8fd3a83035a3d14c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29982 zcmeFYbyQqUvoE}d!QF$qy99T4ch|w)-GT&n4Z)oN!8HU3ZowS_0fLj@!7q89bKdtk zXRUj`Z=HYcKeuL0&#$Yix~r>uclWNn=XvS*JF>2vgoKHzx{9=%q7(oE0RXbBqOFrN z6gmJnI(fLM%ZQWd>gkigO#)B=0ssrJ1Av)@yNi;Bv?lOEa#9jx?k_@r@W1JL0eGni z0A`tGRmsTy&Hw*}Ff3f$JOBWs`XbF~330c0!Qn61!P~>-5C7K-#x}SAgQ1}RV7C_o zUohSuZ2dQ8`b*|-EcypqIyzat$o#R{#nRF85AJ`#Z@oMrFBq)&f+M|bA>J=|?gdjh zcskm?;NLG8+Yw^s4gk=|fA}5{3!4|r_JWb!G}R?uupj`yBU}FioBsoQKzv^81ON$V z7hgA9Ya0(TItzL-c0N8{GFgbX1H{9FS=G$K-ptLCOv2gG#mvbU0RHatA1wg$k8jCd zoXpM6$IZ>m!Sdq%f6@Q3@Ly8@XYgli|CYE=`)kZVxa0rk{ag3nJm(?+;JteBP13)4 z=Gg$y8UX-!EC1$E6#xKQH~_SK{YQUr|I8N~4-Xf9R#tCsZx&mK1m*IA@wDqucb|SNN`rnQ4|4{88HvEBq&+8X}Rq_O2o3H@rU$Fu3 z$v6Owg#>^N^Il3o{~9+%1a08Ym8V0x_xHSi!7t^1rT-;?N_@#ex!YQk{b5U}Ym!-b zx_SMytEw}?b1fB(Nf{($s(6G>0 z&}7ie&;ro1&|1(I(5}!y&~eZ?&=t@v(EZS}&|A>Ipr2q+V2EHCVfbO>V02-uVZ31? zVKQLKV47hDVCG?VVXk3eVR2z;VR>L>VfA3`VEtj^VBf>m!S=vT!*0V~!NI}d!!g1M z!KuKR!Fj+%!ezsKfcp$L1GfwJ8y*Rs6rKZK8eSjX2|f%y1HKBr8-50U5B?qj9f2Bw zA3+7d62TWC5upU31K}&eF2X$`1|lt@5TYic9bzbA7Gf>p0OA_r1ribx1ri^U8j>wi z2vQc(N2Fn-Eu>pyOk@UR31mZLPvivTa^xQ5W#kJK6cid1Q51a?50nIyN|Y}s8z{d~ zu~1o1;~mEwCkm%DXE0|Q z=h_N=aTRRcTF` zTG>gtRRvIyQ%O}>SEW^TRqa%RRZ~@ar}jggT|Gd3NCQj5M59XMT2ovzQFB#`PRmoP zPa93!P`gt5Mn_U7MQ2l&RX0#~Opid%R9Xm{@0#xV%T2+p%pK%z&rjN~)F0a4!oM$oJRme+D^M)3FbEW69@H0185|zG8zK`@5sDaUA371n z7M2or`9|kWM>uhKNceVyOhk1gYNT7_Vw6Bs{#)p`wr?k+Iis_qpJFUyMq}AxGh!d& zEaFDv+2ga~pA)PSCKI_6^O9hboRSujMUpF0FjD+dwo{c-ThhqVV$v?tP0~j)UT5TI zB4&DKZe}TFwPjOfCuKk6*yPN=6Mt8iOOzX(d-LA%{Y;){UTr>cer*0-fo;K3p-f>* z5lvA>F>J9{@sASilA%)G(#kS|ve>f6a;Ngm3iXQqN}kHfDx#{yYN%?@>Vpr4AEs&~ zYuaj=YYXde>ta6wA3Z-F)|=KZG$=ImHS#vrHc>a_He)r%w18TCTYk0Lv~ITPwN1Cn zxA%1jbToIebe4CKcfI?B^C__#u{*r``Lo~Ws~(r0<6i6D?LL#f)i1hV=K9t8CkGS; zh6kkwzYK{DeI6DX?i%4AX&>bsZ5`ttYZ>PnZ=T?qXrAPrZ28LbwQY)Ts$*Jkx_d@s zrgv6yc5qH^Zfst8etJP`VQJBDadXLXX@A*a`E12&<$g6}4Q4H79eq7xgJ`4Z8~wMs zP43N4TasJj-!;CkZd+_0?YQqe?1uk9`H`_lx>vc+vEOwdeK2)saJYNqa&&(jd4h40 zdrEiO^i%Zb_%GdGyJv1^&*yO$_!s4uuP^(qRIfI!9k1_iVt(WQuDIpB9lF!G+q?IE zfP2V#q<`#qQg~W>c6@$*J_X3dES=3EfTifmSpWnE(7{k(*gxlj|D@+tARK`D7lB_8 z82TjzL&HG-AsASgzX%o<4h|j;4i*6c5fK3a`M(kf3aL&x<90?uY)9;NU^9P|z@7 zgcs&Z!9PlX7n4CSu;>5?3Kj&0f`w^R(6lh4sB#ZW0!+X|$~dF<;ya1%`oz2E&8@vKj4rOTnLdc-vJU<31XX{#dh**m2w&`NKmc z2Jg}Ej^)eVU`1N2c@8j4f2Pj4xFQBnQEgi0s|^s|(Bz;M)qKBQa}e4ZFu!zPeg;J3 z#ykG#0;opu+*ht&R*o_*mfZsao&MaZ1F%E_xSuBU7pb%2SZaM_WMG@}xKB{^dV7 zUhk}(n5QT>`@B3~!P=>vM$|`Z7P@mq09701Qg02nXJzZ-=qJooKUcjt&YIQ6WfKt{ z;lVn`+G+F7mk7WM;kn_jD`{NYG?SprD*NBM7rgL5300>nj;M^hZd%n-W0o%aAMOj+ zQvIuH{J2&4XmwlXt$!JN0K!IP`G56)GP;OI5MOC%5pG9oM4 z0AMrpMnll7?XL-emS&cHH9U91_O3eP%AtUM+LB|k{#3Dzsm9ODg`>Nx4S?l+sQ6}7 zl_(^n_o%=Jy-c|EW51juS4J{emZ6)orBZ_{U+yOVuZmG!e+nv)>>7;~yktRN>T>yT z4r&#vzD?ZQWU{6Y#?b|r=6Tf`8wFl)>{m7XJnTl|oC4tUek;6Is2SZSZOt!VdMKV) z>2%C|=lu2k^1@+@G{v;in*neCl)vidLcw`5^#A=?CCK0DLMh}=Bh$x>KUPZDZXC77 zbhR#jGB}rNr$S8hyxT`izGBH(w@5Ay>K2&-ZbGXj)`E}2i`qgC-MM@)cL;lv4blTn z-@V{B{^SU3dQz@K z)%I)>7auU20@q^h?R~3Bv`7^EbQVELh_6U?QceHE_SgN`n{AsIPw(D%jR`;BX?Pva zp!xN<8eLY7z@>|JByu6GBR{)_wz{`9kb6(b)z+ibFGR; z+AG|O*Av+e2b0}tohE_2a?OJJzFcOF5f{hv1ZF3eH+(CL2S0LM8Q;(5?6PV=H;;H< zVvQ_M3b_DZ(vLiG!Nh5^O9NVux~JK+5Ep~GW#T&8Kw}e&20a4hXc7Xxi+Y^pLQ?r^ zxUk0?stTRYInTgve)CUU^YjX~-8E6nEm4A<@csI`ToylhJRUI~Lhq1GYV%M(9dYII z4eMA_uO%Oq>NLq1&uiC6wY0FObLxo-9WO5S%of_m2Yi!U|FO!fQGT$rNbGrQRIIr; z5!S9z=;2Zl)0(KP`SrG<{i}s6WrU!rgFw zTG+(GM2KiXW^{Gw*q*dhlC~;3FTwkuJK#(4!k#F&dc(&{22!h4H&Zm~+rc@> z@73|OJ5gQeBi@ydYx#!FSkmsTeSU3+FwthBGESY5NU?}ecc(-7aHCM&mwofLg=auL z-fA>(NuGESY4N+$nPtUOi-N_D(Z};c`jgG929>^CFT2wQ5~JG&EeY@Zqs5~B?aqsL z`!0HlnaWR=2j1;S_&YQNT;Eme?m2zkcC&i@GkAym^&gq8H6}jmHwf>#owyeWkVL6n z7B^pkPilR7y?PB2{a+mvH8@8coU)C5B`e`|Pxo1FV-^QOq=g@6daQrom#_#Aa?`&XeJf&*pI76whKNG@=oO zPG!1Mu=f_nW*Sol?JfMb>j&9RE0u!+nzM&`8#qci?x?n=K$B8_hsWM2Rt7%Q1nZNn zCJSOkyia@d8|R;zxtPxcA4Gyh0vr9Hz_3f*JAYF63|6! zzTKMd)PH0D53?^vDR>YR0D=LYfr9WNlSFxHtO#_K?G!fQ>a?dI*Y$acq9rq?FLH3w)+iX|SWM#4d6k2D8noFsYn>}Kjz5xa1{Oe^%-d)BVQOSuuU1dB9zdp+=n@lClquHGR zhU4Gs6h`_*MvC-B%QaxObXUp`CTNl6b!}S?fevO> z3;IVreOsGoq$C5Rt>`F__;Bn?^>9r4a43vaK+!GoM z)T@Y??@J`r<~16$r=PwwqjHa~xwP$yKLfR-S%nY^hN{YS1qC`SC9w)tHq11pg7owO z6gs`C3RH&dxi(TqS$$FinSH`C2Kfqq_3whN3d&Vshe-CAS3v3`MaD4Y+$e!VTSpZ{ zBV)=id}@M41H1eX_imkuyR^1+u7%zXY*$+fPYyxIQT4uLks}r67WVMfqX+M% zYgH5x1cmfiTT7$MQsvVM7x!8@Hmc96Un!E3r*}l7rzXuYk&&yq<`w3Ot=xV~+M4%M zRzWFiRbgg7pW?Y`p)!4kH<4|>R}Kxhn*OQJ$To9qo}v`qRjKDx&xm3sVIM=WD3hcd zW~KCoX~=^j*-q?K%pk@MzY=@cwoI2?WKqc;RN9j8eODm93B7D?opUtC%Y@xTURRS^ z2*1dnMm3#Yz7uwVr7DKtC#S<)Awr$|@LDN~TIJy5h-aTo6``5dX$1HBK*H$g!n&et z*zNgj<)Sv4_@kUBIz^mdMNORsx|4FH(5QU6dQ}t*AwcyO7#oMNk#t z%xUKRkTDTMQ0HF5SW}OH&h|4n%;>9*btKOWfbdYEUzwR9k}DLDvlLZmYN!+y zEv}f^l-gR(NYR%$vM@0#y*ARhgo}5iZT$=2V%mdpToGhLNY8U1TU z0;$~e!z{CWRhfaEd4jFgTg)n7pGwLBIhYs;5(3#@Gcu7Wg0GYGO4wTI<&C|P2Ol(( zw@V^XDCJ3K;ZbJbk4q!t8SMfz_)8jP$q`HC1z#s`l_cU&{i#ENizQmACWjLHdvd8@ zl@89T5~374;_C<;#8TJ5v?(y2?h-RXlM-WxUPKfAIfnYXbkr))eN~{|S`8ZG|-_ zk4$^;o2py4Pc*u(P(-DN8hVsa|GpeiGfY!c2tIQJPsrTGPdq4}2;r_$iku-Um$&%_ z%$wPR@`1s{Z%B=JDkzC8`P>TKO53vBkDgQ|30mxuRC)i<)FZmr5n}w^Y%V3ok{-yC z52#{bW^mV>3$m#bhGt&^=uavZIL$@=9Om2?=)o1LIo76@Han?_;5My9B>0#XK;1kA06_ zc&D^-MKQdbRK-!igW9J2WStj3g`J~09dEl>6*$6NfxzW=uzBq2_ z7c0p2T?thsyyZBvB<2>hGA%%wBpgxundlAH8(vgQ)gpyKF%EWe8kIwr;Tk(hs;?Um z2ETlKu}2lfB%+kCUF8bbz$)KoKp=c5U^ER2@VEskAKdt<#nir0Aagtep@WNSRT1Li zI}#~YrXQZFV)Dr`GdsKdf~+KyiIcv`YEpiXcfkj`5tuJkhTQj?^A^fi&kQ*gfG|C* zw8B7gwmseB5HeZPNr9oy{WQfZ=#_oQ(VE|-F(HUT1ggmta;lp0MDBwbMCUO0;_D}o z>qvma*T_-DUg18-v1-Ut2=y;$x?zsrF3G6;7zpki7-;R=DD69n{Do9`F`UtOh-pmY zHR|DcTi>y6=rD0-b|S7zhEtZpAP9zWqfya4{?-UG=K2h$Cy3!AY&z@2Aw#B$)Z(~l zRaj(*_c096_q|cX@bE6Yz?&IbxRO_*Aq9bOx)ivZ4&3{?r% z@FksNVePM`szI6KR^krlXlAMHr(ZqfQbDIR6hxGP$xG=$qH+lGyNI0OZ(S}=vLPJu z;Z&p8?Y${8Y0zQAo>EAfbfvr8a-=Dl^Rz{2SuW9q8Z>CL!;+f#;p*eTS`^sJ=q31E ze5D#;-$O|@fv8Zbl8Q(j1V?jd99ga>aZ##q@^(D*v%|D9%p?j8L^1KG6TLLyY~~#t z@}>&$wUY>5Ma&8_OUWP9ymaBw2*bh+ra0n;6zF>%yy^FR=E=aXpV8$?GdB*u`Glwq;eys&lQi*yxD0j zie3!3GxH=k;8n6Ch#YebqYxHaIvKfw?vw1JQ7^}(`uK`wuj2NVcQ6&Rf~aYwIhsf#f66VFe=;DF0&xJclYIDXDmlLE1fp zTpHVqo>Sfwk9qu#e`QWJN)4rEKA*Cr61AedLkl__o3*RT+~rJ7>6KOV#ws}|oko=IO-blhFWA|MlEJ7yU1+$)X1QCh0>DmuNWNAow7Y0&)gbUFh=bC#X_7BxT4jL9J9Wp^6~? z7ZY2fD2?Xk%u&*^xi!s|r@{$=v`;?st6}fB>SRuCndd(qdMY=}XKyXv{f6(fG7K%< z(bp^zh8a^iuIiS>xh}uR#qaYEwav(KNEQB-{*W@{##XI#@cv`XUJmC7)6-(IAt(1U zU_I;|!Gy)5XL~)rCY!4)!q&aB>hUyMvJ=(zc3Jzw_lqza1=xu}E;A|;sdHLdx5k8r zlwmKW8SHN^Lq7K&AJ5tFldR9_P8esZN1lNPf;`#j`r-8>we0o}ovNv4(P>ze$*$K2 zK|AIKCm6#Ca+drn2Sdt|J0}ZJa^JjA-lZN+*xOAqEs>F2R$w4evteR?+8erm`Z?)y zTk71Q)O`kxPWeHx(1grCq@SgA)Tpgn(KP=U*V})%n$wc;6Da}2?r0xG*(F1ES##sX zQ_TAe1kd1)$jVR2f5I++Yno)Q3I62x5PQl#{2=3GO)yx;UNA?{b-pUuVs{%ov_c>~ zN^DoBa7>UV^GMDhuT|Vcqg2J&dk%Nd+AQ%7Hrb!!(|ED!Vax~oG}O5V4Z;^VAnDCz&?YSrdHJjvkL(c69SQ3a@Rm z)^b1hG|y{u<(v8z>{2Fhewe%1Fg6lAZ2RJX)oOL`t#zc`q{6^9ks0&k%14@9IptHM zeEa(%JMpW(TQTxNRxL)Wr}3!W4{E3HjFaVY^u9k?ao1c=tW)-uNN`_&WBtV(tEGI; z#pK^zOgXzs@U6WqG)&dZPa`AFXt7*70a6-}>YO%d!+^VnJ85O#dhTGYaBw_e7~i&_ zu}bI3eY9}jvQ`9=pD8?h1~^l<4@RCiGSb46l$Y22zR4NX%%|P(6Hk{nVxRLw`kg#j z>B*2xc6$=r^XS5crX=i(MP(a)y=Iu z3EKzKwQgAW`}PFZ*}Z>aBZ7b1(4YmS4()d<*wZO0PRlUlL*GT9XSl;VPaG8Dj#Pn3 z*Ff8tt?WVek7ut+XIRI4nnL$$CP$@mn0vj0#Y2P!c!<>z3de-CU1nQ zJWr&1N4ZJ3;`Hkc!}1x2+Rol@E)eQaU3~DToLSn)XUQyaN8S9qHYA z7Cs5gaYlSX!S{_gGk1H*{D&uQg?776(|(+2Q$8&p)^!YD0_z{lVp?#U+iJcvo#Gkz z2)4siaBqKibsR(y8Iqf|V;Lt>Cx_Rh~UTD*$RHJ?D6W`nk3iTDNk8s8B`84JhDf?Brs zt+g#U2gnV?E*@*EnHV#8+J(c-EbO?CBNZ$+Nbe_CEemQ^Rx%R>5VNoC4qD3sUphsM z%bM}sW)g2%e`a?Gsl#==;oh`yr)9ym#!J8pVK`8#p#cyJ9}Et$5Rky+)5cf&^4`AA zmDnJPabFhW{W+aW;`sE2Q?0gcgA7Wkz4=uIwYlyI zYA)P`xvt11?XmYQH)1YpIbqW-tIN2hBio9nhBDq;N>958(qDGUC~Li*0mcf~4k1sc zt*fFVCyQv_0ikcsHi&VYw95q5keNJ=t&9g!J`}I`FT)Npayt|jt5-1s%tgx@Bk$QW zoRJ9?r$s#0Tk{x>JB@XQ97&Vy-X-7h=Iv}Tm*elO;@U%mdP}hfQA)TXTSvA`ltQ>B zItgYw+=j9?Y??Q^n&;=6rHm9J&G5Jrm*&!5d#xCoX9h0@_?@4MjZHMyWUulHcg?m6 zO7N`+Xn(D)xTe0RTk|!x_iV6U_y7)qtaJjc3tTRRE>IaWA*1SPQh^rQheXdn5M)&b z=&Nl}gq^Sbxh$PKdgV3WctRi)Rqo^)vVtZYD%z|j%4v31ZBJzH+-PNX4D@%c zzSoX*K>Tx}?~PBkxwFPz2chUenhXzv@>UL46d<^?+25DvOHC^YBBmkhWaEmG{Ysqu z3+vB7dgmU;nopLPt7B)fu5LF~D%?SvM{}Z&{$&DJ;J8tePwdv^lL3aOEX183^sq>48w%JjIS#CcGrzUIsLC%^p z&vSb!Z{Z#CFY_7_f)OsYh7F0 zpB{WWW>|I|$6lv2?@pb48r;UKFUK3Y@!|^n(vp!kvmiujuQK2F9iy<3c_;(9-f@Y~ z;Ps?A4Ouoy*ZwD`G^jL4b{=!sijBQ3i-&O~jC{MQ+6ME=7ghU50jp6?WCO%z$9u@> ztXDtBxHJD?GzC=0$z!S0Jn!x-?=WwC0^4_E(YWgPHX4)8p3HLckB{{q*$kL@=Yc+GL4xCDbw+3^y)poF}A=MwU5PSJedn^Z4ac}Lq>dk?~9uT_kJqp&L8jW=>+Sr zpRcP0mhC`6P#YERoorEc856K&%gwXZW7S!k1w)={Ep}IsceZtG6RvD0XGvRFy;oVd z#wLkB{n#ED7>o6K^sK!#syqC7PnnLlp_g{Nq5hpGcAba5!GM*d9;LqSm*%6{4dy7b z@1WRNuDhxuqu0U^Vn>)Fpwv%ymMK%QE<0})Kh=-!KM%R~cIXxG<-Zw#-@O9{4-ZwJg%Xe)tYxCOccaz9C z=To~$q?8QY9^NMPIp-HX-|-0C9p1kmN}hh(4GeLh`J0I`KvpX_*bdgv2gi0#is=f}%B^ z)5+M+-!;!b)@$yg1f5YFb^YcWYL;6X^P2U%ZT>o+a1845p&69Pqr}rcIX(LaBY5g4Fb^+6S?bV4TC^r>EQ<0^^ zIh1cX_uFam1?FV)r%9Nj(*ybsts=di&>X-GC2Xxozy|LboI1}H2rOuB6utG#iApjoTn5kna9EWx>R`!ETN-Fy9Dq+b#;%3Q z_OpB=tz5FDQcAB6&!rw`?f!mW)Jm)2D;&Nz;pNHNZbbbA9#LN$L_qha>VdQl0Hdi( zah6)AnC1wRjTbigN_Ei5^6qG}y47C;P9orQ3T8BPA(_OfM1`=hZG@4&1erySZ>Bm2 z(_4HImsdMA7(3)I1BC5Za1z4D4@*7U(?Ip31~f~+AtdqZLRvCvEv`hA?q}k;O2moM z=ax%R8wEz#7~KAhx8nO}DrUhTNipbGVvW)oRi;^yebYIuV3sm{Mt3|cOt7;|gMBRR zv8d>9*!{<**#QKP^F<`BHU#mv-8KLMOJ3g-#oJX9L~qHcXl-IO0;sW*PXd6#`v78u zm_ey;Zt3>HBQK9zN~hVA+vGAuo`Qw;uOLa1O` s)tMuc?sA5k|X%k}Ad8@f2@q z$T2XZb!6Yh@A4)IBUTp0DaW*Z)CsB??S!J#oY7RagbUS?j7Hgl3Q1OkLJz&rs#vRw zDk(>p1e&7lP9>^|Vs@T^uX~@s^n`KQ=r7IA*&95`j?b{O+)tAacB3sJr2H(hgs{q% zdRMf)MZUEw=IzHkNwKHc&@Z4xUk<=Rlu@DL51*>TMvXAoegk|6M=9hB6=$Eon2T<+ z%leU3IrJDZ9m{E{3Bk0WvxKeUFh_AWEKN5|4>~@Jb=LV-rcxE){4J$8tUb0;F8cL| zygH3~p6Pxe>D8A(<)2B4Sjbbvyzde!2KCC(8Af_mG%%tM!J=6U{I?Y3f7;i>J)f6% zdkeElO@l$6&#amjNRzgajUP5qwu&VhS4Kn*|MKQAh_@3~93vK4MnhE(K4p`kfX95D zWAXzQ&AO4cR17ph^)>|+C#fRXNo0hus2`g~q5&DO+zf%eoMsE$JD1wUyzL0eEfU0t zanVRMgx0vC#@c)%Pu^fT!xp%Gp)5;wD%Hah*JCZd)K2|sO(f&hsO0dDa7Bt;B}K2U zmDEayQ=wPi+kRXalCqK(8qC;mNs38)JXv0%VR=xPsg2iCWFH5Cnue+5p^>~gU3H{j z!7C-+HpT8GJN6A zB#!jbs@}ymAKEC@Upgd(!6(}C&fAV!G@|DWjhRqcp710f|ERDVG?-Hoq&2};Z9c3n zzl^swKYi(yG`8KlREyX#*wp!pt7ELvQ;b0q4E0jx9~G*Dka^Cq&Dm2m{)<5ki|dz& z)<0`#?0LjCw|uEUGx_!rXOT328sF%galr@YNDvW&dbQ^fD;ixe=ISAF+V3k=DPba~ zjb_APcd*ovh)B^@9^8I4XHVUK8p3{vDJXuG2cvEIfY`uk_SpQrmiXlf)kV}LbW~e#Nkek!+A|k`K zR37yNRuJ?sdl_HVwv-`^*Sf0kD#_t?`^VJ^%g508_987@Gb=VsYfE?(l|BUW{kZIW zzuA z`vlt-np?39Sxl8>c0xUo`YgRxRY(>6`|%viWj z!gQGv_`>#5MdqWWHcgTmDCp!Fe-%eF8;X{`NM|YH+f;=HB#~92H)%os=qGjYs?m$TV z%8eTqX<0nH+!J-SkT65&>TKE9-GE!B;8^F6-2?G{URFwWWz0lcyl>xd6*oWW?YF{07u*f8G_%{Q?*6gnQ2rY9kqy04hghzo!};o@MXEJtq;uDw zp7Jo2!Y11_brbP&t3(#66ldz@iR=)VHhG+fnjZKUyuY?H*~?gl8KBgyq^{R%H29KV zJCHiDEbFY^&9)whsMHZ%ebpsa&rGz-hcPKNgm`OG98ZsGuQnA#GvE>LVu%lqu}MOi zeA|SQ=?_3FB&|CS-n$doBc1K@^{Pv+Q9nBo^o&XUeJ$c=M%Zk3#-@96e>LwUXgO{w z`(6OM81uBJ5xq*7DG+$t_#^`z@L6}q^XzEK{jTG814n)`**RK;$mxN37*!(!nUIBjsk5C=M>tsqlTqy=5y4w(4D49Ldxh1q{XjER2N1-6jvmA#d2j|o} z%jUJg=1!~XPgnP_8gPiS|(Iirm&%_^piYm^h6i2P8t5d>|^Wc zw@StFul&92DP1f`uB^`f6z71+)Jv4p`wE{@V(pA#gn_kE)4YchEs0td`pIIN__aoV zs^o+B07axmFSHw4c_Go&nYEP&huluSsK~d)itVeb7f-mUj9sCsh^0kp{4zU4yS{pN zep1J%-?_b-b$CT~cAR%fA)~)Qp*lTx&Db#O3ksi1>Ab1;g2703a~G82AK4my{=-Y?jd%HVewPzH$7t0xm51Dk z|4gh(@9B-EG-A4Ok7mZ@k?Vu-;hj})v6EhhIO!P{r9G*Bi7un_iT;>bpYk7k5^}ozZ0GTbWY5ZrN_h#L%$_8%p=#P^xIrD2XU$6 zwjFPLgvV3cT{mOC<%(N|^4QaZncsS*HyjrZsmEMSQra~k!e6QNOyiG{K7A(#IC$m| zJ+~CH@0O~*Ma$hBLwEChIhq;c!fX5q$+?8m8|s)_M4hc^M?)H767lfpqveoVQ&bMJ zQ=v?p9{Tdw_DGxX_V%jsc2I=U^5}bW2b)eHW?$EQc?v!4X}lt^8VdzR6)-ue9vz+STL7 zy@*^i8SjlXQA=ye)z#Myt<}sYZwU1I$NwW zpuy_*UAlVQ*jw&CZR;OkWYCRYCu{yG$?zH6xo$>=i0_YJs$)2KOUZ*e@@e*i`3mRM zFm=pHJyYBQIiqof?z*0Q!OK27dxfzkBQi^yCn`v~c>9@*c3L+A`pNx#pD}NG80CDs zju^7@je6z^h458c*r<8647b&MZdZ~FgBW#)S*TFV=Id|)R3FVNCc)UZqmE~%SvA$> ziRL}K1X-nC+;(bnjl@eyS@d*+5859#l6&qtx z55}7zXTNz`b9*)WZre_>1+u@9n4fYRoSwKn(VP%{s{HVQy+ZzRUP1uq{z*?LLUfrp zMBGCn!gKFRfO-JWd{S4rX35K!Q7mUqJ49N7Dzwvam7?2 zk`qktiS)Q;LT>=#e6~`j*cqbQ+{7Pl_mdB+mQto<(#N+1s_$ApsMV*`e zG0*2mNrMdbdW`@-UDCCGa0?N}0;|vul>)QrPEJ;e+!a2<)TRf2{Bea1KZOr1v2KimyB0v z6{yuulnwmKLal+``i)yJ@t}mSUWT_E3r=8IeuNw-7&*I)bcdUBhI%If-y`OhQ(qtM zpX@))`5vO$hF(ED5sqMKaR>vo&Muz;E2`h^B*(dpetvfL<4MG^vVXgkT36w)GV8sJyN@kwSK6>Sq|b zhb-58Kc-#&so`DCF(Q7mvUDr+V5Pt{Gv;xqSKx?J{Fa z6+`kPGxjX2*%H z8%aK?(B5h2*1z^V>WQbirg0^5Eeac-JQh_2c2f8GXBh2#b6Jy7Wmt*s?>6hiQg|KB z0payzcWrZADUP8N#-J&bgA1&90k0m%I(>HL^uC?LZ|sBPYQ|#5GIR(;fueqQp5Znl z+Rp%HfR_ZCIQ)u@>(KpOL*TNC$N6oLE~u4!ry?LZ#o& zGnSOQP$l&4NNWC|b>beTMj@eWac|IQcs}wniJ_4$taz9vz2ul7RA=rE)h}W28OZDL zSsmtLS*ppJZt&hLHZxD|Z@Vo}aEDEX)V(I}^nIfBAw}hE<(llzFy>Wl=jolu>O~@0^m8^p2hc-9YGHj?_{qLMa z`j2Y3%LCox!&^UoI@261lF)PLr(hAL>^i%>8@NwyfGyS%F3?G7-i8jUAJWGisrY@p zu)QnDvKUwV!Eit_@^0J8*LqaUTu)~iCyqUNoeyJhoG>YfS0Tvx3~IW(_Aujoh3hJZ zX%Lal#dHz0S4Y%iPHbts>YxM`v_T@s_jw1TI2V zPB_GU!JctDFqE^&MQ0_xXWxE_h|V&1J~KLnJ}|H)`Bmqeo_$SVXMvUH4nkwQD zom>?wQV`;zG3f2#nE$YjVO4Q0mOj~W<1b9EIgPm~h*^i^(T17WS)#r_lgF95P9FUT zO)AcJ#N*sYLS`^`&8%K#;M+Yb71gWsK4Wcd+)*(Orjnr#XQoZ)_=u`khQ)h5)#>b{ zMqt#prjAy|x;;G_=6bO2OeHp~tDd#N9^Mk%0cDY#)=Krjk+?UJ-ZjZK``*Z%95-PuH%r_Vw1Z6M2uaNcLEez95XIrVMefg}iMHS#u%NXSmaCUP?{+$&+% z^RTyaEoX~bZzqIof-k@!A|KII4z>jaf*}cIkC{~q2j4$P&u?48Ct@P}I8KmxZ+I_< zZxu9jc}JM2t}?rNbaeUTJU?xv4N)6o$gfDuA}xOj$TRzEg%Q&Z@)XqV2*%;EGOcQF z+m&!O%i*ak|F5dv`Yj6Y2^(Go1qDHnuBE%XK~PdbkOmiNq(iy{q?e_;ln{w!sihJ9 z5X+Jxu;kJqu*5E~G(LRaAKv$U{(^I^nS17%xz9awsIA-$NaXvf#}!gQRL7g13=7vh zTT7RJAD$WsjfCYBQG#^&!2^YC^ZMX1rvJn*^d0--SF|_H{%G6PHmZ2G8SDC|%AKBU z*9z}fZk7(e_l2)F8EQG01SWlVgt^F~Kl}FS+o!cofr*v7Ex)EhnTO~fPa1U_f^>9A z|JtQ7Mv;@&KHt3ss-db}a@`;rp|$5=pAh5h(NCr7Ose?O8v;0Oj5QkImsD_cti#y( z>|MH2{HsiK8KT+hk-$yq%gk4tE94(6|B4JAV0OWIeHAngH3L2kb|7tjq4csv>LvT+ z5u5gX!7o_s?brOW_0XFqPJzY1e*g~$T&9Lv`m7-_q>nQelj>73lU{iCAY9?~KR_Qu zcch)-ysozk`zs~xsuMp|rPtTd)s`!|Wnf&7v(6lGmke8ppd-WX#Z_)9#W6Q|y3&V5 zbHtbP{?RTxV>lY1PpVbKhkWMqQRp-+p14Jw%(ewX7uGW^S3#Jd%wVa_|h5vbS-hBO~a-uKU$oV(Hkjo%QN zLl?Y;UncxqsX;m}X6RJoG7X18wLbC*JeiQeb?d6xPfEcE4|xysru?z@BVzP-eh%E$ z;&NWnDr;=$@ob}4_x}`laI(lZH_ zdNIe?y%p%FRrYvj zdn|m${aR%6!`rm(>l&#Kw&q)EMksuiKB1Q^kgO~D(;vl{&pj<@#EX2q!jndWIQGdT zcsYA*lE2t9LX*qRID*rYKQGTe|G6_dQ%#7bF|K$fc1hxk0A+~V>QbbBIfO8$f@8}P z#)zzT@(y9HwNCJ#Z3&r8617aGu^M2VR>uc9k}R!h7(4Q`mZZQemjaLsc}tQ-AuR2R zW7Twg9RlODE@V5lZ|65HcRiM$|J7%@hV=ia{9Gv%W+*@*_1C*R|Hz_P_|Wfj z3jJG&6zDImB$issR$Klo{=Q?&R*72gmsBqAHqvHejExD?^#Q@fb&J!-&HfC5SLwgo zeSzY$u?lY z$)2{tRw9V}uOPB-Gy|Ov!}dc0bth|4dygl(m|GN#)#P|Bn|)8RRFvhJ$g0oBe|5B| z46+zUm4`eWPeJPawi7+K=URtgc6OL+D{Wwg;z7&zSh1nK&mFV)FL+HW;$*swn`xdZy;B*gK?ivEyl?U%%BwslMZR7XC-V%bp z7Fh|<%kqMQnRksEYrFy#1IPk5D9M3^lqzBz(K9N)Pi{XsD9RVVxwXUXx-)mZOUeM+ zI<_i%PeZ&99aBd#^DY0;H5em-|VyAT8p_xOU1okaSlV=k4%P->2_)*xs1Krv-{Cbr7;>RWf=IJx-WbUVse zlPo0d>`;w)wsDjb+e=SBvm+v+I~v?+?%e{FDG>(*%L4lEllfGK%?`PlhCq0m(o`VCQqmKu+jNgN67>SWV3 z=*a0$+1=mQ#Ali)_HCN&1f)qp7K>`S*@zA=F1MszYz|+n=Sin2?Zu}Nwa*G<(0T^2Ec0C{7df93D{5AN znN#TY1NPs~yc@_*H-{GX_fyA3V{Ve!IU0#L+8(|qK2UNEF@ZyEPj3ns&j*HfPmawx zyxumEk@Z?ag(a0ff^YIlnSV@dY7X8fqF3o{$$N>95D2oS=cE!noiqFaq;X2#9^G`9>$fmOCL|g1zJ6y$j|cO_isbmFCPGT5x|i8ciu>5L;TB zL;k}C(x)My6@qpH);Eusu-J{emhj`N@x+$ohXXlGlz)aCYDQe_IYOlOv~2}grVOqO zyTe^sRN@o&ugd*|EZ&w`=y!^GxAS}&X zHUfJ7p*rx`H19uDf-MkhURXf(>LYIzIoo<3V+-eHE>?-KM;Hmmm=s8CyvFTDMkwUf zf6;dde?*)})Jmv((*M*5DDH8~7HX5!kY`D~7B}`oygVYQ-j4tKqa1{W8PC#34#SWz zz`|lX-pi9q&fB0!i$5e-Xeqb&Q|4fHnsTOLdG8L*ns~O&#ve27+Pkz z=BSW|zW`R22bP=LrO{|@Bxg-$A^3k~raou+lA=Cywh)j%J0LYbWml!foj>xeuR2}Q z72$MbDbcQ#%H8I9&iuK^V`p( zGFh_da5Rfo=g4yQ#O)cn_fe@*qb7~sV)O1SZ@6+FL`7e}dTo2ym2rqG!BlM?+ql}; z)Nh=q&!x2`L=8{K${GysZCQ3fZSIQLhrT#{-%uwH{ZKh_M+_2JYsp z(IU?~=^S7nvry`Ffy(oaTk$`s7olLA#IWsfX`!If1hwe>1WYRvK=v=6Z^14^f5kn$Hu;ime01zF{al`lOKwbINEQY2(uen;vLX@ zaIF+;3d`+J*a(ye{4m8oS1HmtzX&*L6C3<;q=$sNb)ZDv&ox?u-ArJpYt^P#mv7@V6%x)aD1Uu!^$>OQW2*V=xW};3|$yna>Ev@5R~u2 zIn90KTyu{0Ec)lrx$!mA5Cfqk@ziP}n4qdaS^)?P+HW4$q>qGmZsM8d+TRi+X?>|l zMm%Scd}%?<0Zxy9p8xhhNBdm#?a&#^QiRO#r7F2$7|~UazU3{EMc<}(`d^y0Di_y@ z1de)rK8frAA@X*+_oK|?4kEFN*KK=!9eQ~OHeJJQhz!Ki;%>DT z@AUqTuE_Mw4>2)`wUlJae}=G){wi|U-zqDE-nuaicq){x$`qPr?W}OgDg{Wn zz?r;ZEu+dHkv<>wU^+GaYe=S;mG4*_xMJzB!DAD2XF8pUp87?>bjZ z!r#Jog;i&$^=@{TDAO95h#`xsqw*R3OSGfpc6poAW-#t>vX$V^Ak~+|6_B^7@1AX} zahUv7fOr3rE=IW$Qr=i!Ixf!XGe$-95!}R{eV$q|8;RSv^_@{Es0neLwB@TEXa6$| zZ?I)R`CQ1goALE>Zg9Hv9Shc~^?m9D>RX+qE2eGhGhtX9aTK3DE&`?9RLhd8KO^<; zC7HHf%YQ`d9OUcml%$)sWOGn#7V!vWBVWO>ZPk`E{QU(Ia;vtnbeN`2vGWH^#n3GY zdWKvuh@TrC!RH@XV+y5;fJ-*L8ceG`-W^e8*00|UH=6Pdr@(1wqH#1?3FGVT=2OS` zMZR&n>Ruc4_r>rBgB&1R-@`Ba!ri9rCT19;R)$f-DZNHbK|KP_v<}MJT4Mg6N1B#f z7YS9=BV*4P_g&#z%oEC{Y?2?vcK5D^@kzv57m?HC|Fzsz%7!?NWy68(&2gE{qQ;2@ zs0HSOwD;FTNPhuJq{M~cSRCI_}zj;z9!naFkgT<6Sa8*hAyEdn({JveB;P~_tV)T&>3aSH=C)VMp zlG$tYDx4z&JZKwb%M6BrpIEw|*Sc6r;u|j>H!aVscY=|2w!>4<9VxKUSJP=_^{xccPx#U!pkO>vP{>uj2hhu{Q4vhpD@Ss#K&s0d(rFnPR2gaW_E-RhLQ*0 zI5$dfri@g+POi895Y0L}lvhBR7gGL16m#HF9hS$f6w|G>_oQNnV*F-#Kn-fK*-2>$ z_ujl*nogpWpl2}*NnjFTdhqxQfM_tZ<0eeM*1c2m)aU%N-g=g!0*tgT?K5iHc>=CI zan)z$U0x%_bfx_7ilTEcQ?a|p&0pkYo-8vp)UU?%0rkpyU1vLTU%jB5 zEhc?O*GFBJz4*txexk2LEhIzdK<$&9-3<5|n0Ynv@pX%0_VngNLSm3p6YM<;@t@D% zzJD-;cV^pKJ|I&5(|ApH+G(%I(Y3B9w92F$?xs<8cXpkv6%?aI?IZnn2F_{x*HHTD zKz#hjK4Z^s@;mNv)zY_rUHF3IO_jAT7Fd84p3U7gY4U#&CtH7pw1w}UA>dMh=4F|( z=WHBM^3Y+U#9}9zWPA(%5o9()ASGXi{=v(e>8qCktf7&+{lkU`P=(gKtAB+bM7C7b zd?Rf!#`w)6N1jje(KZW;Ro_Tmgkd{Jm%A5R zbP_da^h4^=#B0)Qk-Y+oSm-)LE(CfnWiV^B8sk&ytNRZw3!9rGs0b0p18+C^P$EGWR0eWQ7A=vdu+ z8{awNjFaF${>gb8-t~Gd`QdV=Vsy>^lz-@fCW0v3%1XuczjyshP=7v5pTNMxTstS` zarOts^noql2z?IKbjTBv<_YBA9n57V9wBup!XZvSFFWJ?)i=KuNpQs$%0RQxO z(kE3xnz91zhxAQ|S6xLwBDOS{12v0=qEDaURzl;9rJ(AeIjV!}wYnbt<*u$QIy^7I zZ%QzDcKr{zA)k>9IyZbdUBoF8KX({^x|j z*D6gjFgcghXJ?+NQbpECv1#=_uZ>R3N~0T9D1%>>d2Wi{O1M)^(Br`T`b{5-m(&Zt zO_O=@`Ek0O27`lN8p|^Vhsb~BrSug?htg|tl$=3Y(7k#nwgESVrrJDAVWYN>fA|Gw zR$1|+u3-4ppv4>5Jmwxp+J#T2XNK50{b{#+5jYD|9Go1|Fj<#KI!>1_M=Ug7_L-?+ z|IMQnzqI3=)KHt~pFd{a7-CbvZZ>}rECiO~f(^5s<~H~-U3S%c+o4yyCV((t^&F{n z%4w+IrJ>-|0jk#^(YaiIE^tH3I4f>2dfepuQfQc=y!G1hoLU5+Bs#DplS z{HZ3Q8U$RFgnIk~B!@=(^xYDn8v{FNxCfR_^9M>&;gT{G@=#5n zA%kR9U%gQ!NGg%H6zr9TU;tECD**^1QOg*A$(}Hm@ITsrD+Uk{UJ`%~O`Dy43#(Q`O09#rU5t`aEd+ z8uzB_FSDWF*C(F|)ktUCGkltG9$oRV5Q+vSu3hY1RgQr1+ME&FR>k=?LW zfz^ZNGA6MvH`R479b#U&jSLfL&=r&O%g?goW)eduGW&J{j-?w4P@2|ICD;MnUz%?? zx6leKm3R~&*yxY@r7p&kF)eUZBPg?{?n3QFKv*o5rVh{|e0aKYvQ&5$s(Cl@t84ZJ zPYG-)CnWnt%lu>3I?j`&FjRKOn2KGa`g`Kt+I3X`OX0ogg-HQPN!Qmd4yq)XQPOEq zQL3CVCmW}*{xxf=i!#~-w8$#d{;bx&$AZKf?Mfh;GR3px#1) zPV#@0q`*m^N3&2`MGv4S5PgbOKJqmh$@UdGU_MZmEIWldshEhH_dw$P>hDYs003oX z^Fas2h?)&gblSIe|1kz|lP<^YB$DI5k&$rw;$TG3Fk(sc*LI1~_YXk|15!sP_9-gR z1O{_K!`k0hc3cq7(MQG)?VsFJu0jd8|F5-r*xEJ;OE1g#IRVpA$UsoqWR>@fQtZ~v{@GsPhEVu(Tg z3%;USYl%|A2beH`F=58!Hk@?-b=>1tce@!L?VCY{emMsE3CfXv- zdmsi^y8LQy{<*r^iS(%0&gWBJ7@<=;gIOPC)KAk61lU- z7Z1~TR~Z(qUDHYUzrzo*eN{)+hF#Qs&Bw?kQ3-BN67 zgHMxyaWh6z5*d|L8??u*8LUAxZlNbD)A|@ zY&SFSy2*j%uvvhIvLZ007uxe{DfE~&%k$8p1fC1I*T`>+7v!0Lm5Yw1C53YZ7I%H( z#MU&K76m=&tu*q+*$ZugVgg+Yi}0FoyZPj|=%O{Pw4(UU_?H`(Sk&Swi;2}zIr8zn z#Z7;!5Bnf3reay9RJn`OymrXdi-)aohVX0mHc)qd3+;#cai$4bCKfstP1Y!4&fr^h zRKvzQ5x#iDb{BOf(zC`?&Z56M=Y3^Vw}k~dt0JaXd`wGe<-R~}D*T{RH_7#Jlp)7$+QS^;eLPk-JpiRwUq6G6ej#QHSnp=A^tIQEDSOJr3MS~eB z?%@si_2PIicW0OQX_B!tm3x4EBI;4^2seaAX{K@nC+4$c*C!Fw3W}m&plHZVB0IDV$Y75nQ zWA)1bO6@T$KC+qhp0F7ANQoR7+n#pyVrx7O1SfUZ0`(V|Fn*{*GKsM&7E=}L-~D96 z8hrY_T{EFTrlsk>l>j#P9L^BSC{2!HazaRW7zlw#ZA}b0-KrD+NKZK(;V-T4Pn5;= z(vK&Li!pi+VG$SIMYJWt|9h!rJV1<-$lkPnuT8%>LEQ48Azk$gTzA4FEe1_d9> z&*3S+JAljGY`b?8sg#S%5`)4O$>Vx!HGW9Yzy541AY@ZGW=4ZE=`=4ux4!4J)g})h z{LJ#n4~+IF7Y-6IKMFrp3I%x#bT%r{NeN;wY*mg9*^DG-?!60)G$$~L`}!^a0D8Xf zW+WDbO=)wH!`bPO{$R$^?qR`E7mM3zi>2jx;GW;9;j$riaq+I6(3{ZU&g2tQv#eLR zH)y!XvZP?4}0i3u}+5iAnHfceP=)Wo_ZZh&9co(UoV3Rw!#TcPC+Z*u4wif&8}brKa^{F-;FyIvJ7E0 z2L-4iS`|l6UeXC7R>&c6@V7Fjp<=aTjbUyl$+V%YbB@O zdX{T2O3P_Wj$52%$4mwz%J-9KbG+(0eFr4e{kb8?U8fGuHSo^y$$9zsJU% zRO%~&%7@lVhtyofCj^HYbf~YUwLDY_!``{j&w8`4P6xQ(KSzG(tW!`aDW2B%;$Nub z6kq@EwF$dIlXMjj4NBV=(mbe?O-`X&H%M? zlfvWSYG6lYmlHmkLx-;Jit5AuB-lp16^iPiQL8PcP2_t=Aeg4hGVLrWp|J{*<2V-= zB@FlIR^iFHGK0wrBgY9-u7nXFQD?DROXm{(pUn~e8}-Actp~fS7S#TNPa{vxg0VBV zuw#AZ;eP-Zo4}jp$(hw4oN>VS-`g$yV0TkzbNy=}*9<_pWh}w^^3V0vy_w!B*3SFp zmGY9N0$N}Hm|6J`g2BY^hqPEPDNv>Z&3rp_80Pg4d*^WI61~G$Ct;r&L&XlgsWswH z#uhH$2*~;W_yq34U1V1i5XLZ4vug|X7g;t&Z&C%lZ;07}s>UZwcS63SJ*?XKeCJfW z#C-mslx4F3&n->Ouflf%!xTrD4R7q}-amlpMI~hG9{?`e6@Ip}hul|I8Fr=oaIuwf zD40!OjlV&^mw5I4S0W(1ZW$zxCGw?Ne~CKZ$oL0vn7J~(!ZZ{1{+IxigPC`HOP$u5)+ojralc}x z&NOl(xTa5Aq(&<;rk)GOetu{tf=4AHIc?~MfkLn9FTq`q zeq)N~PMrKvT6;m)S{E$f{2!)%{fOjAza(r{Q{CTln9r2hA~?9Brm<Mn#N{AYh|MKaP9`6n}iY{c+6 zdskY&O+*r|G!46(&B4r=7+5`FT=Ps}WQUZO11!<*HC ziGtrL9S`SVjDZfPu5zRr`i$AFut}s_jk{fX_jLt(I(d^ZCYw&Tx>EvJ)7so?_pIDb zv9j`JGb37cxdr_VMWB@ZpJ}J$M-OZ08S}w0CtC;?7lZMA4eOfXoxA6!1+!l3PF6dE$v%2pQyXfdoVI=%#_m$*(T^K4?7j*hoyr@wv;)S4> zjSAFZy3cN=Hk!l)5nI|y_Gi|l=Z892X-(VuR+%RsJxs(q4jkuf4lG&|-Gdu76{;Z) z)kj*Ey8+>WyB%A1RP0{BPoP_aW^j)ok>cf**D!3(o+%WZ{0D_S17BNzcVLch${cm4 z7u=0c6uo^C*kCqgN~-?Qm-S@j>vnOY!gSB$_W_>l4y=;37XW`R%^g)wNRLR zw~OD!Il_W&x+hRIbh}3z{%Ot0Zw=f*zotDwS?H_>O?^gXo&#q9(rr!T+v6+h}-|jTTEfHD4xB;Km z1WCbo9Av&zce)^;o4(YPQJ={*f#WB6KuIILLLzKGYyV^$Cgjn0o}!DC$;8RJMcdge z?ax>cQ6xswJk`3yY*~A$FKLq?l)8Wkr$@@g5!Pw@5&WmOYIg@q!8SpS9sGe2uYZzO zU|yn-Emtlp`W-%gKF^g(4yF{ZVq2Y*)*(Ts>(SZABG9?(NMbOe?>Oak|IfQEy z&C2cCs=xDW$loo!0DK&eSJ{#p&$&~Jn%*!6p82${pj}!_2XYz%;O7EC?0!+^;%)eW zF~I~WIp$A^8vN8bv4C{VO6v{bg4@#n%PC%pB;O)0;?n+aXT*6%-Kq6HZI`f_onv=Z zj#>v#Hv1c-*Y{VvduO?+zs6TGVfPR`J=c`jGv<*9 z?3nuY_)YI+@B&;;QRd5=6xUz&_0V9YW!6#hC2D) zz09;|AVy%Lr;pJqtnA=&0!4Whfv!?e=nKW;0tQgdonsbXr|LdVvK00A3vtcP^y(-yu6I$uEK_b~ZvH3hp|sB)_%S zDB!c5W=^b!u2d==G9bwpdf1J9YkNgn1yh>{vGurB^$6TQIZvT-5;`wA$M zm<6uoGsJQCY$UM%IQeyO#XP$;#SQcCQ6aAFXNMi&i%Vf0=QRTh${MV+hk_t<1X<&~ zo#CJ7mQ{;ka(wf0+n9y}AE#kG?&qU|>EX z+c$rIptEw7Y+qWyyjM>Sk1^^MZgsG)k4$+spC3Rp{n#7j^k)7afNb8xlAxq+y7A*C zPAFpux=RN58dRo;dLOl%A!Oz)Rg5aSS}+2hLMjGXBJ(;` zS^mEaQad1kHEv%%;=W(ksdoq#4UU~luSx2bY%-^&~=v7s$ps#r2pASIgDD^R7}Z+R)?V!N}%`nn|I-`xKJ DRnA(w diff --git a/resources/styles/popup.css b/resources/styles/popup.css index e0a74b3..029ebc7 100644 --- a/resources/styles/popup.css +++ b/resources/styles/popup.css @@ -1,108 +1,73 @@ :root { - /* Dark theme palette */ - --dark-bg: #0f0f1a; - --dark-panel-bg: #1a1a2a; - --dark-settings-bg: #1a1a2a; - --dark-accent-start: #00ffd8; - --dark-accent-mid: #9d00ff; - --dark-accent-end: #ff7700; - --dark-highpass-filter: #22c55e; - --dark-lowpass-filter: #f97316; - --dark-text: #e0e0e0; - --dark-text-header: #e0e0e0; - --dark-slider-track-bg: rgba(255, 255, 255, 0.1); - --dark-slider-thumb: #3a3a5e; - --dark-axis: rgba(224, 224, 224, 0.699); - --dark-border: rgba(255, 255, 255, 0.1); - --dark-surface-alt: #2a2a3a; - --dark-text-placeholder: rgba(224, 224, 224, 0.5); - --dark-text-volume-scale: rgba(224, 224, 224, 0.6); - --dark-hover-bg: rgba(255, 255, 255, 0.2); - --dark-active-bg: rgba(255, 255, 255, 0.3); - --dark-menu-hover: rgba(255, 255, 255, 0.05); - --dark-overlay: rgba(15, 15, 26, 0.8); - --dark-shadow-soft: rgba(0, 0, 0, 0.5); - --dark-shadow-strong: rgba(0, 0, 0, 0.7); - --dark-donation: #f79518; - --dark-slider-glow: rgba(0, 255, 216, 0.5); - --dark-reset-btn: #ff4d4db6; - --dark-reset-btn-hover: #ff4d4de5; - --dark-reset-btn-active: #ff4d4d; - --dark-btn-bg: rgba(255, 255, 255, 0.1); - --dark-svg-btn-filter: brightness(0) saturate(100%) invert(11%) sepia(6%) saturate(2953%) hue-rotate(201deg) brightness(193%) contrast(86%); - --dark-info-btn-display: none; - --dark-notice-link: #f79518; - --dark-notice-link-hover: #ffb24a; - - /* Light theme palette */ - --light-bg: #f5f5fa; - --light-panel-bg: #ffffff; - --light-settings-bg: #ffffff; - --light-accent-start: #00ffd8; - --light-accent-mid: #9d00ff; - --light-accent-end: #ff7700; - --light-highpass-filter: #16a34a; - --light-lowpass-filter: #ea580c; - --light-text: #1a1a2a; - --light-text-header: #1a1a2a; - --light-slider-track-bg: rgba(0, 0, 0, 0.08); - --light-slider-thumb: #d0d0e6; - --light-axis: rgba(26, 26, 42, 0.7); - --light-border: rgba(0, 0, 0, 0.08); - --light-surface-alt: #f0f0fb; - --light-text-placeholder: rgba(26, 26, 42, 0.4); - --light-text-volume-scale: rgba(26, 26, 42, 0.6); - --light-hover-bg: rgba(0, 0, 0, 0.04); - --light-active-bg: rgba(0, 0, 0, 0.08); - --light-menu-hover: rgba(0, 0, 0, 0.03); - --light-overlay: rgba(15, 15, 26, 0.4); - --light-shadow-soft: rgba(15, 15, 26, 0.08); - --light-shadow-strong: rgba(15, 15, 26, 0.16); - --light-donation: #e27c10; - --light-slider-glow: rgba(0, 255, 216, 0.35); - --light-reset-btn: #ff4d4dcc; - --light-reset-btn-hover: #ff4d4de6; - --light-reset-btn-active: #ff4d4d; - --light-btn-bg: rgba(0, 0, 0, 0.04); - --light-svg-btn-filter: brightness(0) saturate(100%) invert(11%) sepia(6%) saturate(2953%) hue-rotate(201deg) brightness(193%) contrast(86%); - --light-info-btn-display: none; - --light-notice-link: #7b1ed2; - --light-notice-link-hover: #561495; - - /* Active theme bindings */ - --bg: var(--dark-bg); - --panel-bg: var(--dark-panel-bg); - --settings-bg: var(--dark-settings-bg); - --accent-start: var(--dark-accent-start); - --accent-mid: var(--dark-accent-mid); - --accent-end: var(--dark-accent-end); - --highpass-filter: var(--dark-highpass-filter); - --lowpass-filter: var(--dark-lowpass-filter); - --text: var(--dark-text); - --text-header: var(--dark-text-header); - --slider-track-bg: var(--dark-slider-track-bg); - --slider-thumb: var(--dark-slider-thumb); - --axis: var(--dark-axis); - --border: var(--dark-border); - --surface-alt: var(--dark-surface-alt); - --text-placeholder: var(--dark-text-placeholder); - --text-volume-scale: var(--dark-text-volume-scale); - --hover-bg: var(--dark-hover-bg); - --active-bg: var(--dark-active-bg); - --menu-hover: var(--dark-menu-hover); - --overlay: var(--dark-overlay); - --shadow-soft: var(--dark-shadow-soft); - --shadow-strong: var(--dark-shadow-strong); - --donation: var(--dark-donation); - --slider-glow: var(--dark-slider-glow); - --reset-btn: var(--dark-reset-btn); - --reset-btn-hover: var(--dark-reset-btn-hover); - --reset-btn-active: var(--dark-reset-btn-active); - --btn-bg: var(--dark-btn-bg); - --svg-btn-filter: var(--dark-svg-btn-filter); - --info-btn-display: var(--dark-info-btn-display); - --notice-link: var(--dark-notice-link); - --notice-link-hover: var(--dark-notice-link-hover); + --bg: #0f0f1a; + --panel-bg: #1a1a2a; + --settings-bg: #1a1a2a; + --accent-start: #00ffd8; + --accent-mid: #9d00ff; + --accent-end: #ff7700; + --highpass-filter: #22c55e; + --lowpass-filter: #f97316; + --text: #e0e0e0; + --text-header: #e0e0e0; + --slider-track-bg: rgba(255, 255, 255, 0.1); + --slider-thumb: #3a3a5e; + --axis: rgba(224, 224, 224, 0.699); + --border: rgba(255, 255, 255, 0.1); + --surface-alt: #2a2a3a; + --text-placeholder: rgba(224, 224, 224, 0.5); + --text-volume-scale: rgba(224, 224, 224, 0.6); + --hover-bg: rgba(255, 255, 255, 0.2); + --active-bg: rgba(255, 255, 255, 0.3); + --menu-hover: rgba(255, 255, 255, 0.05); + --overlay: rgba(15, 15, 26, 0.8); + --shadow-soft: rgba(0, 0, 0, 0.5); + --shadow-strong: rgba(0, 0, 0, 0.7); + --donation: #f79518; + --slider-glow: rgba(0, 255, 216, 0.5); + --reset-btn: #ff4d4db6; + --reset-btn-hover: #ff4d4de5; + --reset-btn-active: #ff4d4d; + --btn-bg: rgba(255, 255, 255, 0.1); + --svg-btn-filter: brightness(0) saturate(100%) invert(11%) sepia(6%) saturate(2953%) hue-rotate(201deg) brightness(193%) contrast(86%); + --info-btn-display: none; + --notice-link: #f79518; + --notice-link-hover: #ffb24a; +} + +:root[data-theme="light"] { + --bg: #f5f5fa; + --panel-bg: #ffffff; + --settings-bg: #ffffff; + --accent-start: #00ffd8; + --accent-mid: #9d00ff; + --accent-end: #ff7700; + --highpass-filter: #16a34a; + --lowpass-filter: #ea580c; + --text: #1a1a2a; + --text-header: #1a1a2a; + --slider-track-bg: rgba(0, 0, 0, 0.08); + --slider-thumb: #d0d0e6; + --axis: rgba(26, 26, 42, 0.7); + --border: rgba(0, 0, 0, 0.08); + --surface-alt: #f0f0fb; + --text-placeholder: rgba(26, 26, 42, 0.4); + --text-volume-scale: rgba(26, 26, 42, 0.6); + --hover-bg: rgba(0, 0, 0, 0.04); + --active-bg: rgba(0, 0, 0, 0.08); + --menu-hover: rgba(0, 0, 0, 0.03); + --overlay: rgba(15, 15, 26, 0.4); + --shadow-soft: rgba(15, 15, 26, 0.08); + --shadow-strong: rgba(15, 15, 26, 0.16); + --donation: #e27c10; + --slider-glow: rgba(0, 255, 216, 0.35); + --reset-btn: #ff4d4dcc; + --reset-btn-hover: #ff4d4de6; + --reset-btn-active: #ff4d4d; + --btn-bg: rgba(0, 0, 0, 0.04); + --svg-btn-filter: brightness(0) saturate(100%) invert(11%) sepia(6%) saturate(2953%) hue-rotate(201deg) brightness(193%) contrast(86%); + --info-btn-display: none; + --notice-link: #7b1ed2; + --notice-link-hover: #561495; } body { diff --git a/src/app/content-isolated/contentIsolated.ts b/src/app/content-isolated/contentIsolated.ts index 839cc70..93aeb68 100644 --- a/src/app/content-isolated/contentIsolated.ts +++ b/src/app/content-isolated/contentIsolated.ts @@ -11,49 +11,17 @@ import { normalizeFilterSettings, } from "../../domains/equalizer/defaultFilters"; -import type { RuntimeMessageMethod } from "../../infrastructure/chrome/runtimeMessages"; -import type { STORAGE_KEYS as INFRASTRUCTURE_STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; +import { + RUNTIME_MESSAGES, + type RuntimeMessage, +} from "../../infrastructure/chrome/runtimeMessages"; +import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; -type InfrastructureStorageKeys = typeof INFRASTRUCTURE_STORAGE_KEYS; -type RuntimeMessagePayload = { - method: RuntimeMessageMethod; - payload?: unknown; -}; type SendRuntimeMessageWithCallback = ( - message: RuntimeMessagePayload, + message: RuntimeMessage, callback: (response: unknown) => void, ) => void; -const CONTENT_RUNTIME_MESSAGES = { - GET_TAB_ID: "getTabId", - CONNECTED: "connected", - DISCONNECTED: "disconnected", - SPECTRUM_FRAME: "spectrum-frame", - PAGE_STARTED: "pageStarted", - CLEAR_STORAGE: "clearStorage", -} as const satisfies Record; - -const STORAGE_KEYS = { - ENABLE_SPECTRUM: "enableSpectrum", - SHORTCUTS: "shortcuts", - tabFilters: (tabId: number | string) => `filters.${tabId}`, - tabEnabled: (tabId: number | string) => `enabled.${tabId}`, - tabMute: (tabId: number | string) => `mute.${tabId}`, - tabVolume: (tabId: number | string) => `volume.${tabId}`, - tabPan: (tabId: number | string) => `pan.${tabId}`, - tabCaptureError: (tabId: number | string) => `captureError.${tabId}`, -} as const satisfies Pick< - InfrastructureStorageKeys, - | "ENABLE_SPECTRUM" - | "SHORTCUTS" - | "tabFilters" - | "tabEnabled" - | "tabMute" - | "tabVolume" - | "tabPan" - | "tabCaptureError" ->; - const sendRuntimeMessageWithCallback = chrome.runtime.sendMessage as unknown as SendRuntimeMessageWithCallback; @@ -66,7 +34,7 @@ let shortcuts = resolveShortcuts(null); const getTabId = (callback: (tabId: number) => void): void => { sendRuntimeMessageWithCallback( - { method: CONTENT_RUNTIME_MESSAGES.GET_TAB_ID }, + { method: RUNTIME_MESSAGES.GET_TAB_ID }, (tabId) => { if (typeof tabId !== "number") return; @@ -109,13 +77,13 @@ const getCaptureErrorMessage = (event: Event): string => { port.addEventListener("connected", () => { clearCaptureError(); chrome.runtime.sendMessage({ - method: CONTENT_RUNTIME_MESSAGES.CONNECTED, + method: RUNTIME_MESSAGES.CONNECTED, }); }); port.addEventListener("disconnected", () => { chrome.runtime.sendMessage({ - method: CONTENT_RUNTIME_MESSAGES.DISCONNECTED, + method: RUNTIME_MESSAGES.DISCONNECTED, }); }); @@ -253,7 +221,7 @@ document.addEventListener( port.addEventListener("spectrum-frame", (event) => { chrome.runtime.sendMessage({ - method: CONTENT_RUNTIME_MESSAGES.SPECTRUM_FRAME, + method: RUNTIME_MESSAGES.SPECTRUM_FRAME, payload: (event as CustomEvent).detail, }); }); @@ -262,14 +230,14 @@ const start = (): void => { if (window.top !== window) return; sendRuntimeMessageWithCallback( - { method: CONTENT_RUNTIME_MESSAGES.GET_TAB_ID }, + { method: RUNTIME_MESSAGES.GET_TAB_ID }, () => { - chrome.runtime.sendMessage({ method: CONTENT_RUNTIME_MESSAGES.PAGE_STARTED }); + chrome.runtime.sendMessage({ method: RUNTIME_MESSAGES.PAGE_STARTED }); }, ); setTimeout(() => { - chrome.runtime.sendMessage({ method: CONTENT_RUNTIME_MESSAGES.CLEAR_STORAGE }); + chrome.runtime.sendMessage({ method: RUNTIME_MESSAGES.CLEAR_STORAGE }); }, 1000); }; diff --git a/src/app/popup/createPopupApp.ts b/src/app/popup/createPopupApp.ts index 16f33b3..1edd3bd 100644 --- a/src/app/popup/createPopupApp.ts +++ b/src/app/popup/createPopupApp.ts @@ -6,11 +6,11 @@ import type { import { clampPointCount } from "../../domains/equalizer/equalizerMath"; import { type LocalizationService } from "../../domains/localization/localizationService"; import { - isPopupEditableShortcutTarget, - matchesPopupShortcut, - POPUP_SHORTCUT_ACTION_MUTE_NAME, - POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME, -} from "../../ui/popup/popupShortcuts"; + isEditableShortcutTarget, + matchesShortcut, + SHORTCUT_ACTION_MUTE_NAME, + SHORTCUT_ACTION_TOGGLE_EQ_NAME, +} from "../../domains/shortcuts/shortcuts"; import type { ThemeColors } from "../../domains/theme/themeColors"; import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; import type { PopupElements } from "../../infrastructure/dom/popupElements"; @@ -397,19 +397,19 @@ export const createPopupApp = ({ document.addEventListener("keydown", (event) => { void (async () => { - if (event.repeat || isPopupEditableShortcutTarget(event.target)) return; + if (event.repeat || isEditableShortcutTarget(event.target)) return; const shortcuts = settingsView.getShortcutSettings(); - if (matchesPopupShortcut(event, shortcuts[POPUP_SHORTCUT_ACTION_MUTE_NAME])) { + if (matchesShortcut(event, shortcuts[SHORTCUT_ACTION_MUTE_NAME])) { event.preventDefault(); event.stopPropagation(); await toggleCurrentTabStorageValue("mute", { enableTab: true }); return; } - if (matchesPopupShortcut( + if (matchesShortcut( event, - shortcuts[POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME], + shortcuts[SHORTCUT_ACTION_TOGGLE_EQ_NAME], )) { if (toolkitController.isToolkitWindow) return; diff --git a/src/app/window-mode/createToolkitWindowController.ts b/src/app/window-mode/createToolkitWindowController.ts index 4baf377..5d33740 100644 --- a/src/app/window-mode/createToolkitWindowController.ts +++ b/src/app/window-mode/createToolkitWindowController.ts @@ -2,6 +2,10 @@ import type { EqualizerPersistedFilter, EqualizerState, } from "../../domains/equalizer/equalizerState"; +import { + applyBiquadSettings, + createBiquadFilter, +} from "../../domains/audio/biquadChain"; import { dbToGain } from "../../domains/equalizer/equalizerMath"; import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; import { @@ -29,39 +33,13 @@ export interface ToolkitSpectrumMeta { frequencyBinCount: number; } -interface ToolkitBiquadInput { - freq: number; - gain: number; - q: number; - type?: BiquadFilterType; -} - -const toBiquadInput = (filter: EqualizerPersistedFilter): ToolkitBiquadInput => ({ +const toBiquadInput = (filter: EqualizerPersistedFilter) => ({ freq: Number(filter.freq ?? 0), gain: Number(filter.gain ?? 0), q: Number(filter.q ?? 0.5), - type: filter.type as BiquadFilterType | undefined, + type: filter.type, }); -const applyBiquadSettings = ( - biquadFilter: BiquadFilterNode, - filter: ToolkitBiquadInput, -): void => { - biquadFilter.type = filter.type ?? "peaking"; - biquadFilter.gain.value = filter.gain; - biquadFilter.frequency.value = filter.freq; - biquadFilter.Q.value = filter.q; -}; - -const createBiquadFilter = ( - context: BaseAudioContext, - filter: ToolkitBiquadInput, -): BiquadFilterNode => { - const biquadFilter = context.createBiquadFilter(); - applyBiquadSettings(biquadFilter, filter); - return biquadFilter; -}; - export interface ToolkitWindowController { readonly isToolkitWindow: boolean; getCurrentTabId(): Promise; diff --git a/src/domains/theme/themes.ts b/src/domains/theme/themes.ts deleted file mode 100644 index 5b2b39c..0000000 --- a/src/domains/theme/themes.ts +++ /dev/null @@ -1,78 +0,0 @@ -export const THEME_NAMES = ["dark", "light"] as const; - -export type ThemeName = (typeof THEME_NAMES)[number]; - -export type ThemeTokens = Record; - -export const themes: Record = { - dark: { - bg: "var(--dark-bg)", - "panel-bg": "var(--dark-panel-bg)", - "settings-bg": "var(--dark-settings-bg)", - "accent-start": "var(--dark-accent-start)", - "accent-mid": "var(--dark-accent-mid)", - "accent-end": "var(--dark-accent-end)", - "highpass-filter": "var(--dark-highpass-filter)", - "lowpass-filter": "var(--dark-lowpass-filter)", - text: "var(--dark-text)", - "text-header": "var(--dark-text-header)", - "slider-track-bg": "var(--dark-slider-track-bg)", - "slider-thumb": "var(--dark-slider-thumb)", - axis: "var(--dark-axis)", - border: "var(--dark-border)", - "surface-alt": "var(--dark-surface-alt)", - "text-placeholder": "var(--dark-text-placeholder)", - "text-volume-scale": "var(--dark-text-volume-scale)", - "hover-bg": "var(--dark-hover-bg)", - "active-bg": "var(--dark-active-bg)", - "menu-hover": "var(--dark-menu-hover)", - overlay: "var(--dark-overlay)", - "shadow-soft": "var(--dark-shadow-soft)", - "shadow-strong": "var(--dark-shadow-strong)", - donation: "var(--dark-donation)", - "slider-glow": "var(--dark-slider-glow)", - "reset-btn": "var(--dark-reset-btn)", - "reset-btn-hover": "var(--dark-reset-btn-hover)", - "reset-btn-active": "var(--dark-reset-btn-active)", - "btn-bg": "var(--dark-btn-bg)", - "svg-btn-filter": "var(--dark-svg-btn-filter)", - "info-btn-display": "var(--dark-info-btn-display)", - "notice-link": "var(--dark-notice-link)", - "notice-link-hover": "var(--dark-notice-link-hover)", - }, - light: { - bg: "var(--light-bg)", - "panel-bg": "var(--light-panel-bg)", - "settings-bg": "var(--light-settings-bg)", - "accent-start": "var(--light-accent-start)", - "accent-mid": "var(--light-accent-mid)", - "accent-end": "var(--light-accent-end)", - "highpass-filter": "var(--light-highpass-filter)", - "lowpass-filter": "var(--light-lowpass-filter)", - text: "var(--light-text)", - "text-header": "var(--light-text-header)", - "slider-track-bg": "var(--light-slider-track-bg)", - "slider-thumb": "var(--light-slider-thumb)", - axis: "var(--light-axis)", - border: "var(--light-border)", - "surface-alt": "var(--light-surface-alt)", - "text-placeholder": "var(--light-text-placeholder)", - "text-volume-scale": "var(--light-text-volume-scale)", - "hover-bg": "var(--light-hover-bg)", - "active-bg": "var(--light-active-bg)", - "menu-hover": "var(--light-menu-hover)", - overlay: "var(--light-overlay)", - "shadow-soft": "var(--light-shadow-soft)", - "shadow-strong": "var(--light-shadow-strong)", - donation: "var(--light-donation)", - "slider-glow": "var(--light-slider-glow)", - "reset-btn": "var(--light-reset-btn)", - "reset-btn-hover": "var(--light-reset-btn-hover)", - "reset-btn-active": "var(--light-reset-btn-active)", - "btn-bg": "var(--light-btn-bg)", - "svg-btn-filter": "var(--light-svg-btn-filter)", - "info-btn-display": "var(--light-info-btn-display)", - "notice-link": "var(--light-notice-link)", - "notice-link-hover": "var(--light-notice-link-hover)", - } -}; diff --git a/src/infrastructure/storage/storageNormalizers.test.ts b/src/infrastructure/storage/storageNormalizers.test.ts deleted file mode 100644 index 2be1673..0000000 --- a/src/infrastructure/storage/storageNormalizers.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readBoolean, readNumber, readStringArray } from "./storageNormalizers"; - -describe("storageNormalizers", () => { - test("reads booleans only when the stored value is boolean", () => { - expect(readBoolean(true, false)).toBe(true); - expect(readBoolean("true", false)).toBe(false); - }); - - test("reads finite numbers only when the stored value is number", () => { - expect(readNumber(3, 0)).toBe(3); - expect(readNumber(Number.NaN, 0)).toBe(0); - expect(readNumber("3", 0)).toBe(0); - }); - - test("reads string arrays only when every item is a string", () => { - expect(readStringArray(["a", "b"])).toEqual(["a", "b"]); - expect(readStringArray(["a", 1])).toEqual([]); - }); -}); diff --git a/src/infrastructure/storage/storageNormalizers.ts b/src/infrastructure/storage/storageNormalizers.ts deleted file mode 100644 index ff1ee33..0000000 --- a/src/infrastructure/storage/storageNormalizers.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const readBoolean = (value: unknown, fallback: boolean): boolean => { - if (typeof value === "boolean") return value; - - return fallback; -}; - -export const readNumber = (value: unknown, fallback: number): number => { - if (typeof value === "number" && Number.isFinite(value)) return value; - - return fallback; -}; - -export const readStringArray = (value: unknown): string[] => { - if (!Array.isArray(value)) return []; - if (!value.every((item) => typeof item === "string")) return []; - - return value; -}; diff --git a/src/ui/popup/popupShortcuts.ts b/src/ui/popup/popupShortcuts.ts deleted file mode 100644 index 58185ca..0000000 --- a/src/ui/popup/popupShortcuts.ts +++ /dev/null @@ -1,187 +0,0 @@ -export const POPUP_SHORTCUT_ACTION_MUTE_NAME = "mute"; -export const POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME = "toggleEq"; - -export interface PopupShortcut { - alt: boolean; - ctrl: boolean; - shift: boolean; - meta: boolean; - key: string; -} - -export type PopupShortcutActionName = - | typeof POPUP_SHORTCUT_ACTION_MUTE_NAME - | typeof POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME; - -export type PopupShortcutMap = Record; -export type PopupShortcutValidationError = "invalid" | "duplicate"; - -export const POPUP_DEFAULT_SHORTCUTS = Object.freeze({ - [POPUP_SHORTCUT_ACTION_MUTE_NAME]: Object.freeze({ - alt: true, - ctrl: false, - shift: false, - meta: false, - key: "M", - }), - [POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME]: Object.freeze({ - alt: true, - ctrl: false, - shift: false, - meta: false, - key: "K", - }), -}) as PopupShortcutMap; - -interface PopupShortcutLike { - alt?: unknown; - ctrl?: unknown; - shift?: unknown; - meta?: unknown; - key?: unknown; -} - -export const normalizePopupShortcutKey = (key: unknown): string | null => { - if (typeof key !== "string") return null; - - const value = key.trim(); - if (/^[a-z0-9]$/i.test(value)) return value.toUpperCase(); - - return null; -}; - -export const normalizePopupShortcutCode = (code: unknown): string | null => { - if (typeof code !== "string") return null; - - if (/^Key[A-Z]$/.test(code)) return code.slice(3); - if (/^Digit[0-9]$/.test(code)) return code.slice(5); - - return null; -}; - -export const isPopupModifierShortcutKey = (key: string): boolean => { - return ["Alt", "Control", "Shift", "Meta"].includes(key); -}; - -export const isPopupEditableShortcutTarget = ( - target: EventTarget | null, -): boolean => { - if (!target) return false; - - const element = - typeof Element !== "undefined" && target instanceof Element - ? target - : typeof Element !== "undefined" && - "parentElement" in target && - target.parentElement instanceof Element - ? target.parentElement - : null; - if (!element) return false; - - return Boolean( - element.closest( - "input, textarea, select, [contenteditable=''], [contenteditable='true']", - ), - ); -}; - -export const formatPopupShortcut = (shortcut: PopupShortcut | null): string => { - if (!shortcut) return ""; - - const parts = []; - if (shortcut.ctrl) parts.push("Ctrl"); - if (shortcut.alt) parts.push("Alt"); - if (shortcut.shift) parts.push("Shift"); - if (shortcut.meta) parts.push("Meta"); - parts.push(shortcut.key); - return parts.join("+"); -}; - -export const arePopupShortcutsEqual = ( - first: PopupShortcut | null | undefined, - second: PopupShortcut | null | undefined, -): boolean => { - return ( - first?.ctrl === second?.ctrl && - first?.alt === second?.alt && - first?.shift === second?.shift && - first?.meta === second?.meta && - first?.key === second?.key - ); -}; - -export const normalizePopupShortcut = (value: unknown): PopupShortcut | null => { - if (!value || typeof value !== "object") return null; - - const shortcutValue = value as PopupShortcutLike; - const shortcut = { - ctrl: shortcutValue.ctrl === true, - alt: shortcutValue.alt === true, - shift: shortcutValue.shift === true, - meta: shortcutValue.meta === true, - key: typeof shortcutValue.key === "string" ? shortcutValue.key : "", - }; - - if (!shortcut.key) return null; - if (!shortcut.ctrl && !shortcut.alt && !shortcut.shift && !shortcut.meta) { - return null; - } - - return shortcut; -}; - -export const normalizePopupShortcutFromKeyboardEvent = ( - event: KeyboardEvent | null | undefined, -): PopupShortcut | null => { - if (!event || event.isComposing) return null; - const key = - normalizePopupShortcutCode(event.code) ?? normalizePopupShortcutKey(event.key); - - return normalizePopupShortcut({ - ctrl: event.ctrlKey, - alt: event.altKey, - shift: event.shiftKey, - meta: event.metaKey, - key, - }); -}; - -export const resolvePopupShortcuts = ( - storedShortcuts: Partial | null | undefined, -): PopupShortcutMap => { - const shortcuts = storedShortcuts ?? POPUP_DEFAULT_SHORTCUTS; - return { - [POPUP_SHORTCUT_ACTION_MUTE_NAME]: normalizePopupShortcut( - shortcuts[POPUP_SHORTCUT_ACTION_MUTE_NAME], - ), - [POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME]: normalizePopupShortcut( - shortcuts[POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME], - ), - }; -}; - -export const validatePopupShortcutConfig = ( - shortcuts: Partial | null | undefined, -): PopupShortcutValidationError | null => { - const muteShortcut = normalizePopupShortcut( - shortcuts?.[POPUP_SHORTCUT_ACTION_MUTE_NAME], - ); - const toggleEqShortcut = normalizePopupShortcut( - shortcuts?.[POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME], - ); - - if (!muteShortcut || !toggleEqShortcut) return "invalid"; - if (arePopupShortcutsEqual(muteShortcut, toggleEqShortcut)) return "duplicate"; - - return null; -}; - -export const matchesPopupShortcut = ( - event: KeyboardEvent, - shortcut: PopupShortcut | null, -): boolean => { - const normalized = normalizePopupShortcutFromKeyboardEvent(event); - if (!normalized) return false; - - return arePopupShortcutsEqual(normalized, shortcut); -}; diff --git a/src/ui/popup/settingsView.ts b/src/ui/popup/settingsView.ts index 98dadd0..2693ab8 100644 --- a/src/ui/popup/settingsView.ts +++ b/src/ui/popup/settingsView.ts @@ -1,32 +1,33 @@ import { clampPointCount } from "../../domains/equalizer/equalizerMath"; import { type LocalizationService } from "../../domains/localization/localizationService"; import { - formatPopupShortcut, - isPopupModifierShortcutKey, - normalizePopupShortcutFromKeyboardEvent, - POPUP_SHORTCUT_ACTION_MUTE_NAME, - POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME, - resolvePopupShortcuts, - type PopupShortcut, - type PopupShortcutActionName, - type PopupShortcutMap, - validatePopupShortcutConfig, -} from "./popupShortcuts"; -import { themes, type ThemeName } from "../../domains/theme/themes"; + formatShortcut, + isModifierShortcutKey, + normalizeShortcutFromKeyboardEvent, + SHORTCUT_ACTION_MUTE_NAME, + SHORTCUT_ACTION_TOGGLE_EQ_NAME, + resolveShortcuts, + type Shortcut, + type ShortcutActionName, + type ShortcutMap, + validateShortcutConfig, +} from "../../domains/shortcuts/shortcuts"; import { isDefaultPresetName } from "../../domains/presets/defaultPresets"; import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; +type ThemeName = "dark" | "light"; + const DEFAULT_THEME: ThemeName = "dark"; export interface SettingsView { init(): Promise; applyTheme(theme: unknown): ThemeName; updatePointCountSelect(count: unknown): void; - getShortcutSettings(): PopupShortcutMap; + getShortcutSettings(): ShortcutMap; } const isThemeName = (theme: unknown): theme is ThemeName => { - return typeof theme === "string" && theme in themes; + return theme === "dark" || theme === "light"; }; const saveTextAsFile = (text: string, filename = "file.txt"): void => { @@ -69,15 +70,11 @@ export const createSettingsView = (deps: { refreshDynamicContent(): Promise; }): SettingsView => { let pendingPointCount: number | null = null; - let shortcutSettings: PopupShortcutMap = resolvePopupShortcuts(null); + let shortcutSettings: ShortcutMap = resolveShortcuts(null); const applyTheme = (theme: unknown): ThemeName => { const chosenTheme = isThemeName(theme) ? theme : DEFAULT_THEME; - const chosen = themes[chosenTheme] ?? themes.dark; - - Object.entries(chosen).forEach(([key, value]) => { - document.documentElement.style.setProperty(`--${key}`, value); - }); + document.documentElement.dataset.theme = chosenTheme; deps.themeSelect.value = chosenTheme; deps.redraw(); return chosenTheme; @@ -133,17 +130,17 @@ export const createSettingsView = (deps: { deps.shortcutsError.style.display = "block"; }; - const shortcutInputs: Record = { - [POPUP_SHORTCUT_ACTION_MUTE_NAME]: deps.shortcutMute, - [POPUP_SHORTCUT_ACTION_TOGGLE_EQ_NAME]: deps.shortcutToggleEq, + const shortcutInputs: Record = { + [SHORTCUT_ACTION_MUTE_NAME]: deps.shortcutMute, + [SHORTCUT_ACTION_TOGGLE_EQ_NAME]: deps.shortcutToggleEq, }; const renderShortcutInputs = ( - invalidAction: PopupShortcutActionName | null = null, + invalidAction: ShortcutActionName | null = null, ): void => { Object.entries(shortcutInputs).forEach(([action, input]) => { - input.value = formatPopupShortcut( - shortcutSettings[action as PopupShortcutActionName], + input.value = formatShortcut( + shortcutSettings[action as ShortcutActionName], ); input.classList.toggle("invalid", action === invalidAction); }); @@ -156,14 +153,14 @@ export const createSettingsView = (deps: { }; const saveShortcut = async ( - action: PopupShortcutActionName, - shortcut: PopupShortcut, + action: ShortcutActionName, + shortcut: Shortcut, ): Promise => { const nextShortcuts = { ...shortcutSettings, [action]: shortcut, }; - const validationError = validatePopupShortcutConfig(nextShortcuts); + const validationError = validateShortcutConfig(nextShortcuts); if (validationError) { const messageName = @@ -175,7 +172,7 @@ export const createSettingsView = (deps: { return false; } - shortcutSettings = resolvePopupShortcuts(nextShortcuts); + shortcutSettings = resolveShortcuts(nextShortcuts); await chrome.storage.local.set({ [STORAGE_KEYS.SHORTCUTS]: shortcutSettings, }); @@ -325,7 +322,7 @@ export const createSettingsView = (deps: { }); Object.entries(shortcutInputs).forEach(([action, input]) => { - const shortcutAction = action as PopupShortcutActionName; + const shortcutAction = action as ShortcutActionName; input.addEventListener("focus", () => { startShortcutEdit(input); @@ -341,9 +338,9 @@ export const createSettingsView = (deps: { event.preventDefault(); event.stopPropagation(); - if (isPopupModifierShortcutKey(event.key)) return; + if (isModifierShortcutKey(event.key)) return; - const shortcut = normalizePopupShortcutFromKeyboardEvent(event); + const shortcut = normalizeShortcutFromKeyboardEvent(event); if (!shortcut) { setShortcutsError("shortcut_validation_error"); input.classList.add("invalid"); @@ -364,8 +361,8 @@ export const createSettingsView = (deps: { ]); deps.hideDefaultPresets.checked = stored[STORAGE_KEYS.HIDE_DEFAULT_PRESETS] === true; - shortcutSettings = resolvePopupShortcuts( - stored[STORAGE_KEYS.SHORTCUTS] as Partial, + shortcutSettings = resolveShortcuts( + stored[STORAGE_KEYS.SHORTCUTS] as Partial, ); renderShortcutInputs(); }, From 5c82d7e14301b463f9f62f9e78a66ff0d8ba837c Mon Sep 17 00:00:00 2001 From: Maitrog Date: Thu, 16 Jul 2026 11:09:53 +0300 Subject: [PATCH 43/63] refactor: checkbox redesign --- resources/styles/popup.css | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/resources/styles/popup.css b/resources/styles/popup.css index 029ebc7..6a609c1 100644 --- a/resources/styles/popup.css +++ b/resources/styles/popup.css @@ -637,7 +637,30 @@ button:disabled:active { } .modal-content input[type="checkbox"] { - accent-color: var(--accent-mid); + appearance: none; + flex: 0 0 42px; + width: 42px; + height: 18px; + margin: 0; + border-radius: 12px; + background-color: var(--surface-alt); + background-image: radial-gradient(circle, #fff 0 5px, transparent 6.5px); + background-position: 3px 2px; + background-repeat: no-repeat; + background-size: 15px 14px; + box-shadow: inset 0 0 0 1px var(--border), 0 1px 3px var(--shadow-soft); + cursor: pointer; + transition: background-color 0.2s, background-position 0.2s; +} + +.modal-content input[type="checkbox"]:checked { + background-color: var(--accent-mid); + background-position: 25px 2px; +} + +.modal-content input[type="checkbox"]:focus-visible { + outline: 2px solid var(--accent-mid); + outline-offset: 2px; } .preset-name-input { @@ -819,10 +842,6 @@ button:disabled:active { margin: 10px 0 14px; } -.checkbox-row input[type="checkbox"] { - accent-color: var(--accent-mid); -} - .confirm-actions { display: flex; justify-content: flex-end; From 3c4c1000f792f558b0db70af03dc6028c4bd1a9c Mon Sep 17 00:00:00 2001 From: Maitrog Date: Thu, 16 Jul 2026 11:37:27 +0300 Subject: [PATCH 44/63] fix: enable button glow effect disappeared after changing the settings --- src/app/popup/createPopupApp.ts | 7 +++---- src/app/window-mode/createToolkitWindowController.test.ts | 2 +- src/app/window-mode/createToolkitWindowController.ts | 4 ++-- src/ui/popup/controlsView.ts | 4 ++-- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/app/popup/createPopupApp.ts b/src/app/popup/createPopupApp.ts index 1edd3bd..03fa97d 100644 --- a/src/app/popup/createPopupApp.ts +++ b/src/app/popup/createPopupApp.ts @@ -143,7 +143,7 @@ export const createPopupApp = ({ setFilters: setCurrentFilters, initPoints, resize, - setEnableButtonText: (enabled) => controlsView?.setEnableButtonText(enabled), + setEnableButtonClass: (enabled) => controlsView?.setEnableButtonClass(enabled), setMuteButtonClass: (muted) => controlsView?.setMuteButtonClass(muted), renderCaptureError, getGainValue: () => Number(elements.masterVolume.value), @@ -228,7 +228,6 @@ export const createPopupApp = ({ await autostartView.renderWhitelist(); await autostartView.refreshPresetSelects(); await refreshPresetDropdown(); - controlsView.setEnableButtonText(false); }; const onToggleEqualizer = async (): Promise => { @@ -428,7 +427,7 @@ export const createPopupApp = ({ if (tabId == null) return; if (changes[STORAGE_KEYS.tabEnabled(tabId)]) { - controlsView.setEnableButtonText( + controlsView.setEnableButtonClass( changes[STORAGE_KEYS.tabEnabled(tabId)].newValue === true, ); } @@ -528,7 +527,7 @@ export const createPopupApp = ({ } resize(); - controlsView.setEnableButtonText(result[STORAGE_KEYS.tabEnabled(tabId)] === true); + controlsView.setEnableButtonClass(result[STORAGE_KEYS.tabEnabled(tabId)] === true); controlsView.setMuteButtonClass(result[STORAGE_KEYS.tabMute(tabId)] === true); if (result[STORAGE_KEYS.ENABLE_SPECTRUM] === true) { diff --git a/src/app/window-mode/createToolkitWindowController.test.ts b/src/app/window-mode/createToolkitWindowController.test.ts index 373652a..369fa40 100644 --- a/src/app/window-mode/createToolkitWindowController.test.ts +++ b/src/app/window-mode/createToolkitWindowController.test.ts @@ -155,7 +155,7 @@ const createController = ( setFilters: vi.fn(), initPoints: vi.fn(), resize: vi.fn(), - setEnableButtonText: vi.fn(), + setEnableButtonClass: vi.fn(), setMuteButtonClass: vi.fn(), renderCaptureError: vi.fn(), getGainValue: () => 0, diff --git a/src/app/window-mode/createToolkitWindowController.ts b/src/app/window-mode/createToolkitWindowController.ts index 5d33740..07ca965 100644 --- a/src/app/window-mode/createToolkitWindowController.ts +++ b/src/app/window-mode/createToolkitWindowController.ts @@ -67,7 +67,7 @@ export const createToolkitWindowController = (deps: { setFilters(filters: EqualizerPersistedFilter[]): void; initPoints(count: number): void; resize(): void; - setEnableButtonText(enabled: boolean): void; + setEnableButtonClass(enabled: boolean): void; setMuteButtonClass(muted: boolean): void; renderCaptureError(message: string | null): void; getGainValue(): number; @@ -364,7 +364,7 @@ export const createToolkitWindowController = (deps: { const gain = result[STORAGE_KEYS.tabGain(tabId)]; deps.resize(); - deps.setEnableButtonText(result[STORAGE_KEYS.tabEnabled(tabId)] === true); + deps.setEnableButtonClass(result[STORAGE_KEYS.tabEnabled(tabId)] === true); deps.setMuteButtonClass(result[STORAGE_KEYS.tabMute(tabId)] === true); deps.setGainValue(typeof gain === "string" || typeof gain === "number" ? Number(gain) : 0); diff --git a/src/ui/popup/controlsView.ts b/src/ui/popup/controlsView.ts index e9e33e8..33b12f8 100644 --- a/src/ui/popup/controlsView.ts +++ b/src/ui/popup/controlsView.ts @@ -1,5 +1,5 @@ export interface ControlsView { - setEnableButtonText(enabled: boolean): void; + setEnableButtonClass(enabled: boolean): void; setMuteButtonClass(muted: boolean): void; } @@ -45,7 +45,7 @@ export const createControlsView = (deps: { }); return { - setEnableButtonText: (enabled) => { + setEnableButtonClass: (enabled) => { deps.changeEqButton.classList.toggle("change-eq-active", enabled); }, From edecf22fc7831a6bcfbd5e6e7571c43270bf431d Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 17 Jul 2026 20:10:00 +0300 Subject: [PATCH 45/63] feat: define onboarding guide sequence --- src/ui/popup/onboardingGuideView.test.ts | 16 ++++ src/ui/popup/onboardingGuideView.ts | 97 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/ui/popup/onboardingGuideView.test.ts create mode 100644 src/ui/popup/onboardingGuideView.ts diff --git a/src/ui/popup/onboardingGuideView.test.ts b/src/ui/popup/onboardingGuideView.test.ts new file mode 100644 index 0000000..8e81f2d --- /dev/null +++ b/src/ui/popup/onboardingGuideView.test.ts @@ -0,0 +1,16 @@ +import { GUIDE_SCREENS, getGuideNavigation } from "./onboardingGuideView"; + +describe("onboarding guide navigation", () => { + test("keeps button explanations inside stages 4 and 5", () => { + expect(GUIDE_SCREENS.map(({ stage }) => stage)).toEqual([ + 1, 2, 3, 4, 4, 5, 5, 5, 6, 7, 8, + ]); + expect(getGuideNavigation(0)).toEqual({ + canGoBack: false, + canSkip: false, + isLast: false, + }); + expect(getGuideNavigation(3).canSkip).toBe(true); + expect(getGuideNavigation(GUIDE_SCREENS.length - 1).isLast).toBe(true); + }); +}); diff --git a/src/ui/popup/onboardingGuideView.ts b/src/ui/popup/onboardingGuideView.ts new file mode 100644 index 0000000..7321f98 --- /dev/null +++ b/src/ui/popup/onboardingGuideView.ts @@ -0,0 +1,97 @@ +export type GuideTarget = + | "volumeMute" + | "changeEq" + | "settings" + | "autostart" + | "windowMode" + | "equalizer" + | "volume" + | "presets"; + +export interface GuideScreen { + stage: number; + kind: "language" | "appearance" | "shortcuts" | "spotlight"; + titleKey: string; + messageKey?: string; + target?: GuideTarget; + substep?: readonly [number, number]; +} + +export const GUIDE_SCREENS: readonly GuideScreen[] = [ + { stage: 1, kind: "language", titleKey: "language_setting_option" }, + { stage: 2, kind: "appearance", titleKey: "settings_title" }, + { + stage: 3, + kind: "shortcuts", + titleKey: "shortcuts_settings_title", + messageKey: "guide_shortcuts_hint", + }, + { + stage: 4, + kind: "spotlight", + target: "volumeMute", + titleKey: "volume_mute_button_tooltip", + substep: [1, 2], + }, + { + stage: 4, + kind: "spotlight", + target: "changeEq", + titleKey: "enable_eq_button_label", + substep: [2, 2], + }, + { + stage: 5, + kind: "spotlight", + target: "settings", + titleKey: "settings_button_tooltip", + substep: [1, 3], + }, + { + stage: 5, + kind: "spotlight", + target: "autostart", + titleKey: "add_to_autostart_tooltip", + substep: [2, 3], + }, + { + stage: 5, + kind: "spotlight", + target: "windowMode", + titleKey: "window_mode_button_tooltip", + substep: [3, 3], + }, + { + stage: 6, + kind: "spotlight", + target: "equalizer", + titleKey: "extName", + messageKey: "guide_canvas_hint", + }, + { + stage: 7, + kind: "spotlight", + target: "volume", + titleKey: "global_controls_title", + messageKey: "guide_volume_hint", + }, + { + stage: 8, + kind: "spotlight", + target: "presets", + titleKey: "preset_controls_title", + messageKey: "guide_presets_hint", + }, +]; + +export interface GuideNavigation { + canGoBack: boolean; + canSkip: boolean; + isLast: boolean; +} + +export const getGuideNavigation = (index: number): GuideNavigation => ({ + canGoBack: index > 0, + canSkip: GUIDE_SCREENS[index].stage >= 4, + isLast: index === GUIDE_SCREENS.length - 1, +}); From d79f5720e29323bfacf041156cf7c02080d8981e Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 17 Jul 2026 20:16:34 +0300 Subject: [PATCH 46/63] feat: add onboarding spotlight overlay --- popup.html | 23 +- resources/styles/popup.css | 106 ++++++++ src/app/popup/onboardingGuideMarkup.test.ts | 16 ++ src/infrastructure/dom/popupElements.ts | 18 ++ src/ui/popup/onboardingGuideView.test.ts | 21 +- src/ui/popup/onboardingGuideView.ts | 279 ++++++++++++++++++++ 6 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 src/app/popup/onboardingGuideMarkup.test.ts diff --git a/popup.html b/popup.html index a3adc2e..99b629d 100644 --- a/popup.html +++ b/popup.html @@ -24,7 +24,7 @@
-
+
@@ -44,7 +44,7 @@

Global Controls

-
+

Preset Controls

@@ -245,6 +245,25 @@

Patch Notes 1.8.0

+ + diff --git a/resources/styles/popup.css b/resources/styles/popup.css index 6a609c1..c92917c 100644 --- a/resources/styles/popup.css +++ b/resources/styles/popup.css @@ -738,6 +738,112 @@ button:disabled:active { line-height: 1.35; } +.onboarding-guide { + position: fixed; + inset: 0; + z-index: 2000; + color: var(--text); +} + +.onboarding-guide[hidden] { + display: none; +} + +.guide-blocker { + position: absolute; + inset: 0; +} + +.guide-blur-panel { + position: absolute; + background: var(--overlay); + backdrop-filter: blur(4px); +} + +.guide-spotlight { + position: absolute; + border: 2px solid var(--accent-mid); + border-radius: 8px; + box-shadow: 0 0 18px var(--accent-mid); + pointer-events: none; +} + +.guide-card { + position: absolute; + z-index: 1; + left: 50%; + width: min(400px, calc(100% - 32px)); + padding: 18px; + transform: translateX(-50%); + border: 1px solid var(--border); + border-radius: 8px; + background: var(--settings-bg); + box-shadow: 0 4px 16px var(--shadow-strong); + box-sizing: border-box; +} + +.guide-card h2 { + margin: 4px 0 0; + font-size: 18px; +} + +.onboarding-guide.card-mode { + background: var(--overlay); + backdrop-filter: blur(4px); +} + +.onboarding-guide.card-mode .guide-blur-panel, +.onboarding-guide.card-mode .guide-spotlight { + display: none; +} + +.onboarding-guide.card-mode .guide-card { + top: 50%; + transform: translate(-50%, -50%); +} + +.guide-content { + display: grid; + gap: 12px; + margin: 12px 0 18px; +} + +.guide-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.guide-field select { + min-width: 150px; +} + +.guide-shortcut { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.guide-shortcut kbd { + color: var(--text); +} + +.guide-progress { + color: var(--text-placeholder); + font-size: 12px; +} + +.guide-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.guide-actions [data-guide-action="skip"] { + margin-right: auto; +} + .autostart-settings-list { display: flex; flex-direction: column; diff --git a/src/app/popup/onboardingGuideMarkup.test.ts b/src/app/popup/onboardingGuideMarkup.test.ts new file mode 100644 index 0000000..ba48b57 --- /dev/null +++ b/src/app/popup/onboardingGuideMarkup.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; + +test("contains the accessible onboarding overlay", () => { + const markup = readFileSync( + new URL("../../../popup.html", import.meta.url), + "utf8", + ); + + expect(markup).toContain('id="onboarding-guide"'); + expect(markup).toContain('role="dialog"'); + expect(markup).toContain('aria-modal="true"'); + expect(markup).toContain('data-guide-action="skip"'); + expect(markup).toMatch( + /
\s*

/, + ); +}); diff --git a/src/infrastructure/dom/popupElements.ts b/src/infrastructure/dom/popupElements.ts index 4c4e1da..553d893 100644 --- a/src/infrastructure/dom/popupElements.ts +++ b/src/infrastructure/dom/popupElements.ts @@ -8,6 +8,7 @@ export interface PopupElements { captureError: HTMLDivElement; capturedTabs: HTMLDivElement; infoButton: HTMLImageElement; + equalizerCurveContainer: HTMLDivElement; eqCanvas: HTMLCanvasElement; spectrumCanvas: HTMLCanvasElement; infoTooltip: HTMLDivElement; @@ -21,6 +22,7 @@ export interface PopupElements { presetsMenu: HTMLDivElement; noneItem: HTMLDivElement; savePresetButton: HTMLButtonElement; + presetControlsCard: HTMLElement; presetSaveModal: HTMLDivElement; presetSaveClose: HTMLSpanElement; presetSaveForm: HTMLFormElement; @@ -86,6 +88,7 @@ export interface PopupElements { pointsResetConfirm: HTMLButtonElement; installUpdateNoticeModal: HTMLDivElement; installUpdateNoticeClose: HTMLButtonElement; + onboardingGuide: HTMLDivElement; } export const getPopupElements = (document: Document): PopupElements => ({ @@ -100,6 +103,11 @@ export const getPopupElements = (document: Document): PopupElements => ({ captureError: getRequiredElement(document, "capture-error", HTMLDivElement), capturedTabs: getRequiredElement(document, "captured-tabs", HTMLDivElement), infoButton: getRequiredElement(document, "info-btn", HTMLImageElement), + equalizerCurveContainer: getRequiredElement( + document, + "equalizer-curve-container", + HTMLDivElement, + ), eqCanvas: getRequiredElement(document, "eq-canvas", HTMLCanvasElement), spectrumCanvas: getRequiredElement( document, @@ -129,6 +137,11 @@ export const getPopupElements = (document: Document): PopupElements => ({ presetsMenu: getRequiredElement(document, "presets-menu", HTMLDivElement), noneItem: getRequiredElement(document, "none-item", HTMLDivElement), savePresetButton: getRequiredElement(document, "save-preset", HTMLButtonElement), + presetControlsCard: getRequiredElement( + document, + "preset-controls-card", + HTMLElement, + ), presetSaveModal: getRequiredElement(document, "preset-save-modal", HTMLDivElement), presetSaveClose: getRequiredElement(document, "preset-save-close", HTMLSpanElement), presetSaveForm: getRequiredElement(document, "preset-save-form", HTMLFormElement), @@ -350,4 +363,9 @@ export const getPopupElements = (document: Document): PopupElements => ({ "install-update-notice-close", HTMLButtonElement, ), + onboardingGuide: getRequiredElement( + document, + "onboarding-guide", + HTMLDivElement, + ), }); diff --git a/src/ui/popup/onboardingGuideView.test.ts b/src/ui/popup/onboardingGuideView.test.ts index 8e81f2d..6519490 100644 --- a/src/ui/popup/onboardingGuideView.test.ts +++ b/src/ui/popup/onboardingGuideView.test.ts @@ -1,4 +1,8 @@ -import { GUIDE_SCREENS, getGuideNavigation } from "./onboardingGuideView"; +import { + GUIDE_SCREENS, + getGuideNavigation, + getSpotlightPanels, +} from "./onboardingGuideView"; describe("onboarding guide navigation", () => { test("keeps button explanations inside stages 4 and 5", () => { @@ -14,3 +18,18 @@ describe("onboarding guide navigation", () => { expect(getGuideNavigation(GUIDE_SCREENS.length - 1).isLast).toBe(true); }); }); + +test("splits the viewport into four panels around the target", () => { + expect( + getSpotlightPanels( + { left: 10, top: 20, right: 40, bottom: 60 }, + 100, + 90, + ), + ).toEqual({ + top: { left: 0, top: 0, width: 100, height: 20 }, + left: { left: 0, top: 20, width: 10, height: 40 }, + right: { left: 40, top: 20, width: 60, height: 40 }, + bottom: { left: 0, top: 60, width: 100, height: 30 }, + }); +}); diff --git a/src/ui/popup/onboardingGuideView.ts b/src/ui/popup/onboardingGuideView.ts index 7321f98..0842c87 100644 --- a/src/ui/popup/onboardingGuideView.ts +++ b/src/ui/popup/onboardingGuideView.ts @@ -95,3 +95,282 @@ export const getGuideNavigation = (index: number): GuideNavigation => ({ canSkip: GUIDE_SCREENS[index].stage >= 4, isLast: index === GUIDE_SCREENS.length - 1, }); + +interface RectEdges { + left: number; + top: number; + right: number; + bottom: number; +} + +interface PositionedRect { + left: number; + top: number; + width: number; + height: number; +} + +export const getSpotlightPanels = ( + target: RectEdges, + viewportWidth: number, + viewportHeight: number, +): Record<"top" | "left" | "right" | "bottom", PositionedRect> => ({ + top: { left: 0, top: 0, width: viewportWidth, height: target.top }, + left: { + left: 0, + top: target.top, + width: target.left, + height: target.bottom - target.top, + }, + right: { + left: target.right, + top: target.top, + width: viewportWidth - target.right, + height: target.bottom - target.top, + }, + bottom: { + left: 0, + top: target.bottom, + width: viewportWidth, + height: viewportHeight - target.bottom, + }, +}); + +export interface OnboardingGuideView { + start(): Promise; + refresh(): void; +} + +export const createOnboardingGuideView = (deps: { + root: HTMLElement; + inertElements: HTMLElement[]; + targets: Record; + sourceLanguageSelect: HTMLSelectElement; + sourceThemeSelect: HTMLSelectElement; + sourcePointCountSelect: HTMLSelectElement; + getMessage(name: string): string; + setLanguage(language: string): Promise; + setTheme(theme: string): Promise; + setPointCount(count: string): Promise; + onShown(): Promise; +}): OnboardingGuideView => { + const getPart = (selector: string): T => { + const element = deps.root.querySelector(selector); + if (!element) throw new Error(`Missing onboarding element: ${selector}`); + return element; + }; + const title = getPart("#guide-title"); + const content = getPart(".guide-content"); + const progress = getPart(".guide-progress"); + const card = getPart(".guide-card"); + const spotlight = getPart(".guide-spotlight"); + const backButton = getPart("[data-guide-action='back']"); + const nextButton = getPart("[data-guide-action='next']"); + const skipButton = getPart("[data-guide-action='skip']"); + const panels = { + top: getPart("[data-guide-panel='top']"), + left: getPart("[data-guide-panel='left']"), + right: getPart("[data-guide-panel='right']"), + bottom: getPart("[data-guide-panel='bottom']"), + }; + let currentIndex = 0; + let started = false; + + const setRect = (element: HTMLElement, rect: PositionedRect): void => { + element.style.left = `${rect.left}px`; + element.style.top = `${rect.top}px`; + element.style.width = `${Math.max(0, rect.width)}px`; + element.style.height = `${Math.max(0, rect.height)}px`; + }; + + const createSelectField = ( + labelText: string, + source: HTMLSelectElement, + onChange: (value: string) => Promise, + ): HTMLLabelElement => { + const field = document.createElement("label"); + field.className = "guide-field"; + const label = document.createElement("span"); + label.textContent = labelText; + const select = source.cloneNode(true) as HTMLSelectElement; + select.removeAttribute("id"); + select.value = source.value; + select.addEventListener("change", () => { + void onChange(select.value).then(() => render()); + }); + field.append(label, select); + return field; + }; + + const renderContent = (screen: GuideScreen): void => { + content.replaceChildren(); + if (screen.kind === "language") { + content.append( + createSelectField( + deps.getMessage("language_setting_option"), + deps.sourceLanguageSelect, + deps.setLanguage, + ), + ); + return; + } + if (screen.kind === "appearance") { + content.append( + createSelectField( + deps.getMessage("theme_setting_option"), + deps.sourceThemeSelect, + deps.setTheme, + ), + createSelectField( + deps.getMessage("points_count_setting_option"), + deps.sourcePointCountSelect, + deps.setPointCount, + ), + ); + return; + } + if (screen.kind === "shortcuts") { + const mute = document.createElement("div"); + mute.className = "guide-shortcut"; + mute.innerHTML = `${deps.getMessage("shortcut_mute_label")}Alt+M`; + const toggle = document.createElement("div"); + toggle.className = "guide-shortcut"; + toggle.innerHTML = `${deps.getMessage("shortcut_toggle_eq_label")}Alt+K`; + content.append(mute, toggle); + } + if (screen.messageKey) { + const message = document.createElement("p"); + message.textContent = deps.getMessage(screen.messageKey); + content.append(message); + } + }; + + const positionSpotlight = (target: HTMLElement): void => { + const padding = 6; + const rect = target.getBoundingClientRect(); + const focus = { + left: Math.max(0, rect.left - padding), + top: Math.max(0, rect.top - padding), + right: Math.min(window.innerWidth, rect.right + padding), + bottom: Math.min(window.innerHeight, rect.bottom + padding), + }; + const panelRects = getSpotlightPanels( + focus, + window.innerWidth, + window.innerHeight, + ); + Object.entries(panels).forEach(([name, panel]) => { + setRect(panel, panelRects[name as keyof typeof panelRects]); + }); + setRect(spotlight, { + left: focus.left, + top: focus.top, + width: focus.right - focus.left, + height: focus.bottom - focus.top, + }); + + const margin = 12; + const cardRect = card.getBoundingClientRect(); + const center = Math.max( + margin + cardRect.width / 2, + Math.min( + window.innerWidth - margin - cardRect.width / 2, + (focus.left + focus.right) / 2, + ), + ); + const below = focus.bottom + margin; + const above = focus.top - cardRect.height - margin; + const top = + below + cardRect.height <= window.innerHeight - margin + ? below + : above >= margin + ? above + : window.innerHeight - cardRect.height - margin; + card.style.left = `${center}px`; + card.style.top = `${Math.max(margin, top)}px`; + card.style.transform = "translateX(-50%)"; + }; + + const render = (): void => { + const screen = GUIDE_SCREENS[currentIndex]; + const navigation = getGuideNavigation(currentIndex); + deps.root.classList.toggle("card-mode", screen.kind !== "spotlight"); + title.textContent = deps.getMessage(screen.titleKey); + progress.textContent = `${screen.stage}/8${ + screen.substep ? ` · ${screen.substep[0]}/${screen.substep[1]}` : "" + }`; + backButton.hidden = !navigation.canGoBack; + skipButton.hidden = !navigation.canSkip; + backButton.textContent = deps.getMessage("guide_back"); + skipButton.textContent = deps.getMessage("guide_skip"); + nextButton.textContent = deps.getMessage( + navigation.isLast ? "guide_finish" : "guide_next", + ); + renderContent(screen); + + card.style.left = ""; + card.style.top = ""; + card.style.transform = ""; + if (screen.kind === "spotlight" && screen.target) { + positionSpotlight(deps.targets[screen.target]); + } + title.focus(); + }; + + const close = (): void => { + deps.root.hidden = true; + deps.inertElements.forEach((element) => { + element.inert = false; + }); + window.removeEventListener("resize", render); + started = false; + }; + + backButton.addEventListener("click", () => { + currentIndex = Math.max(0, currentIndex - 1); + render(); + }); + nextButton.addEventListener("click", () => { + if (getGuideNavigation(currentIndex).isLast) { + close(); + return; + } + currentIndex += 1; + render(); + }); + skipButton.addEventListener("click", close); + deps.root.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key !== "Tab") return; + const controls = Array.from( + deps.root.querySelectorAll( + "button:not([hidden]):not(:disabled), select:not(:disabled)", + ), + ); + if (controls.length === 0) return; + const current = controls.indexOf(document.activeElement as typeof controls[number]); + const next = event.shiftKey + ? (current - 1 + controls.length) % controls.length + : (current + 1) % controls.length; + event.preventDefault(); + controls[next].focus(); + }); + + return { + start: async () => { + if (started) return; + started = true; + currentIndex = 0; + deps.inertElements.forEach((element) => { + element.inert = true; + }); + deps.root.hidden = false; + render(); + window.addEventListener("resize", render); + await deps.onShown(); + }, + refresh: () => { + if (started) render(); + }, + }; +}; From f0fded455de45005bb1316c7b4fb6a866c72de97 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 17 Jul 2026 20:20:44 +0300 Subject: [PATCH 47/63] feat: launch onboarding after installation --- src/app/popup/createPopupApp.ts | 49 +++++++++++++++++++- src/ui/popup/installUpdateNoticeView.test.ts | 32 ++++++++++++- src/ui/popup/installUpdateNoticeView.ts | 2 +- src/ui/popup/onboardingGuideView.ts | 4 -- src/ui/popup/settingsView.ts | 17 ++++++- 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/app/popup/createPopupApp.ts b/src/app/popup/createPopupApp.ts index 03fa97d..9e2c895 100644 --- a/src/app/popup/createPopupApp.ts +++ b/src/app/popup/createPopupApp.ts @@ -27,7 +27,11 @@ import { formatGainValue, type ControlsView, } from "../../ui/popup/controlsView"; -import { createInstallUpdateNoticeView } from "../../ui/popup/installUpdateNoticeView"; +import { + createInstallUpdateNoticeView, + getPendingInstallUpdateNotice, +} from "../../ui/popup/installUpdateNoticeView"; +import { createOnboardingGuideView } from "../../ui/popup/onboardingGuideView"; import { createPresetsView, type PresetsView } from "../../ui/popup/presetsView"; import { createSettingsView, type SettingsView } from "../../ui/popup/settingsView"; @@ -394,6 +398,38 @@ export const createPopupApp = ({ isToolkitWindow: toolkitController.isToolkitWindow, }); + const onboardingGuideView = createOnboardingGuideView({ + root: elements.onboardingGuide, + inertElements: Array.from(document.body.children).filter( + (element): element is HTMLElement => + element instanceof HTMLElement && element !== elements.onboardingGuide, + ), + targets: { + volumeMute: elements.volumeMuteButton, + changeEq: elements.changeEqButton, + settings: elements.settingsButton, + autostart: elements.addToAutostartWhitelistButton, + windowMode: elements.windowModeButton, + equalizer: elements.equalizerCurveContainer, + volume: elements.masterVolume, + presets: elements.presetControlsCard, + }, + sourceLanguageSelect: elements.languageSelect, + sourceThemeSelect: elements.themeSelect, + sourcePointCountSelect: elements.pointsCount, + getMessage: localization.getMessage, + setLanguage: async (language) => { + await localization.setLanguage(language, { + save: true, + refreshDynamicContent, + }); + }, + setTheme: (theme) => settingsView.setTheme(theme), + setPointCount: (count) => settingsView.setPointCount(count), + onShown: () => + chrome.storage.local.remove(STORAGE_KEYS.INSTALL_UPDATE_NOTICE), + }); + document.addEventListener("keydown", (event) => { void (async () => { if (event.repeat || isEditableShortcutTarget(event.target)) return; @@ -548,7 +584,16 @@ export const createPopupApp = ({ : null, ); - installUpdateNoticeView.showInstallUpdateNotice(stored); + const pendingNotice = getPendingInstallUpdateNotice({ + stored, + currentVersion: chrome.runtime.getManifest().version, + isToolkitWindow: toolkitController.isToolkitWindow, + }); + if (pendingNotice?.reason === "install") { + await onboardingGuideView.start(); + } else { + installUpdateNoticeView.showInstallUpdateNotice(stored); + } await toolkitController.startTabCapture(); await toolkitController.renderCapturedTabs(); }; diff --git a/src/ui/popup/installUpdateNoticeView.test.ts b/src/ui/popup/installUpdateNoticeView.test.ts index 2ae95c9..7d1156a 100644 --- a/src/ui/popup/installUpdateNoticeView.test.ts +++ b/src/ui/popup/installUpdateNoticeView.test.ts @@ -1,5 +1,8 @@ import { STORAGE_KEYS } from "../../infrastructure/chrome/storageKeys"; -import { getPendingInstallUpdateNotice } from "./installUpdateNoticeView"; +import { + createInstallUpdateNoticeView, + getPendingInstallUpdateNotice, +} from "./installUpdateNoticeView"; describe("getPendingInstallUpdateNotice", () => { const stored = { @@ -41,4 +44,31 @@ describe("getPendingInstallUpdateNotice", () => { }), ).toBeNull(); }); + + it("shows Patch Notes for updates but not installs", () => { + const modal = { style: { display: "none" } } as HTMLElement; + const closeButton = { addEventListener: vi.fn() } as unknown as HTMLElement; + const view = createInstallUpdateNoticeView({ + modal, + closeButton, + currentVersion: "1.8.0", + isToolkitWindow: false, + }); + + view.showInstallUpdateNotice({ + [STORAGE_KEYS.INSTALL_UPDATE_NOTICE]: { + reason: "install", + version: "1.8.0", + }, + }); + expect(modal.style.display).toBe("none"); + + view.showInstallUpdateNotice({ + [STORAGE_KEYS.INSTALL_UPDATE_NOTICE]: { + reason: "update", + version: "1.8.0", + }, + }); + expect(modal.style.display).toBe("block"); + }); }); diff --git a/src/ui/popup/installUpdateNoticeView.ts b/src/ui/popup/installUpdateNoticeView.ts index f8c44cb..3d536b5 100644 --- a/src/ui/popup/installUpdateNoticeView.ts +++ b/src/ui/popup/installUpdateNoticeView.ts @@ -58,7 +58,7 @@ export const createInstallUpdateNoticeView = (deps: { currentVersion: deps.currentVersion, isToolkitWindow: deps.isToolkitWindow, }); - if (!notice) return; + if (notice?.reason !== "update") return; deps.modal.style.display = "block"; }, diff --git a/src/ui/popup/onboardingGuideView.ts b/src/ui/popup/onboardingGuideView.ts index 0842c87..9bc5414 100644 --- a/src/ui/popup/onboardingGuideView.ts +++ b/src/ui/popup/onboardingGuideView.ts @@ -138,7 +138,6 @@ export const getSpotlightPanels = ( export interface OnboardingGuideView { start(): Promise; - refresh(): void; } export const createOnboardingGuideView = (deps: { @@ -369,8 +368,5 @@ export const createOnboardingGuideView = (deps: { window.addEventListener("resize", render); await deps.onShown(); }, - refresh: () => { - if (started) render(); - }, }; }; diff --git a/src/ui/popup/settingsView.ts b/src/ui/popup/settingsView.ts index 2693ab8..147dc95 100644 --- a/src/ui/popup/settingsView.ts +++ b/src/ui/popup/settingsView.ts @@ -22,6 +22,8 @@ const DEFAULT_THEME: ThemeName = "dark"; export interface SettingsView { init(): Promise; applyTheme(theme: unknown): ThemeName; + setTheme(theme: unknown): Promise; + setPointCount(count: unknown): Promise; updatePointCountSelect(count: unknown): void; getShortcutSettings(): ShortcutMap; } @@ -84,6 +86,10 @@ export const createSettingsView = (deps: { return chrome.storage.local.set({ [STORAGE_KEYS.THEME]: theme }); }; + const setTheme = async (theme: unknown): Promise => { + await saveTheme(applyTheme(theme)); + }; + const shouldSkipPointsResetConfirm = async (): Promise => { const result = await chrome.storage.local.get([STORAGE_KEYS.SKIP_POINTS_CONFIRM]); return ( @@ -110,6 +116,12 @@ export const createSettingsView = (deps: { await deps.saveCurrentFilters(); }; + const setPointCount = (count: unknown): Promise => { + return applyPointCountChange( + clampPointCount(Number.parseInt(String(count), 10)), + ); + }; + const closePointsResetModal = (): void => { deps.pointsResetModal.style.display = "none"; }; @@ -196,8 +208,7 @@ export const createSettingsView = (deps: { }); deps.themeSelect.addEventListener("change", () => { - const theme = applyTheme(deps.themeSelect.value); - void saveTheme(theme); + void setTheme(deps.themeSelect.value); }); deps.pointsCount.addEventListener("change", () => { @@ -367,6 +378,8 @@ export const createSettingsView = (deps: { renderShortcutInputs(); }, applyTheme, + setTheme, + setPointCount, updatePointCountSelect, getShortcutSettings: () => shortcutSettings, }; From 9a0c3792848672ca307983bff248b71c496a4cc9 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 17 Jul 2026 20:27:52 +0300 Subject: [PATCH 48/63] feat: localize onboarding guide --- _locales/bn/messages.json | 12 +++++++- _locales/de/messages.json | 12 +++++++- _locales/en/messages.json | 12 +++++++- _locales/es/messages.json | 12 +++++++- _locales/fr/messages.json | 12 +++++++- _locales/hi/messages.json | 12 +++++++- _locales/id/messages.json | 12 +++++++- _locales/it/messages.json | 12 +++++++- _locales/ja/messages.json | 12 +++++++- _locales/ko/messages.json | 12 +++++++- _locales/nl/messages.json | 12 +++++++- _locales/pt_PT/messages.json | 12 +++++++- _locales/ru/messages.json | 12 +++++++- _locales/ta/messages.json | 12 +++++++- _locales/th/messages.json | 12 +++++++- _locales/tr/messages.json | 12 +++++++- _locales/vi/messages.json | 12 +++++++- _locales/zh_CN/messages.json | 12 +++++++- .../localization/localizationKeys.test.ts | 28 +++++++++++++++++++ .../localization/localizationService.test.ts | 27 ++++++++++++++++++ .../localization/localizationService.ts | 2 ++ 21 files changed, 255 insertions(+), 18 deletions(-) create mode 100644 src/domains/localization/localizationKeys.test.ts diff --git a/_locales/bn/messages.json b/_locales/bn/messages.json index 8efe0c6..d340281 100644 --- a/_locales/bn/messages.json +++ b/_locales/bn/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "এই নামে একটি প্রিসেট ইতিমধ্যে বিদ্যমান।" - } + }, + "guide_back": { "message": "পিছনে" }, + "guide_next": { "message": "পরবর্তী" }, + "guide_skip": { "message": "এড়িয়ে যান" }, + "guide_finish": { "message": "শেষ করুন" }, + "guide_theme_dark": { "message": "গাঢ়" }, + "guide_theme_light": { "message": "হালকা" }, + "guide_shortcuts_hint": { "message": "সেটিংসে এই শর্টকাটগুলো পরিবর্তন করতে পারেন।" }, + "guide_canvas_hint": { "message": "ইকুয়ালাইজার সাজাতে পয়েন্ট টানুন। সেটিংসে পয়েন্টের সংখ্যা পরিবর্তন করুন।" }, + "guide_volume_hint": { "message": "সামগ্রিক ভলিউম সামঞ্জস্য করতে এই স্লাইডার ব্যবহার করুন।" }, + "guide_presets_hint": { "message": "এখানে বর্তমান সেটআপ সংরক্ষণ করুন বা একটি প্রিসেট প্রয়োগ করুন।" } } diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 15dbff2..008470c 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Eine Voreinstellung mit diesem Namen existiert bereits." - } + }, + "guide_back": { "message": "Zurück" }, + "guide_next": { "message": "Weiter" }, + "guide_skip": { "message": "Überspringen" }, + "guide_finish": { "message": "Fertig" }, + "guide_theme_dark": { "message": "Dunkel" }, + "guide_theme_light": { "message": "Hell" }, + "guide_shortcuts_hint": { "message": "Du kannst diese Tastenkürzel in den Einstellungen ändern." }, + "guide_canvas_hint": { "message": "Ziehe die Punkte, um den Equalizer einzustellen. Die Anzahl kannst du in den Einstellungen ändern." }, + "guide_volume_hint": { "message": "Mit diesem Regler passt du die Gesamtlautstärke an." }, + "guide_presets_hint": { "message": "Hier kannst du die aktuelle Einstellung speichern oder ein Preset anwenden." } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b27d80c..f9d4c3d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "A preset with this name already exists." - } + }, + "guide_back": { "message": "Back" }, + "guide_next": { "message": "Next" }, + "guide_skip": { "message": "Skip" }, + "guide_finish": { "message": "Finish" }, + "guide_theme_dark": { "message": "Dark" }, + "guide_theme_light": { "message": "Light" }, + "guide_shortcuts_hint": { "message": "You can change these shortcuts in Settings." }, + "guide_canvas_hint": { "message": "Drag points to shape the equalizer. Change the number of points in Settings." }, + "guide_volume_hint": { "message": "Use this slider to adjust the overall volume." }, + "guide_presets_hint": { "message": "Save the current setup or apply a preset here." } } diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 07e036d..9a63cb0 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Ya existe un preajuste con este nombre." - } + }, + "guide_back": { "message": "Atrás" }, + "guide_next": { "message": "Siguiente" }, + "guide_skip": { "message": "Omitir" }, + "guide_finish": { "message": "Finalizar" }, + "guide_theme_dark": { "message": "Oscuro" }, + "guide_theme_light": { "message": "Claro" }, + "guide_shortcuts_hint": { "message": "Puedes cambiar estos atajos en Ajustes." }, + "guide_canvas_hint": { "message": "Arrastra los puntos para ajustar el ecualizador. Cambia su cantidad en Ajustes." }, + "guide_volume_hint": { "message": "Usa este control para ajustar el volumen general." }, + "guide_presets_hint": { "message": "Aquí puedes guardar la configuración actual o aplicar un preajuste." } } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 8c68718..fe980b1 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Un préréglage portant ce nom existe déjà." - } + }, + "guide_back": { "message": "Retour" }, + "guide_next": { "message": "Suivant" }, + "guide_skip": { "message": "Ignorer" }, + "guide_finish": { "message": "Terminer" }, + "guide_theme_dark": { "message": "Sombre" }, + "guide_theme_light": { "message": "Clair" }, + "guide_shortcuts_hint": { "message": "Vous pouvez modifier ces raccourcis dans les paramètres." }, + "guide_canvas_hint": { "message": "Faites glisser les points pour régler l’égaliseur. Modifiez leur nombre dans les paramètres." }, + "guide_volume_hint": { "message": "Utilisez ce curseur pour régler le volume général." }, + "guide_presets_hint": { "message": "Enregistrez le réglage actuel ou appliquez un préréglage ici." } } diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index 06b7d52..1fd6bbb 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "इस नाम का प्रीसेट पहले से मौजूद है।" - } + }, + "guide_back": { "message": "पीछे" }, + "guide_next": { "message": "अगला" }, + "guide_skip": { "message": "छोड़ें" }, + "guide_finish": { "message": "समाप्त करें" }, + "guide_theme_dark": { "message": "गहरा" }, + "guide_theme_light": { "message": "हल्का" }, + "guide_shortcuts_hint": { "message": "आप इन शॉर्टकट को सेटिंग्स में बदल सकते हैं।" }, + "guide_canvas_hint": { "message": "इक्वलाइज़र सेट करने के लिए बिंदुओं को खींचें। सेटिंग्स में बिंदुओं की संख्या बदलें।" }, + "guide_volume_hint": { "message": "कुल वॉल्यूम बदलने के लिए इस स्लाइडर का उपयोग करें।" }, + "guide_presets_hint": { "message": "यहाँ मौजूदा सेटअप सहेजें या कोई प्रीसेट लागू करें।" } } diff --git a/_locales/id/messages.json b/_locales/id/messages.json index 461f05a..da3f05c 100644 --- a/_locales/id/messages.json +++ b/_locales/id/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Preset dengan nama ini sudah ada." - } + }, + "guide_back": { "message": "Kembali" }, + "guide_next": { "message": "Berikutnya" }, + "guide_skip": { "message": "Lewati" }, + "guide_finish": { "message": "Selesai" }, + "guide_theme_dark": { "message": "Gelap" }, + "guide_theme_light": { "message": "Terang" }, + "guide_shortcuts_hint": { "message": "Anda dapat mengubah pintasan ini di Pengaturan." }, + "guide_canvas_hint": { "message": "Seret titik untuk mengatur equalizer. Ubah jumlah titik di Pengaturan." }, + "guide_volume_hint": { "message": "Gunakan penggeser ini untuk menyesuaikan volume keseluruhan." }, + "guide_presets_hint": { "message": "Simpan pengaturan saat ini atau terapkan preset di sini." } } diff --git a/_locales/it/messages.json b/_locales/it/messages.json index ea13d3e..39a1e2e 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Esiste già una preimpostazione con questo nome." - } + }, + "guide_back": { "message": "Indietro" }, + "guide_next": { "message": "Avanti" }, + "guide_skip": { "message": "Salta" }, + "guide_finish": { "message": "Fine" }, + "guide_theme_dark": { "message": "Scuro" }, + "guide_theme_light": { "message": "Chiaro" }, + "guide_shortcuts_hint": { "message": "Puoi modificare queste scorciatoie nelle Impostazioni." }, + "guide_canvas_hint": { "message": "Trascina i punti per regolare l’equalizzatore. Modificane il numero nelle Impostazioni." }, + "guide_volume_hint": { "message": "Usa questo cursore per regolare il volume generale." }, + "guide_presets_hint": { "message": "Salva la configurazione corrente o applica un preset qui." } } diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index 25db6a3..d612108 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "この名前のプリセットは既に存在します。" - } + }, + "guide_back": { "message": "戻る" }, + "guide_next": { "message": "次へ" }, + "guide_skip": { "message": "スキップ" }, + "guide_finish": { "message": "完了" }, + "guide_theme_dark": { "message": "ダーク" }, + "guide_theme_light": { "message": "ライト" }, + "guide_shortcuts_hint": { "message": "これらのショートカットは設定で変更できます。" }, + "guide_canvas_hint": { "message": "ポイントをドラッグしてイコライザーを調整します。ポイント数は設定で変更できます。" }, + "guide_volume_hint": { "message": "このスライダーで全体の音量を調整します。" }, + "guide_presets_hint": { "message": "ここで現在の設定を保存するか、プリセットを適用できます。" } } diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 7ec2f09..b6b58e3 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "이 이름의 프리셋이 이미 있습니다." - } + }, + "guide_back": { "message": "뒤로" }, + "guide_next": { "message": "다음" }, + "guide_skip": { "message": "건너뛰기" }, + "guide_finish": { "message": "완료" }, + "guide_theme_dark": { "message": "어둡게" }, + "guide_theme_light": { "message": "밝게" }, + "guide_shortcuts_hint": { "message": "이 단축키는 설정에서 변경할 수 있습니다." }, + "guide_canvas_hint": { "message": "포인트를 드래그해 이퀄라이저를 조정하세요. 포인트 수는 설정에서 변경할 수 있습니다." }, + "guide_volume_hint": { "message": "이 슬라이더로 전체 볼륨을 조절하세요." }, + "guide_presets_hint": { "message": "여기에서 현재 설정을 저장하거나 프리셋을 적용하세요." } } diff --git a/_locales/nl/messages.json b/_locales/nl/messages.json index 7208c44..52690b1 100644 --- a/_locales/nl/messages.json +++ b/_locales/nl/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Er bestaat al een voorinstelling met deze naam." - } + }, + "guide_back": { "message": "Terug" }, + "guide_next": { "message": "Volgende" }, + "guide_skip": { "message": "Overslaan" }, + "guide_finish": { "message": "Voltooien" }, + "guide_theme_dark": { "message": "Donker" }, + "guide_theme_light": { "message": "Licht" }, + "guide_shortcuts_hint": { "message": "Je kunt deze sneltoetsen wijzigen in Instellingen." }, + "guide_canvas_hint": { "message": "Sleep de punten om de equalizer in te stellen. Wijzig het aantal punten in Instellingen." }, + "guide_volume_hint": { "message": "Gebruik deze schuifregelaar om het algemene volume aan te passen." }, + "guide_presets_hint": { "message": "Sla hier de huidige instelling op of pas een preset toe." } } diff --git a/_locales/pt_PT/messages.json b/_locales/pt_PT/messages.json index a8639b0..f235427 100644 --- a/_locales/pt_PT/messages.json +++ b/_locales/pt_PT/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Já existe uma predefinição com este nome." - } + }, + "guide_back": { "message": "Voltar" }, + "guide_next": { "message": "Seguinte" }, + "guide_skip": { "message": "Ignorar" }, + "guide_finish": { "message": "Concluir" }, + "guide_theme_dark": { "message": "Escuro" }, + "guide_theme_light": { "message": "Claro" }, + "guide_shortcuts_hint": { "message": "Pode alterar estes atalhos nas Definições." }, + "guide_canvas_hint": { "message": "Arraste os pontos para ajustar o equalizador. Altere o número de pontos nas Definições." }, + "guide_volume_hint": { "message": "Utilize este controlo para ajustar o volume geral." }, + "guide_presets_hint": { "message": "Guarde a configuração atual ou aplique uma predefinição aqui." } } diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index 064cd58..a38669c 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Пресет с таким названием уже существует." - } + }, + "guide_back": { "message": "Назад" }, + "guide_next": { "message": "Далее" }, + "guide_skip": { "message": "Пропустить" }, + "guide_finish": { "message": "Завершить" }, + "guide_theme_dark": { "message": "Тёмная" }, + "guide_theme_light": { "message": "Светлая" }, + "guide_shortcuts_hint": { "message": "Эти сочетания можно изменить в настройках." }, + "guide_canvas_hint": { "message": "Перетаскивайте точки для настройки эквалайзера. Их количество можно изменить в настройках." }, + "guide_volume_hint": { "message": "Используйте этот слайдер для регулировки общей громкости." }, + "guide_presets_hint": { "message": "Здесь можно сохранить текущую настройку или применить пресет." } } diff --git a/_locales/ta/messages.json b/_locales/ta/messages.json index af9583e..d4c7c98 100644 --- a/_locales/ta/messages.json +++ b/_locales/ta/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "இந்தப் பெயரில் ஒரு முன்னமைவு ஏற்கனவே உள்ளது." - } + }, + "guide_back": { "message": "பின்செல்" }, + "guide_next": { "message": "அடுத்து" }, + "guide_skip": { "message": "தவிர்" }, + "guide_finish": { "message": "முடி" }, + "guide_theme_dark": { "message": "இருண்ட" }, + "guide_theme_light": { "message": "வெளிர்" }, + "guide_shortcuts_hint": { "message": "இந்தக் குறுக்குவழிகளை அமைப்புகளில் மாற்றலாம்." }, + "guide_canvas_hint": { "message": "சமப்படுத்தியை அமைக்க புள்ளிகளை இழுக்கவும். புள்ளிகளின் எண்ணிக்கையை அமைப்புகளில் மாற்றலாம்." }, + "guide_volume_hint": { "message": "மொத்த ஒலியளவை மாற்ற இந்த நகர்த்தியைப் பயன்படுத்தவும்." }, + "guide_presets_hint": { "message": "தற்போதைய அமைப்பைச் சேமிக்கவும் அல்லது முன்அமைவைப் பயன்படுத்தவும்." } } diff --git a/_locales/th/messages.json b/_locales/th/messages.json index 8a19d3c..bb6d82f 100644 --- a/_locales/th/messages.json +++ b/_locales/th/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "มีค่าที่ตั้งไว้ชื่อนี้อยู่แล้ว" - } + }, + "guide_back": { "message": "ย้อนกลับ" }, + "guide_next": { "message": "ถัดไป" }, + "guide_skip": { "message": "ข้าม" }, + "guide_finish": { "message": "เสร็จสิ้น" }, + "guide_theme_dark": { "message": "มืด" }, + "guide_theme_light": { "message": "สว่าง" }, + "guide_shortcuts_hint": { "message": "คุณเปลี่ยนปุ่มลัดเหล่านี้ได้ในการตั้งค่า" }, + "guide_canvas_hint": { "message": "ลากจุดเพื่อปรับอีควอไลเซอร์ และเปลี่ยนจำนวนจุดได้ในการตั้งค่า" }, + "guide_volume_hint": { "message": "ใช้แถบเลื่อนนี้เพื่อปรับระดับเสียงโดยรวม" }, + "guide_presets_hint": { "message": "บันทึกการตั้งค่าปัจจุบันหรือใช้ค่าที่ตั้งไว้ล่วงหน้าได้ที่นี่" } } diff --git a/_locales/tr/messages.json b/_locales/tr/messages.json index eb9ffc2..4e61a12 100644 --- a/_locales/tr/messages.json +++ b/_locales/tr/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Bu ada sahip bir ön ayar zaten var." - } + }, + "guide_back": { "message": "Geri" }, + "guide_next": { "message": "İleri" }, + "guide_skip": { "message": "Atla" }, + "guide_finish": { "message": "Bitir" }, + "guide_theme_dark": { "message": "Koyu" }, + "guide_theme_light": { "message": "Açık" }, + "guide_shortcuts_hint": { "message": "Bu kısayolları Ayarlar bölümünde değiştirebilirsiniz." }, + "guide_canvas_hint": { "message": "Ekolayzırı ayarlamak için noktaları sürükleyin. Nokta sayısını Ayarlar bölümünde değiştirin." }, + "guide_volume_hint": { "message": "Genel ses düzeyini ayarlamak için bu kaydırıcıyı kullanın." }, + "guide_presets_hint": { "message": "Geçerli ayarı kaydedin veya burada bir ön ayar uygulayın." } } diff --git a/_locales/vi/messages.json b/_locales/vi/messages.json index 53d6898..d7865b6 100644 --- a/_locales/vi/messages.json +++ b/_locales/vi/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "Đã có cài đặt sẵn với tên này." - } + }, + "guide_back": { "message": "Quay lại" }, + "guide_next": { "message": "Tiếp theo" }, + "guide_skip": { "message": "Bỏ qua" }, + "guide_finish": { "message": "Hoàn tất" }, + "guide_theme_dark": { "message": "Tối" }, + "guide_theme_light": { "message": "Sáng" }, + "guide_shortcuts_hint": { "message": "Bạn có thể đổi các phím tắt này trong phần Cài đặt." }, + "guide_canvas_hint": { "message": "Kéo các điểm để chỉnh bộ cân bằng. Đổi số lượng điểm trong phần Cài đặt." }, + "guide_volume_hint": { "message": "Dùng thanh trượt này để chỉnh âm lượng tổng thể." }, + "guide_presets_hint": { "message": "Lưu thiết lập hiện tại hoặc áp dụng một cấu hình đặt trước tại đây." } } diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 7360ddd..f882a5e 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -169,5 +169,15 @@ }, "preset_name_duplicate_error": { "message": "已存在同名预设。" - } + }, + "guide_back": { "message": "返回" }, + "guide_next": { "message": "下一步" }, + "guide_skip": { "message": "跳过" }, + "guide_finish": { "message": "完成" }, + "guide_theme_dark": { "message": "深色" }, + "guide_theme_light": { "message": "浅色" }, + "guide_shortcuts_hint": { "message": "你可以在设置中更改这些快捷键。" }, + "guide_canvas_hint": { "message": "拖动控制点以调节均衡器。可在设置中更改控制点数量。" }, + "guide_volume_hint": { "message": "使用此滑块调节整体音量。" }, + "guide_presets_hint": { "message": "在这里保存当前设置或应用预设。" } } diff --git a/src/domains/localization/localizationKeys.test.ts b/src/domains/localization/localizationKeys.test.ts new file mode 100644 index 0000000..116a1bc --- /dev/null +++ b/src/domains/localization/localizationKeys.test.ts @@ -0,0 +1,28 @@ +import { readdirSync, readFileSync } from "node:fs"; + +const localesUrl = new URL("../../../_locales/", import.meta.url); +const guideKeys = [ + "guide_back", + "guide_next", + "guide_skip", + "guide_finish", + "guide_theme_dark", + "guide_theme_light", + "guide_shortcuts_hint", + "guide_canvas_hint", + "guide_volume_hint", + "guide_presets_hint", +] as const; + +test("every locale contains the onboarding guide messages", () => { + for (const entry of readdirSync(localesUrl, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const messages = JSON.parse( + readFileSync(new URL(`${entry.name}/messages.json`, localesUrl), "utf8"), + ) as Record; + + guideKeys.forEach((key) => { + expect(messages[key]?.message, `${entry.name}: ${key}`).toBeTruthy(); + }); + } +}); diff --git a/src/domains/localization/localizationService.test.ts b/src/domains/localization/localizationService.test.ts index 1c9f6c7..a3627da 100644 --- a/src/domains/localization/localizationService.test.ts +++ b/src/domains/localization/localizationService.test.ts @@ -61,4 +61,31 @@ describe("createLocalizationService", () => { expect(chrome.storage.local.set).toHaveBeenCalledWith({ uiLanguage: "ru" }); expect(refreshDynamicContent).toHaveBeenCalledOnce(); }); + + test("localizes theme option labels used by settings and onboarding", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + createResponse({ + guide_theme_dark: { message: "Dark localized" }, + guide_theme_light: { message: "Light localized" }, + }), + ) + .mockResolvedValueOnce(createResponse({})); + const darkOption = { textContent: "" }; + const lightOption = { textContent: "" }; + const root = { + getElementById: vi.fn((id: string) => { + if (id === "theme-dark-option") return darkOption; + if (id === "theme-light-option") return lightOption; + return null; + }), + } as unknown as Document; + const service = createLocalizationService(); + await service.ready; + + service.applyLocalization(root); + + expect(darkOption.textContent).toBe("Dark localized"); + expect(lightOption.textContent).toBe("Light localized"); + }); }); diff --git a/src/domains/localization/localizationService.ts b/src/domains/localization/localizationService.ts index 5e7b53f..3a12e4f 100644 --- a/src/domains/localization/localizationService.ts +++ b/src/domains/localization/localizationService.ts @@ -118,6 +118,8 @@ export const createLocalizationService = (): LocalizationService => { setElementTextContent(root, "points-reset-cancel", "cancel", getMessage); setElementTextContent(root, "points-reset-confirm", "ok", getMessage); setElementTextContent(root, "theme-label", "theme_setting_option", getMessage); + setElementTextContent(root, "theme-dark-option", "guide_theme_dark", getMessage); + setElementTextContent(root, "theme-light-option", "guide_theme_light", getMessage); setElementTextContent(root, "shortcuts-settings-title", "shortcuts_settings_title", getMessage); setElementTextContent(root, "shortcut-mute-label", "shortcut_mute_label", getMessage); setElementTextContent(root, "shortcut-toggle-eq-label", "shortcut_toggle_eq_label", getMessage); From 2058e56744095302d78bfe58429ef2469c7b48d9 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Fri, 17 Jul 2026 20:35:04 +0300 Subject: [PATCH 49/63] fix: polish onboarding guide --- src/app/popup/createPopupApp.ts | 4 +-- src/ui/popup/installUpdateNoticeView.test.ts | 16 ++------- src/ui/popup/installUpdateNoticeView.ts | 11 ++---- src/ui/popup/onboardingGuideView.test.ts | 6 ++++ src/ui/popup/onboardingGuideView.ts | 38 ++++++++++++++------ 5 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/app/popup/createPopupApp.ts b/src/app/popup/createPopupApp.ts index 9e2c895..30c1c71 100644 --- a/src/app/popup/createPopupApp.ts +++ b/src/app/popup/createPopupApp.ts @@ -394,8 +394,6 @@ export const createPopupApp = ({ const installUpdateNoticeView = createInstallUpdateNoticeView({ modal: elements.installUpdateNoticeModal, closeButton: elements.installUpdateNoticeClose, - currentVersion: chrome.runtime.getManifest().version, - isToolkitWindow: toolkitController.isToolkitWindow, }); const onboardingGuideView = createOnboardingGuideView({ @@ -592,7 +590,7 @@ export const createPopupApp = ({ if (pendingNotice?.reason === "install") { await onboardingGuideView.start(); } else { - installUpdateNoticeView.showInstallUpdateNotice(stored); + installUpdateNoticeView.showInstallUpdateNotice(pendingNotice); } await toolkitController.startTabCapture(); await toolkitController.renderCapturedTabs(); diff --git a/src/ui/popup/installUpdateNoticeView.test.ts b/src/ui/popup/installUpdateNoticeView.test.ts index 7d1156a..144dff4 100644 --- a/src/ui/popup/installUpdateNoticeView.test.ts +++ b/src/ui/popup/installUpdateNoticeView.test.ts @@ -51,24 +51,12 @@ describe("getPendingInstallUpdateNotice", () => { const view = createInstallUpdateNoticeView({ modal, closeButton, - currentVersion: "1.8.0", - isToolkitWindow: false, }); - view.showInstallUpdateNotice({ - [STORAGE_KEYS.INSTALL_UPDATE_NOTICE]: { - reason: "install", - version: "1.8.0", - }, - }); + view.showInstallUpdateNotice({ reason: "install", version: "1.8.0" }); expect(modal.style.display).toBe("none"); - view.showInstallUpdateNotice({ - [STORAGE_KEYS.INSTALL_UPDATE_NOTICE]: { - reason: "update", - version: "1.8.0", - }, - }); + view.showInstallUpdateNotice({ reason: "update", version: "1.8.0" }); expect(modal.style.display).toBe("block"); }); }); diff --git a/src/ui/popup/installUpdateNoticeView.ts b/src/ui/popup/installUpdateNoticeView.ts index 3d536b5..a3b7fc1 100644 --- a/src/ui/popup/installUpdateNoticeView.ts +++ b/src/ui/popup/installUpdateNoticeView.ts @@ -12,7 +12,7 @@ export interface PendingInstallUpdateNoticeOptions { } export interface InstallUpdateNoticeView { - showInstallUpdateNotice(stored: Record): void; + showInstallUpdateNotice(notice: InstallUpdateNotice | null): void; } export const getPendingInstallUpdateNotice = ({ @@ -39,8 +39,6 @@ export const getPendingInstallUpdateNotice = ({ export const createInstallUpdateNoticeView = (deps: { modal: HTMLElement; closeButton: HTMLElement; - currentVersion: string; - isToolkitWindow: boolean; }): InstallUpdateNoticeView => { const closeInstallUpdateNotice = async (): Promise => { deps.modal.style.display = "none"; @@ -52,12 +50,7 @@ export const createInstallUpdateNoticeView = (deps: { }); return { - showInstallUpdateNotice: (stored) => { - const notice = getPendingInstallUpdateNotice({ - stored, - currentVersion: deps.currentVersion, - isToolkitWindow: deps.isToolkitWindow, - }); + showInstallUpdateNotice: (notice) => { if (notice?.reason !== "update") return; deps.modal.style.display = "block"; diff --git a/src/ui/popup/onboardingGuideView.test.ts b/src/ui/popup/onboardingGuideView.test.ts index 6519490..a763c01 100644 --- a/src/ui/popup/onboardingGuideView.test.ts +++ b/src/ui/popup/onboardingGuideView.test.ts @@ -1,6 +1,7 @@ import { GUIDE_SCREENS, getGuideNavigation, + getNextFocusIndex, getSpotlightPanels, } from "./onboardingGuideView"; @@ -19,6 +20,11 @@ describe("onboarding guide navigation", () => { }); }); +test("moves backward from the dialog title to the last control", () => { + expect(getNextFocusIndex(-1, 3, true)).toBe(2); + expect(getNextFocusIndex(2, 3, false)).toBe(0); +}); + test("splits the viewport into four panels around the target", () => { expect( getSpotlightPanels( diff --git a/src/ui/popup/onboardingGuideView.ts b/src/ui/popup/onboardingGuideView.ts index 9bc5414..1ceb541 100644 --- a/src/ui/popup/onboardingGuideView.ts +++ b/src/ui/popup/onboardingGuideView.ts @@ -136,6 +136,18 @@ export const getSpotlightPanels = ( }, }); +export const getNextFocusIndex = ( + currentIndex: number, + length: number, + backwards: boolean, +): number => { + if (length <= 0) return -1; + if (currentIndex < 0) return backwards ? length - 1 : 0; + return backwards + ? (currentIndex - 1 + length) % length + : (currentIndex + 1) % length; +}; + export interface OnboardingGuideView { start(): Promise; } @@ -195,6 +207,7 @@ export const createOnboardingGuideView = (deps: { select.removeAttribute("id"); select.value = source.value; select.addEventListener("change", () => { + source.value = select.value; void onChange(select.value).then(() => render()); }); field.append(label, select); @@ -229,13 +242,20 @@ export const createOnboardingGuideView = (deps: { return; } if (screen.kind === "shortcuts") { - const mute = document.createElement("div"); - mute.className = "guide-shortcut"; - mute.innerHTML = `${deps.getMessage("shortcut_mute_label")}Alt+M`; - const toggle = document.createElement("div"); - toggle.className = "guide-shortcut"; - toggle.innerHTML = `${deps.getMessage("shortcut_toggle_eq_label")}Alt+K`; - content.append(mute, toggle); + const createShortcut = (messageName: string, keys: string): HTMLElement => { + const row = document.createElement("div"); + row.className = "guide-shortcut"; + const label = document.createElement("span"); + label.textContent = deps.getMessage(messageName); + const shortcut = document.createElement("kbd"); + shortcut.textContent = keys; + row.append(label, shortcut); + return row; + }; + content.append( + createShortcut("shortcut_mute_label", "Alt+M"), + createShortcut("shortcut_toggle_eq_label", "Alt+K"), + ); } if (screen.messageKey) { const message = document.createElement("p"); @@ -348,9 +368,7 @@ export const createOnboardingGuideView = (deps: { ); if (controls.length === 0) return; const current = controls.indexOf(document.activeElement as typeof controls[number]); - const next = event.shiftKey - ? (current - 1 + controls.length) % controls.length - : (current + 1) % controls.length; + const next = getNextFocusIndex(current, controls.length, event.shiftKey); event.preventDefault(); controls[next].focus(); }); From 8e3a0bee78858613900f456f0ea71f4986aef976 Mon Sep 17 00:00:00 2001 From: Maitrog Date: Sun, 19 Jul 2026 19:49:41 +0300 Subject: [PATCH 50/63] fix: onbording style fixes --- popup.html | 1 - resources/styles/popup.css | 6 +----- src/ui/popup/onboardingGuideView.ts | 6 +----- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/popup.html b/popup.html index 99b629d..db654a1 100644 --- a/popup.html +++ b/popup.html @@ -253,7 +253,6 @@

Patch Notes 1.8.0