Prepare v1.11.4 release and build updates

Switch Chrome extension packaging to .zip in CI and release workflows and add 1.11.4 distribution artifacts (chrome .zip, firefox .xpi, updated user scripts). Large source changes include a refactor/extension of the subtitles subsystem (new inlineStyle, activeCues, textSpacing, etc.), new video lifecycle host and auth refresh messaging, added background notifications/storage and bridge XHR/runtime modules, translation playback and smart-ducking runtime modules, multiple type additions and a Node crypto shim, plus numerous UI and localization updates. Also update build and Vite configs and other miscellaneous fixes across the codebase to prepare the release.
This commit is contained in:
NullVerdict 2026-03-25 23:48:02 +04:00
parent c9fa3d4c6f
commit 5a9151080b
171 changed files with 35969 additions and 36233 deletions

View file

@ -56,7 +56,7 @@ jobs:
with:
name: vot-extension-${{ matrix.node-version }}
path: |
dist-ext/vot-extension-chrome-*.crx
dist-ext/vot-extension-chrome-*.zip
dist-ext/vot-extension-firefox-*.xpi
bun:

View file

@ -33,5 +33,5 @@ jobs:
prerelease: false
files: |
vot/vot*.user.js
vot/vot-extension-chrome-*.crx
vot/vot-extension-chrome-*.zip
vot/vot-extension-firefox-*.xpi

View file

@ -6,11 +6,7 @@
"useIgnoreFile": false
},
"files": {
"includes": [
"**",
"!**/dist/**/*.js",
"!**/*.d.ts"
]
"includes": ["**", "!**/dist/**/*.js", "!**/*.d.ts"]
},
"formatter": {
"enabled": true,

281
bun.lock
View file

@ -14,140 +14,75 @@
},
"devDependencies": {
"@toil/translate": "^1.0.8",
"@types/bun": "^1.3.9",
"@types/bun": "^1.3.11",
"@vot.js/core": "^2.4.12",
"crx3": "^2.0.0",
"lefthook": "^2.1.1",
"lightningcss": "^1.31.1",
"lefthook": "^2.1.4",
"lightningcss": "^1.32.0",
"npm-run-all2": "^8.0.4",
"sass": "^1.97.3",
"sass": "^1.98.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-monkey": "^7.1.9",
"zip-a-folder": "^4.0.4",
"vite": "^8.0.1",
"zip-a-folder": "^6.1.0",
},
},
},
"packages": {
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.2.3", "", {}, "sha512-tFQoXHJdkEOSwj5tRIZSPNUuXK3RaR7T1nUrPgbYX1pUbvqqaaZAsfo+NXBPsz5rZMSKVFrgK1WL8Q/MSLvprg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"@parcel/watcher": ["@parcel/watcher@2.5.0", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-win32-x64": "2.5.0" } }, ""],
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, ""],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
"@toil/gm-types": ["@toil/gm-types@1.0.4", "", { "peerDependencies": { "typescript": "^5.8.2" } }, "sha512-DbqZsYYrVXaBB4usDN3/HbkIhk2M+ECKv06lEScSoCMJUZ1qbtpbYLIqioEk2GE9adwWjNpH+y/Bf0ij3wQxhw=="],
"@toil/translate": ["@toil/translate@1.0.8", "", { "peerDependencies": { "typescript": "^5.7.3" } }, "sha512-C4fCwQ6KPWxj5M2+ZCKukVUWLVVXS5WgjIhr1wMAwB4WSNNtPEqDWdjFlwMsreMGBMqLWWwuMXTxhL0tVLr8og=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, ""],
@ -159,160 +94,100 @@
"@vot.js/shared": ["@vot.js/shared@2.4.12", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-XdMEXzLhKxKjCIkH9Lfy5MVmHz2WYcAcPTUZFN/kyJ6ErcBKsEPwDN7MLQDuD7uEXvCeUSPcbWM2i9b8R0A4dQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, ""],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, ""],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, ""],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"chaimu": ["chaimu@1.0.6", "", {}, "sha512-9PLbKaD1+RLc+BzLoQzSZ57rmuy0yKQ+5yrfHSwDS9CNkMvn8VZJDXiqPio8Ic7gSA5yiECpal8+4rJwQGBXiQ=="],
"chokidar": ["chokidar@4.0.1", "", { "dependencies": { "readdirp": "^4.0.1" } }, ""],
"concat-map": ["concat-map@0.0.1", "", {}, ""],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crx3": ["crx3@2.0.0", "", { "dependencies": { "mri": "^1.2.0", "pbf": "^4.0.1", "yazl": "^3.3.1" }, "bin": { "crx3": "bin/crx3.js" } }, "sha512-f23Oi2Zpl68aBSf5gHwn+lxQyPF+m2NAhMwwycXOxqOx6bpzDqzbcp6k/DRsyHxpsDvg5WwXcHOJSOgJ7Px5LQ=="],
"cuint": ["cuint@0.2.2", "", {}, "sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw=="],
"default-browser": ["default-browser@5.2.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, ""],
"default-browser-id": ["default-browser-id@5.0.0", "", {}, ""],
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, ""],
"detect-libc": ["detect-libc@2.0.3", "", {}, "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"glob": ["glob@12.0.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"immutable": ["immutable@5.0.3", "", {}, "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw=="],
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": "cli.js" }, ""],
"immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, ""],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, ""],
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": "cli.js" }, ""],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, ""],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@4.0.0", "", {}, "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA=="],
"lefthook": ["lefthook@2.1.1", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.1", "lefthook-darwin-x64": "2.1.1", "lefthook-freebsd-arm64": "2.1.1", "lefthook-freebsd-x64": "2.1.1", "lefthook-linux-arm64": "2.1.1", "lefthook-linux-x64": "2.1.1", "lefthook-openbsd-arm64": "2.1.1", "lefthook-openbsd-x64": "2.1.1", "lefthook-windows-arm64": "2.1.1", "lefthook-windows-x64": "2.1.1" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-Tl9h9c+sG3ShzTHKuR3LAIblnnh+Mgxnm2Ul7yu9cu260Z27LEbO3V6Zw4YZFP59/2rlD42pt/llYsQCkkCFzw=="],
"lefthook": ["lefthook@2.1.4", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.4", "lefthook-darwin-x64": "2.1.4", "lefthook-freebsd-arm64": "2.1.4", "lefthook-freebsd-x64": "2.1.4", "lefthook-linux-arm64": "2.1.4", "lefthook-linux-x64": "2.1.4", "lefthook-openbsd-arm64": "2.1.4", "lefthook-openbsd-x64": "2.1.4", "lefthook-windows-arm64": "2.1.4", "lefthook-windows-x64": "2.1.4" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-JNfJ5gAn0KADvJ1I6/xMcx70+/6TL6U9gqGkKvPw5RNMfatC7jIg0Evl97HN846xmfz959BV70l8r3QsBJk30w=="],
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-O/RS1j03/Fnq5zCzEb2r7UOBsqPeBuf1C5pMkIJcO4TSE6hf3rhLUkcorKc2M5ni/n5zLGtzQUXHV08/fSAT3Q=="],
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw=="],
"lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-mm/kdKl81ROPoYnj9XYk5JDqj+/6Al8w/SSPDfhItkLJyl4pqS+hWUOP6gDGrnuRk8S0DvJ2+hzhnDsQnZohWQ=="],
"lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-K1ncIMEe84fe+ss1hQNO7rIvqiKy2TJvTFpkypvqFodT7mJXZn7GLKYTIXdIuyPAYthRa9DwFnx5uMoHwD2F1Q=="],
"lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-F7JXlKmjxGqGbCWPLND0bVB4DMQezIe48pEwTlUQZbxh450c2gP5Q8FdttMZKOT163kBGGTqJAJSEC6zW+QSxA=="],
"lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-PVUhjOhVN71YaYsVdQyNbFZ4a2jFB2Tg5hKrrn9kaWpx64aLz/XivLjwr8sEuTaP1GRlEWBpW6Bhrcsyo39qFw=="],
"lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Po8/lJMqNzKSZPuEI46dLuWoBoXtAxCuRpeOh6DAV/M4RhBynaCu8rLMZ9BqF7cVbZEWoplOmYo6HdOuiYpCkQ=="],
"lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZWV9o/LeyWNEBoVO+BhLqxH3rGTba05nkm5NvMjEFSj7LbUNUDbQmupZwtHl1OMGJO66eZP0CalzRfUH6GhBxQ=="],
"lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mI2ljFgPEqHxI8vrN9nKgnVu63Rz1KisDbPwlvs7BTYNwq3sncdK5ukpGR4zzWdh6saNJ5tCtHEtep5GQI11nw=="],
"lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iWN0pGnTjrIvNIcSI1vQBJXUbybTqJ5CLMniPA0olabMXQfPDrdMKVQe+mgdwHK+E3/Y0H0ZNL3lnOj6Sk6szA=="],
"lefthook-linux-x64": ["lefthook-linux-x64@2.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-m3G/FaxC+crxeg9XeaUuHfEoL+i9gbkg2Hp2KD2IcVVIxprqlyqf0Hb8zbLV2NMXuo5RSGokJu44oAoTO3Ou2g=="],
"lefthook-linux-x64": ["lefthook-linux-x64@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-96bTBE/JdYgqWYAJDh+/e/0MaxJ25XTOAk7iy/fKoZ1ugf6S0W9bEFbnCFNooXOcxNVTan5xWKfcjJmPIKtsJA=="],
"lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-gz/8FJPvhjOdOFt1GmFvuvDOe+W+BBRjoeAT1/mTgkN7HCXMXgqNjjvakQKQeGz1I1v08wXG1ZNf5y+T9XBCDQ=="],
"lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-oYUoK6AIJNEr9lUSpIMj6g7sWzotvtc3ryw7yoOyQM6uqmEduw73URV/qGoUcm4nqqmR93ZalZwR2r3Gd61zvw=="],
"lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-ch3lyMUtbmtWUufaQVn4IoEs/2hjK51XqaCdY1mh5ca//VctR1peknIwQ5feHu+vATCDviWQ7HsdNDewm3HMPg=="],
"lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/Dv9Jcm68y9cggr1PhyUhOabBGP9+hzQPoiyOhKks7y9qrJl79A8XfG6LHekSuYc2VpiSu5wdnnrE1cj2nfTg=="],
"lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mm3PZhKDs9FE/jQDimkfWxtoj9xQ2k8uw2MdhtC825bhvIh+MEi0WFj/MOW+ug0RBg0I55tGYzZ5aVuozAWpTQ=="],
"lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-hSww7z+QX4YMnw2lK7DMrs3+w7NtxksuMKOkCKGyxUAC/0m1LAICo0ZbtdDtZ7agxRQQQ/SEbzFRhU5ysNcbjA=="],
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-1L2oGIzmhfOTxfwbe5mpSQ+m3ilpvGNymwIhn4UHq6hwHsUL6HEhODqx02GfBn6OXpVIr56bvdBAusjL/SVYGQ=="],
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-eE68LwnogxwcPgGsbVGPGxmghyMGmU9SdGwcc+uhGnUxPz1jL89oECMWJNc36zjVK24umNeDAzB5KA3lw1MuWw=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "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" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lit-html": ["lit-html@3.3.2", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw=="],
"lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"make-dir": ["make-dir@3.1.0", "", { "dependencies": { "semver": "^6.0.0" } }, "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw=="],
"lzma": ["lzma@2.3.2", "", { "bin": { "lzma.js": "bin/lzma.js" } }, "sha512-DcfiawQ1avYbW+hsILhF38IKAlnguc/fjHrychs9hdxe4qLykvhT5VTGNs5YRWgaNePh7NTxGD4uv4gKsRomCQ=="],
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime": ["mime@2.5.2", "", { "bin": { "mime": "cli.js" } }, "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg=="],
"minimatch": ["minimatch@3.0.8", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, ""],
@ -321,14 +196,8 @@
"npm-run-all2": ["npm-run-all2@8.0.4", "", { "dependencies": { "ansi-styles": "^6.2.1", "cross-spawn": "^7.0.6", "memorystream": "^0.3.1", "picomatch": "^4.0.2", "pidtree": "^0.6.0", "read-package-json-fast": "^4.0.0", "shell-quote": "^1.7.3", "which": "^5.0.0" }, "bin": { "run-p": "bin/run-p/index.js", "run-s": "bin/run-s/index.js", "npm-run-all": "bin/npm-run-all/index.js", "npm-run-all2": "bin/npm-run-all/index.js" } }, "sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA=="],
"open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-key": ["path-key@3.1.1", "", {}, ""],
"path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
"pbf": ["pbf@4.0.1", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA=="],
"picocolors": ["picocolors@1.1.1", "", {}, ""],
@ -337,9 +206,7 @@
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-url": ["postcss-url@10.1.3", "", { "dependencies": { "make-dir": "~3.1.0", "mime": "~2.5.2", "minimatch": "~3.0.4", "xxhashjs": "~0.2.2" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="],
@ -349,13 +216,9 @@
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
"run-applescript": ["run-applescript@7.0.0", "", {}, ""],
"sass": ["sass@1.97.3", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"sass": ["sass@1.98.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, ""],
@ -363,50 +226,32 @@
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map-js": ["source-map-js@1.0.2", "", {}, ""],
"systemjs": ["systemjs@6.15.1", "", {}, "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@5.26.5", "", {}, ""],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-plugin-monkey": ["vite-plugin-monkey@7.1.9", "", { "dependencies": { "acorn": "^8.15.0", "acorn-walk": "^8.3.4", "cross-spawn": "^7.0.6", "htmlparser2": "^10.0.0", "import-meta-resolve": "^4.1.0", "magic-string": "^0.30.17", "mrmime": "^2.0.1", "open": "^10.2.0", "picocolors": "^1.1.1", "postcss-url": "^10.1.3", "systemjs": "^6.15.1" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["vite"] }, "sha512-kYtcRH8DsbrwfugrviYRuk50weY5yyVKcuGRkjUSc7mK0uPFtOHK8sUSEecpiS9725IF/4uyum8bYHs7dswDBg=="],
"vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.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" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"xxhashjs": ["xxhashjs@0.2.2", "", { "dependencies": { "cuint": "^0.2.2" } }, "sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw=="],
"yazl": ["yazl@3.3.1", "", { "dependencies": { "buffer-crc32": "^1.0.0" } }, "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng=="],
"zip-a-folder": ["zip-a-folder@4.0.4", "", { "dependencies": { "glob": "^12.0.0" } }, "sha512-sVqxEtf6cTMrdnw7XCS5ktV3h9fRSrVZ78h9ehrtcVs5hQ0th5dyT5NCH/7WAQQC3IkKFHWNvamY06JRCNHU5g=="],
"zip-a-folder": ["zip-a-folder@6.1.0", "", { "dependencies": { "lzma": "^2.3.2", "tinyglobby": "^0.2.15" }, "bin": { "zip-a-folder": "dist/cli.mjs" } }, "sha512-c/warR1v5M19y0EOov8P25NunZu37Un8ha7CQbylUm+3dO0hpS9dg/fR8MKb8bUTwCyCgfVeRdx6D05NNKjl9g=="],
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": "bin/detect-libc.js" }, ""],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, ""],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"glob/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, ""],
"postcss/source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, ""],
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="],
}
}

View file

@ -1,4 +1,14 @@
# 1.11.3
# 1.11.4
- Добавлена поддержка Mediafile Cloud (#1603), Jove (#1593), Datacamp (#1606), PreserveTube (#1366)
- Добавлена настройка "Язык субтитров по умолчанию": можно выбрать автоопределение, язык оригинального видео или конкретный язык
- Улучшено отображение субтитров: корректнее объединяются одновременно активные реплики, сохранены пробелы и пунктуация при переносах, улучшено разбиение длинных фраз
- Улучшено восстановление перевода после паузы/возобновления видео: если ссылка на аудиодорожку устарела, перевод автоматически обновляется
- Исправлена синхронизация громкости перевода и видео: устранен дрейф, корректно обрабатываются верхние границы и ручное изменение громкости (#1605)
- Улучшена авторизация: данные аккаунта в настройках теперь обновляются автоматически после входа без ручного обновления
- Исправлена некорректная работа на Udemy (#1617)
# 1.11.3
- Исправлена ошибка, из-за которой UI исчезал в полноэкранном режиме
- Улучшена обработка auto-generated субтитров YouTube: реплики объединяются в более цельные фразы и предложения

Binary file not shown.

Binary file not shown.

View file

@ -3,8 +3,8 @@
"vot-extension@firefox": {
"updates": [
{
"version": "1.11.3",
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox-1.11.3.xpi"
"version": "1.11.4",
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox-1.11.4.xpi"
}
]
}

668
dist/vot-min.user.js vendored

File diff suppressed because one or more lines are too long

53373
dist/vot.user.js vendored

File diff suppressed because one or more lines are too long

View file

@ -16,17 +16,16 @@
],
"devDependencies": {
"@toil/translate": "^1.0.8",
"@types/bun": "^1.3.9",
"@types/bun": "^1.3.11",
"@vot.js/core": "^2.4.12",
"sass": "^1.98.0",
"crx3": "^2.0.0",
"lefthook": "^2.1.1",
"lightningcss": "^1.31.1",
"lefthook": "^2.1.4",
"lightningcss": "^1.32.0",
"npm-run-all2": "^8.0.4",
"sass": "^1.97.3",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-monkey": "^7.1.9",
"zip-a-folder": "^4.0.4"
"vite": "^8.0.1",
"zip-a-folder": "^6.1.0"
},
"scripts": {
"test": "bun test",
@ -35,12 +34,12 @@
"test:ui": "vite build --config vite.test-ui.config.ts",
"build": "vite build --config vite.config.ts",
"build:min": "vite build --mode minify --config vite.config.ts",
"build:all": "npx run-p build build:min",
"build:all": "run-p build build:min",
"build:ext": "vite build --config vite.extension.config.ts",
"build:chrome": "vite build --mode chrome --config vite.extension.config.ts",
"build:firefox": "vite build --mode firefox --config vite.extension.config.ts",
"build:dev": "vite build --mode development --config vite.config.ts",
"dev": "vite dev",
"dev": "vite build --watch --mode development --config vite.config.ts",
"format": "bunx @biomejs/biome check --write --unsafe ./src",
"gen:wiki": "bun run ./scripts/wiki-gen/index.js",
"localize": "bunx \"@toil/localize-tui\""

View file

@ -321,4 +321,4 @@ const extraData = {
const sitesBlackList = ["porntn"];
export { siteData, extraData, sitesBlackList };
export { extraData, siteData, sitesBlackList };

View file

@ -43,9 +43,9 @@ function normalizeDomain(domain) {
return domain
.trim()
.toLowerCase()
.replaceAll(String.raw`\.`,".")
.replaceAll(String.raw`\-`,"-")
.replaceAll(String.raw`\/`,"/")
.replaceAll(String.raw`\.`, ".")
.replaceAll(String.raw`\-`, "-")
.replaceAll(String.raw`\/`, "/")
.replace(/^https?:\/\//, "")
.replace(/\/.*$/, "")
.replaceAll(/[()]/g, "")
@ -152,27 +152,13 @@ function parseCharClass(regexSource, startIndex) {
}
if (char === "\\") {
const escaped = regexSource[index + 1];
if (!escaped) {
canExpand = false;
index += 1;
continue;
}
if ("dDsSwW".includes(escaped)) {
canExpand = false;
} else {
chars.push(escaped);
}
index += 2;
const escapedResult = parseCharClassEscape(regexSource, index, chars);
canExpand = canExpand && escapedResult.canExpand;
index = escapedResult.index;
continue;
}
if (
regexSource[index + 1] === "-" &&
regexSource[index + 2] &&
regexSource[index + 2] !== "]"
) {
if (isCharClassRange(regexSource, index)) {
canExpand = false;
index += 3;
continue;
@ -189,6 +175,61 @@ function parseCharClass(regexSource, startIndex) {
return { variants: unique(chars), index };
}
function parseCharClassEscape(regexSource, index, chars) {
const escaped = regexSource[index + 1];
if (!escaped) {
return {
canExpand: false,
index: index + 1,
};
}
if ("dDsSwW".includes(escaped)) {
return {
canExpand: false,
index: index + 2,
};
}
chars.push(escaped);
return {
canExpand: true,
index: index + 2,
};
}
function isCharClassRange(regexSource, index) {
return (
regexSource[index + 1] === "-" &&
regexSource[index + 2] &&
regexSource[index + 2] !== "]"
);
}
function consumeLazySuffix(regexSource, index) {
if (regexSource[index] === "?") {
return index + 1;
}
return index;
}
function buildSimpleQuantifierResult(startIndex, min, max, regexSource) {
return {
min,
max,
index: consumeLazySuffix(regexSource, startIndex + 1),
};
}
function buildRangeQuantifierResult(closeIndex, min, max, regexSource) {
return {
min,
max,
index: consumeLazySuffix(regexSource, closeIndex + 1),
};
}
function parseQuantifier(regexSource, startIndex) {
if (startIndex >= regexSource.length) {
return null;
@ -196,27 +237,25 @@ function parseQuantifier(regexSource, startIndex) {
const quantifier = regexSource[startIndex];
if (quantifier === "?") {
let index = startIndex + 1;
if (regexSource[index] === "?") {
index += 1;
}
return { min: 0, max: 1, index };
return buildSimpleQuantifierResult(startIndex, 0, 1, regexSource);
}
if (quantifier === "+") {
let index = startIndex + 1;
if (regexSource[index] === "?") {
index += 1;
}
return { min: 1, max: Number.POSITIVE_INFINITY, index };
return buildSimpleQuantifierResult(
startIndex,
1,
Number.POSITIVE_INFINITY,
regexSource,
);
}
if (quantifier === "*") {
let index = startIndex + 1;
if (regexSource[index] === "?") {
index += 1;
}
return { min: 0, max: Number.POSITIVE_INFINITY, index };
return buildSimpleQuantifierResult(
startIndex,
0,
Number.POSITIVE_INFINITY,
regexSource,
);
}
if (quantifier !== "{") {
@ -234,11 +273,7 @@ function parseQuantifier(regexSource, startIndex) {
if (exactMatch) {
const value = Number(exactMatch[1]);
let index = closeIndex + 1;
if (regexSource[index] === "?") {
index += 1;
}
return { min: value, max: value, index };
return buildRangeQuantifierResult(closeIndex, value, value, regexSource);
}
if (rangeMatch) {
@ -247,11 +282,7 @@ function parseQuantifier(regexSource, startIndex) {
rangeMatch[2] === undefined
? Number.POSITIVE_INFINITY
: Number(rangeMatch[2]);
let index = closeIndex + 1;
if (regexSource[index] === "?") {
index += 1;
}
return { min, max, index };
return buildRangeQuantifierResult(closeIndex, min, max, regexSource);
}
return null;
@ -323,20 +354,44 @@ function parseSequence(regexSource, startIndex) {
return { variants, index };
}
function parseEscapedAtom(regexSource, startIndex) {
const escaped = regexSource[startIndex + 1];
if (!escaped) {
return { variants: [""], index: startIndex + 1 };
}
if ("dDsSwW".includes(escaped)) {
return { variants: ["*"], index: startIndex + 2 };
}
return { variants: [escaped], index: startIndex + 2 };
}
function parseGroupAtom(regexSource, startIndex) {
let index = startIndex + 1;
if (regexSource[index] === "?" && regexSource[index + 1] === ":") {
index += 2;
} else if (regexSource[index] === "?") {
const closeIndex = findGroupEnd(regexSource, startIndex);
return {
variants: ["*"],
index: closeIndex === -1 ? regexSource.length : closeIndex + 1,
};
}
const parsedGroup = parseExpression(regexSource, index);
if (regexSource[parsedGroup.index] !== ")") {
return { variants: ["*"], index: parsedGroup.index };
}
return { variants: parsedGroup.variants, index: parsedGroup.index + 1 };
}
function parseAtom(regexSource, startIndex) {
const char = regexSource[startIndex];
if (char === "\\") {
const escaped = regexSource[startIndex + 1];
if (!escaped) {
return { variants: [""], index: startIndex + 1 };
}
if ("dDsSwW".includes(escaped)) {
return { variants: ["*"], index: startIndex + 2 };
}
return { variants: [escaped], index: startIndex + 2 };
return parseEscapedAtom(regexSource, startIndex);
}
if (char === "[") {
@ -344,25 +399,13 @@ function parseAtom(regexSource, startIndex) {
}
if (char === "(") {
let index = startIndex + 1;
if (regexSource[index] === "?" && regexSource[index + 1] === ":") {
index += 2;
} else if (regexSource[index] === "?") {
const closeIndex = findGroupEnd(regexSource, startIndex);
return {
variants: ["*"],
index: closeIndex === -1 ? regexSource.length : closeIndex + 1,
};
}
const parsedGroup = parseExpression(regexSource, index);
if (regexSource[parsedGroup.index] !== ")") {
return { variants: ["*"], index: parsedGroup.index };
}
return { variants: parsedGroup.variants, index: parsedGroup.index + 1 };
return parseGroupAtom(regexSource, startIndex);
}
return parseLiteralAtom(char, startIndex);
}
function parseLiteralAtom(char, startIndex) {
if (char === ".") {
return { variants: ["."], index: startIndex + 1 };
}
@ -420,7 +463,9 @@ function extractDomainsFromRegex(regex) {
const parsed = parseExpression(source, 0);
const variants = parsed.variants.length ? parsed.variants : [source];
return unique(variants.map((domain) => normalizeDomain(domain)).filter(Boolean));
return unique(
variants.map((domain) => normalizeDomain(domain)).filter(Boolean),
);
}
function parseRegexLiteral(literal) {
@ -510,7 +555,8 @@ function mergeByHost(supportedSites) {
domains: new Set(),
};
existing.needBypassCSP = existing.needBypassCSP || Boolean(site.needBypassCSP);
existing.needBypassCSP =
existing.needBypassCSP || Boolean(site.needBypassCSP);
for (const domain of extractDomainsFromMatch(site.match)) {
existing.domains.add(domain);
}
@ -564,7 +610,9 @@ ${locales.availabledDomains[lang]}:
}
function genMarkdown(supportedSites, lang = "ru") {
return mergeByHost(supportedSites).map((site) => renderSiteMarkdown(site, lang));
return mergeByHost(supportedSites).map((site) =>
renderSiteMarkdown(site, lang),
);
}
function getSupportedSites() {
@ -576,7 +624,9 @@ function getSupportedSites() {
host,
match: host === "custom" ? "any" : site.match,
status: hasExtraData ? extraData[host].status : "✅",
statusPhrase: hasExtraData ? extraData[host].statusPhrase : locales.working,
statusPhrase: hasExtraData
? extraData[host].statusPhrase
: locales.working,
needBypassCSP: site.needBypassCSP,
};
});

View file

@ -1 +1,14 @@
export * from "./src/AudioDownloader";
export type {
AudioBufferResult,
AudioChunkStreamOptions,
AudioChunkStreamResult,
AudioDownloaderOptions,
AudioStreamRequest,
AudioStreamResult,
} from "./src/AudioDownloader";
export {
AudioDownloader,
buildClientAttemptOrder,
extractVideoId,
YtWatchContextForbiddenError,
} from "./src/AudioDownloader";

View file

@ -206,35 +206,41 @@ export function extractVideoId(input: string): string {
const hostname = url.hostname.toLowerCase();
if (hostname === "youtu.be" || hostname.endsWith(".youtu.be")) {
const id = url.pathname.split("/").find(Boolean);
return getValidatedVideoId(url.pathname.split("/").find(Boolean), input);
}
const searchId = url.searchParams.get("v");
if (searchId && VIDEO_ID_PATTERN.test(searchId)) return searchId;
const pathSegments = url.pathname.split("/").filter(Boolean);
const pathId = getVideoIdFromPathSegments(pathSegments);
if (pathId) return pathId;
throw new Error(`Cannot extract YouTube video id from: ${input}`);
}
function getValidatedVideoId(id: string | undefined, input: string): string {
if (id && VIDEO_ID_PATTERN.test(id)) {
return id;
}
throw new Error(`Cannot extract YouTube video id from: ${input}`);
}
function getVideoIdFromPathSegments(pathSegments: string[]): string | null {
const pathMarkers = ["shorts", "embed"] as const;
for (const marker of pathMarkers) {
const markerIndex = pathSegments.indexOf(marker);
if (markerIndex === -1) continue;
const id = pathSegments[markerIndex + 1];
if (id && VIDEO_ID_PATTERN.test(id)) {
return id;
}
}
const searchId = url.searchParams.get("v");
if (searchId && VIDEO_ID_PATTERN.test(searchId)) {
return searchId;
}
const pathSegments = url.pathname.split("/").filter(Boolean);
const shortsIndex = pathSegments.indexOf("shorts");
if (shortsIndex !== -1) {
const shortsId = pathSegments[shortsIndex + 1];
if (shortsId && VIDEO_ID_PATTERN.test(shortsId)) {
return shortsId;
}
}
const embedIndex = pathSegments.indexOf("embed");
if (embedIndex !== -1) {
const embedId = pathSegments[embedIndex + 1];
if (embedId && VIDEO_ID_PATTERN.test(embedId)) {
return embedId;
}
}
throw new Error(`Cannot extract YouTube video id from: ${input}`);
return null;
}
function decodeEscapedJsonString(input: string): string {

View file

@ -54,7 +54,7 @@ export function extractAudioCodecFromMimeType(
);
}
export function pickByBitrate<T extends ProgressiveFormatCandidate>(
function pickByBitrate<T extends ProgressiveFormatCandidate>(
formats: readonly T[],
direction: "max" | "min",
): T | null {

View file

@ -1,489 +0,0 @@
interface AudioChunkSink {
write(chunk: Uint8Array): Promise<void>;
}
interface AacExtractionOptions {
codecHint?: string;
sampleRateHint?: number;
channelsHint?: number;
}
interface AacExtractionResult {
codec: string;
sampleRate: number;
channels: number;
bytesWritten: number;
}
interface Box {
type: string;
start: number;
size: number;
end: number;
payloadStart: number;
payloadEnd: number;
}
interface StscEntry {
firstChunk: number;
samplesPerChunk: number;
}
interface AudioTrackTables {
sampleSizes: number[];
chunkOffsets: number[];
sampleToChunk: StscEntry[];
}
interface AdtsConfig {
profile: number;
sampleRateIndex: number;
channelConfig: number;
}
const AAC_SAMPLE_RATES = [
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025,
8000, 7350,
] as const;
function readUint64(view: DataView, offset: number): number {
const high = view.getUint32(offset, false);
const low = view.getUint32(offset + 4, false);
const value = high * 2 ** 32 + low;
if (!Number.isSafeInteger(value)) {
throw new TypeError(
"Encountered 64-bit MP4 offset larger than Number.MAX_SAFE_INTEGER",
);
}
return value;
}
function readBox(
bytes: Uint8Array,
view: DataView,
start: number,
end: number,
): Box | null {
if (start + 8 > end) {
return null;
}
let size = view.getUint32(start, false);
const type = String.fromCodePoint(
bytes[start + 4] ?? 0,
bytes[start + 5] ?? 0,
bytes[start + 6] ?? 0,
bytes[start + 7] ?? 0,
);
let headerSize = 8;
if (size === 1) {
if (start + 16 > end) {
return null;
}
size = readUint64(view, start + 8);
headerSize = 16;
} else if (size === 0) {
size = end - start;
}
if (size < headerSize || start + size > end) {
return null;
}
return {
type,
start,
size,
end: start + size,
payloadStart: start + headerSize,
payloadEnd: start + size,
};
}
function listChildBoxes(
bytes: Uint8Array,
view: DataView,
start: number,
end: number,
): Box[] {
const result: Box[] = [];
let offset = start;
while (offset + 8 <= end) {
const box = readBox(bytes, view, offset, end);
if (!box) {
break;
}
result.push(box);
offset = box.end;
}
return result;
}
function findChildBox(
bytes: Uint8Array,
view: DataView,
start: number,
end: number,
type: string,
): Box | null {
const type0 = type.codePointAt(0) ?? 0;
const type1 = type.codePointAt(1) ?? 0;
const type2 = type.codePointAt(2) ?? 0;
const type3 = type.codePointAt(3) ?? 0;
const isComparableType = type.length === 4;
let offset = start;
while (offset + 8 <= end) {
let boxSize = view.getUint32(offset, false);
let headerSize = 8;
if (boxSize === 1) {
if (offset + 16 > end) {
return null;
}
boxSize = readUint64(view, offset + 8);
headerSize = 16;
} else if (boxSize === 0) {
boxSize = end - offset;
}
const boxEnd = offset + boxSize;
if (boxSize < headerSize || boxEnd > end) {
return null;
}
if (
isComparableType &&
bytes[offset + 4] === type0 &&
bytes[offset + 5] === type1 &&
bytes[offset + 6] === type2 &&
bytes[offset + 7] === type3
) {
return {
type,
start: offset,
size: boxSize,
end: boxEnd,
payloadStart: offset + headerSize,
payloadEnd: boxEnd,
};
}
offset = boxEnd;
}
return null;
}
function findBoxPath(
bytes: Uint8Array,
view: DataView,
start: number,
end: number,
path: string[],
): Box | null {
let currentStart = start;
let currentEnd = end;
let current: Box | null = null;
for (const step of path) {
current = findChildBox(bytes, view, currentStart, currentEnd, step);
if (!current) {
return null;
}
currentStart = current.payloadStart;
currentEnd = current.payloadEnd;
}
return current;
}
function parseStsz(view: DataView, box: Box): number[] {
let offset = box.payloadStart;
offset += 4; // version + flags
const sampleSize = view.getUint32(offset, false);
offset += 4;
const sampleCount = view.getUint32(offset, false);
offset += 4;
if (sampleSize !== 0) {
return new Array(sampleCount).fill(sampleSize);
}
const sampleSizes = new Array<number>(sampleCount);
for (let i = 0; i < sampleCount; i++) {
sampleSizes[i] = view.getUint32(offset, false);
offset += 4;
}
return sampleSizes;
}
function parseStsc(view: DataView, box: Box): StscEntry[] {
let offset = box.payloadStart;
offset += 4; // version + flags
const entryCount = view.getUint32(offset, false);
offset += 4;
const entries = new Array<StscEntry>(entryCount);
for (let i = 0; i < entryCount; i++) {
entries[i] = {
firstChunk: view.getUint32(offset, false),
samplesPerChunk: view.getUint32(offset + 4, false),
};
offset += 12;
}
return entries;
}
function parseChunkOffsets(view: DataView, box: Box): number[] {
let offset = box.payloadStart;
offset += 4; // version + flags
const entryCount = view.getUint32(offset, false);
offset += 4;
const offsets: number[] = new Array<number>(entryCount);
if (box.type === "co64") {
for (let i = 0; i < entryCount; i++) {
offsets[i] = readUint64(view, offset);
offset += 8;
}
return offsets;
}
for (let i = 0; i < entryCount; i++) {
offsets[i] = view.getUint32(offset, false);
offset += 4;
}
return offsets;
}
function getAudioTrackTables(
bytes: Uint8Array,
view: DataView,
): AudioTrackTables {
const moov = findChildBox(bytes, view, 0, bytes.byteLength, "moov");
if (!moov) {
throw new Error("MP4 does not contain moov box");
}
const traks = listChildBoxes(
bytes,
view,
moov.payloadStart,
moov.payloadEnd,
).filter((box) => box.type === "trak");
let audioTrak: Box | null = null;
for (const trak of traks) {
const hdlr = findBoxPath(bytes, view, trak.payloadStart, trak.payloadEnd, [
"mdia",
"hdlr",
]);
if (!hdlr) {
continue;
}
if (hdlr.payloadStart + 12 > hdlr.payloadEnd) {
continue;
}
const handlerType = String.fromCodePoint(
bytes[hdlr.payloadStart + 8] ?? 0,
bytes[hdlr.payloadStart + 9] ?? 0,
bytes[hdlr.payloadStart + 10] ?? 0,
bytes[hdlr.payloadStart + 11] ?? 0,
);
if (handlerType === "soun") {
audioTrak = trak;
break;
}
}
if (!audioTrak) {
throw new Error("Failed to locate audio track in MP4");
}
const stbl = findBoxPath(
bytes,
view,
audioTrak.payloadStart,
audioTrak.payloadEnd,
["mdia", "minf", "stbl"],
);
if (!stbl) {
throw new Error("Audio track is missing stbl box");
}
const stblChildren = listChildBoxes(
bytes,
view,
stbl.payloadStart,
stbl.payloadEnd,
);
let stsz: Box | null = null;
let stsc: Box | null = null;
let stco: Box | null = null;
let co64: Box | null = null;
for (const child of stblChildren) {
if (!stsz && child.type === "stsz") {
stsz = child;
} else if (!stsc && child.type === "stsc") {
stsc = child;
} else if (!stco && child.type === "stco") {
stco = child;
} else if (!co64 && child.type === "co64") {
co64 = child;
}
}
const chunkOffsetBox = stco ?? co64;
if (!stsz || !stsc || !chunkOffsetBox) {
throw new Error("Audio track is missing one of stsz/stsc/stco/co64 tables");
}
return {
sampleSizes: parseStsz(view, stsz),
sampleToChunk: parseStsc(view, stsc),
chunkOffsets: parseChunkOffsets(view, chunkOffsetBox),
};
}
function parseAacObjectType(codec: string): number {
const match = /mp4a\.40\.(\d+)/i.exec(codec);
const value = match?.[1] ? Number.parseInt(match[1], 10) : 2;
return Number.isFinite(value) && value > 0 ? value : 2;
}
function sampleRateToAdtsIndex(sampleRate: number): number {
const idx = (AAC_SAMPLE_RATES as readonly number[]).indexOf(sampleRate);
if (idx >= 0) {
return idx;
}
return 4;
}
function buildAdtsConfig(
aacObjectType: number,
sampleRate: number,
channels: number,
): AdtsConfig {
return {
profile: Math.max(0, Math.min(3, aacObjectType - 1)),
sampleRateIndex: sampleRateToAdtsIndex(sampleRate),
channelConfig: Math.max(1, Math.min(7, channels)),
};
}
function buildAdtsHeader(frameLength: number, config: AdtsConfig): Uint8Array {
const header = new Uint8Array(7);
const adtsFrameLength = frameLength + 7;
header[0] = 0xff;
header[1] = 0xf1;
header[2] =
((config.profile & 0x03) << 6) |
((config.sampleRateIndex & 0x0f) << 2) |
((config.channelConfig >> 2) & 0x01);
header[3] =
((config.channelConfig & 0x03) << 6) | ((adtsFrameLength >> 11) & 0x03);
header[4] = (adtsFrameLength >> 3) & 0xff;
header[5] = ((adtsFrameLength & 0x07) << 5) | 0x1f;
header[6] = 0xfc;
return header;
}
export async function extractAacFromMp4(
mp4Bytes: Uint8Array,
sink: AudioChunkSink,
options: AacExtractionOptions = {},
): Promise<AacExtractionResult> {
const view = new DataView(
mp4Bytes.buffer,
mp4Bytes.byteOffset,
mp4Bytes.byteLength,
);
const { sampleSizes, sampleToChunk, chunkOffsets } = getAudioTrackTables(
mp4Bytes,
view,
);
const codec = options.codecHint ?? "mp4a.40.2";
const sampleRate = options.sampleRateHint ?? 44100;
const channels = options.channelsHint ?? 2;
const aacObjectType = parseAacObjectType(codec);
const adtsConfig = buildAdtsConfig(aacObjectType, sampleRate, channels);
let sampleIndex = 0;
let stscIndex = 0;
let bytesWritten = 0;
for (let chunkIndex = 1; chunkIndex <= chunkOffsets.length; chunkIndex++) {
while (
stscIndex + 1 < sampleToChunk.length &&
chunkIndex >=
(sampleToChunk[stscIndex + 1]?.firstChunk ?? Number.POSITIVE_INFINITY)
) {
stscIndex++;
}
const chunkRule = sampleToChunk[stscIndex];
if (!chunkRule) {
throw new Error("stsc table lookup failed for current chunk index");
}
const chunkOffsetValue = chunkOffsets[chunkIndex - 1];
if (typeof chunkOffsetValue !== "number") {
throw new TypeError(
"Chunk offset table lookup failed for current chunk index",
);
}
let chunkOffset = chunkOffsetValue;
for (
let i = 0;
i < chunkRule.samplesPerChunk && sampleIndex < sampleSizes.length;
i++
) {
const sampleSize = sampleSizes[sampleIndex++];
if (typeof sampleSize !== "number") {
throw new TypeError(
"Sample size table lookup failed for current sample index",
);
}
const sampleEnd: number = chunkOffset + sampleSize;
if (sampleEnd > mp4Bytes.byteLength) {
throw new Error("MP4 sample offset points outside file boundaries");
}
const adtsHeader = buildAdtsHeader(sampleSize, adtsConfig);
const sampleBytes = mp4Bytes.subarray(chunkOffset, sampleEnd);
await sink.write(adtsHeader);
await sink.write(sampleBytes);
bytesWritten += adtsHeader.byteLength + sampleBytes.byteLength;
chunkOffset = sampleEnd;
}
}
if (sampleIndex !== sampleSizes.length) {
throw new Error("MP4 audio sample table traversal ended prematurely");
}
return {
codec,
sampleRate,
channels,
bytesWritten,
};
}

View file

@ -5,7 +5,14 @@ type IframeConfig = {
responseFormatter: (videoId: string, data: unknown) => unknown;
};
let iframeInteractorInitialized = false;
export function initIframeInteractor(): void {
if (iframeInteractorInitialized) {
return;
}
iframeInteractorInitialized = true;
const configs: Record<string, IframeConfig> = {
"https://dev.epicgames.com": {
targetOrigin: "https://dev.epicgames.com",

View file

@ -6,7 +6,6 @@ import {
} from "../localization/localizationProvider";
import debug from "../utils/debug";
import { isIframe } from "../utils/iframeConnector";
import { initIframeInteractor } from "./iframeInteractor";
type LogBootstrap = (
message: string,
@ -15,7 +14,6 @@ type LogBootstrap = (
let runtimeActivated = false;
let runtimeActivationPromise: Promise<void> | null = null;
let iframeInteractorBound = false;
export async function ensureRuntimeActivated(
reason: string,
@ -42,11 +40,6 @@ export async function ensureRuntimeActivated(
}
debug.log(`Selected menu language: ${localizationProvider.lang}`);
if (!iframeInteractorBound) {
iframeInteractorBound = true;
initIframeInteractor();
}
runtimeActivated = true;
})();

View file

@ -12,6 +12,7 @@ export const m3u8ProxyHost = "media-proxy.toil.cc/v1/proxy/m3u8";
/**
* @see https://github.com/FOSWLY/vot-worker
*/
export const proxyWorkerHostMode1 = "vot-new.toil-dump.workers.dev";
export const proxyWorkerHost = "vot-worker.kload.workers.dev"; // vot-worker.toil.cc
export const votBackendUrl = "https://vot.toil.cc/v1";
@ -52,7 +53,6 @@ export const defaultTranslationService: "yandexbrowser" | "msedge" =
export const defaultDetectService: "yandexbrowser" | "msedge" | "rust-server" =
"yandexbrowser";
export const nonProxyExtensions: string[] = ["Tampermonkey", "Violentmonkey"];
export const proxyOnlyCountries: string[] = ["UA", "LV", "LT"];
/**

View file

@ -1,5 +1,6 @@
import type { Account } from "../types/storage";
import { votStorage } from "../utils/storage";
import { notifyAuthOpener } from "./authRefreshMessage";
type AuthProfilePayload = {
avatar_id: string;
@ -51,6 +52,7 @@ async function handleAuthCallbackPage() {
username: undefined,
avatarId: undefined,
});
notifyAuthOpener();
}
async function handleProfilePage() {
@ -70,6 +72,7 @@ async function handleProfilePage() {
username,
avatarId,
});
notifyAuthOpener();
}
export async function initAuth() {

View file

@ -0,0 +1,38 @@
export const AUTH_REFRESH_MESSAGE_SOURCE = "vot-auth";
export const AUTH_REFRESH_MESSAGE_TYPE = "account-updated";
export type AuthRefreshMessage = Readonly<{
source: typeof AUTH_REFRESH_MESSAGE_SOURCE;
type: typeof AUTH_REFRESH_MESSAGE_TYPE;
}>;
export function createAuthRefreshMessage(): AuthRefreshMessage {
return {
source: AUTH_REFRESH_MESSAGE_SOURCE,
type: AUTH_REFRESH_MESSAGE_TYPE,
};
}
export function isAuthRefreshMessage(
value: unknown,
): value is AuthRefreshMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<AuthRefreshMessage>;
return (
candidate.source === AUTH_REFRESH_MESSAGE_SOURCE &&
candidate.type === AUTH_REFRESH_MESSAGE_TYPE
);
}
export function notifyAuthOpener(
target: Pick<Window, "postMessage"> | null | undefined = globalThis.opener,
): void {
if (!target || typeof target.postMessage !== "function") {
return;
}
target.postMessage(createAuthRefreshMessage(), "*");
}

View file

@ -1,9 +1,10 @@
export type BootstrapMode = "skip" | "top-full" | "iframe-lazy";
export type BootstrapMode = "skip" | "auth-eager" | "lazy";
export type BootstrapPolicyInput = {
isIframe: boolean;
href: string;
origin: string;
authOrigin: string;
};
export function shouldSkipIframeBootstrap(
@ -23,8 +24,8 @@ export function resolveBootstrapMode(
if (shouldSkipIframeBootstrap(input)) {
return "skip";
}
if (input.isIframe) {
return "iframe-lazy";
if (!input.isIframe && input.origin === input.authOrigin) {
return "auth-eager";
}
return "top-full";
return "lazy";
}

View file

@ -9,7 +9,6 @@ import { votStorage } from "../utils/storage";
import { fnv1a32ToKeyPart } from "../utils/utils";
export const YANDEX_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
export const VOT_SESSION_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const RESPONSE_CACHE_CREATED_AT_HEADER = "x-vot-cache-created-at";
const RESPONSE_CACHE_KEY_HEADER = "x-vot-cache-key";
const DEFAULT_RESPONSE_CACHE_NAME = "vot-http-cache-v1";
@ -46,13 +45,7 @@ type CacheReadResult = {
expiresAt?: number;
};
type VOTSession = ClientSession;
type VOTSessions = Partial<Record<SessionModule, VOTSession>>;
type StoredVOTSession = {
secretKey: string;
uuid: string;
expiresAt: number;
};
type VOTSessions = Partial<Record<SessionModule, ClientSession>>;
type VOTSessionStorage = Pick<
typeof votStorage,
"getRaw" | "setRaw" | "deleteRaw"
@ -62,7 +55,7 @@ function getCurrentUnixTimestampSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function isVOTSession(value: unknown): value is VOTSession {
function isClientSession(value: unknown): value is ClientSession {
if (!value || typeof value !== "object") {
return false;
}
@ -94,7 +87,7 @@ function sanitizeVOTSessions(value: unknown): VOTSessions {
const now = getCurrentUnixTimestampSeconds();
const entries = Object.entries(value as Record<string, unknown>).flatMap(
([module, session]) => {
if (!isVOTSession(session)) {
if (!isClientSession(session)) {
return [];
}
@ -109,85 +102,49 @@ function sanitizeVOTSessions(value: unknown): VOTSessions {
return Object.fromEntries(entries) as VOTSessions;
}
function isStoredVOTSession(value: unknown): value is StoredVOTSession {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as {
expiresAt?: unknown;
secretKey?: unknown;
uuid?: unknown;
};
return (
typeof candidate.expiresAt === "number" &&
Number.isFinite(candidate.expiresAt) &&
typeof candidate.secretKey === "string" &&
candidate.secretKey.length > 0 &&
typeof candidate.uuid === "string" &&
candidate.uuid.length > 0
);
function hasSessions(sessions: VOTSessions): boolean {
return Object.keys(sessions).length > 0;
}
export class VOTSessionStorageCache {
constructor(private readonly storage: VOTSessionStorage = votStorage) {}
private getStorageKey(host: string): string {
void host;
private getStorageKey(): string {
return VOT_SESSION_STORAGE_KEY;
}
async restore(
host: string,
_host: string,
currentSessions: VOTSessions = {},
): Promise<VOTSessions> {
const storageKey = this.getStorageKey(host);
const storageKey = this.getStorageKey();
const rawStoredSession = await this.storage.getRaw<unknown>(storageKey);
if (!isStoredVOTSession(rawStoredSession)) {
const restoredSessions = sanitizeVOTSessions(rawStoredSession);
if (!hasSessions(restoredSessions)) {
if (rawStoredSession !== undefined) {
await this.storage.deleteRaw(storageKey);
}
return currentSessions;
}
const nowMs = Date.now();
if (rawStoredSession.expiresAt <= nowMs) {
await this.storage.deleteRaw(storageKey);
return currentSessions;
}
const remainingSeconds = Math.max(
1,
Math.ceil((rawStoredSession.expiresAt - nowMs) / 1000),
);
return {
...currentSessions,
"video-translation": {
secretKey: rawStoredSession.secretKey,
uuid: rawStoredSession.uuid,
expires: remainingSeconds,
timestamp: Math.floor(nowMs / 1000),
},
...restoredSessions,
};
}
async persist(
host: string,
_host: string,
sessions: VOTSessions | undefined,
): Promise<void> {
void host;
const storageKey = this.getStorageKey(host);
const translationSession =
sanitizeVOTSessions(sessions)["video-translation"];
if (!translationSession) {
const storageKey = this.getStorageKey();
const sanitizedSessions = sanitizeVOTSessions(sessions);
if (!hasSessions(sanitizedSessions)) {
await this.storage.deleteRaw(storageKey);
return;
}
await this.storage.setRaw(storageKey, {
secretKey: translationSession.secretKey,
uuid: translationSession.uuid,
expiresAt:
(translationSession.timestamp + translationSession.expires) * 1000,
} satisfies StoredVOTSession);
await this.storage.setRaw(storageKey, sanitizedSessions);
}
}
@ -322,67 +279,35 @@ class ResponseCacheManager {
const allowStaleOnError = options.allowStaleOnError !== false;
const nowMs = Date.now();
let staleFallback: Response | undefined;
if (useMemory) {
const memoryHit = this.readMemoryCache(key, nowMs);
if (memoryHit.fresh) {
return memoryHit.fresh;
}
staleFallback = memoryHit.stale ?? staleFallback;
const staleFallback = await this.readCachedResponse({
key,
nowMs,
useMemory,
useCacheApi,
cacheName,
url: context.url,
cacheApiKey,
ttlMs,
allowStaleOnError,
});
if (staleFallback.fresh) {
return staleFallback.fresh;
}
if (useCacheApi) {
const cacheApiHit = await this.readCacheApi(
cacheName,
context.url,
cacheApiKey,
ttlMs,
nowMs,
allowStaleOnError,
);
if (cacheApiHit.fresh) {
if (useMemory) {
this.writeMemoryCache(
key,
cacheApiHit.fresh.clone(),
cacheApiHit.expiresAt ?? nowMs + ttlMs,
);
}
return cacheApiHit.fresh;
}
staleFallback = staleFallback ?? cacheApiHit.stale;
}
const runNetworkRequest = async (): Promise<Response> => {
const response = await fetcher();
if (!response.ok) {
return response;
}
const createdAtMs = Date.now();
const expiresAt = this.computeExpiresAt(createdAtMs, ttlMs);
if (useMemory) {
this.writeMemoryCache(key, response.clone(), expiresAt);
}
if (useCacheApi) {
const storable = this.toStorableResponse(response.clone(), createdAtMs);
await this.writeCacheApi(cacheName, context.url, cacheApiKey, storable);
}
return response;
};
if (!dedupe) {
try {
return await runNetworkRequest();
} catch (err) {
if (allowStaleOnError && staleFallback) {
return staleFallback;
}
throw err;
}
return await this.runNetworkRequestWithFallback(
{
key,
cacheName,
url: context.url,
cacheApiKey,
ttlMs,
useMemory,
useCacheApi,
},
fetcher,
allowStaleOnError ? staleFallback.stale : undefined,
);
}
const inFlight = this.inFlightRequests.get(key);
@ -390,16 +315,19 @@ class ResponseCacheManager {
return (await inFlight).clone();
}
const networkPromise = (async (): Promise<Response> => {
try {
return await runNetworkRequest();
} catch (err) {
if (allowStaleOnError && staleFallback) {
return staleFallback.clone();
}
throw err;
}
})();
const networkPromise = this.runNetworkRequestWithFallback(
{
key,
cacheName,
url: context.url,
cacheApiKey,
ttlMs,
useMemory,
useCacheApi,
},
fetcher,
allowStaleOnError ? staleFallback.stale?.clone() : undefined,
);
this.inFlightRequests.set(key, networkPromise);
try {
@ -409,6 +337,126 @@ class ResponseCacheManager {
}
}
private async readCachedResponse({
key,
nowMs,
useMemory,
useCacheApi,
cacheName,
url,
cacheApiKey,
ttlMs,
allowStaleOnError,
}: {
key: string;
nowMs: number;
useMemory: boolean;
useCacheApi: boolean;
cacheName: string;
url: string;
cacheApiKey: string;
ttlMs: number;
allowStaleOnError: boolean;
}): Promise<{ fresh?: Response; stale?: Response }> {
let staleFallback: Response | undefined;
if (useMemory) {
const memoryHit = this.readMemoryCache(key, nowMs);
if (memoryHit.fresh) {
return { fresh: memoryHit.fresh };
}
staleFallback = memoryHit.stale;
}
if (!useCacheApi) {
return { stale: staleFallback };
}
const cacheApiHit = await this.readCacheApi(
cacheName,
url,
cacheApiKey,
ttlMs,
nowMs,
allowStaleOnError,
);
if (cacheApiHit.fresh) {
if (useMemory) {
this.writeMemoryCache(
key,
cacheApiHit.fresh.clone(),
cacheApiHit.expiresAt ?? nowMs + ttlMs,
);
}
return { fresh: cacheApiHit.fresh };
}
return { stale: staleFallback ?? cacheApiHit.stale };
}
private async runNetworkRequestWithFallback(
cacheConfig: {
key: string;
cacheName: string;
url: string;
cacheApiKey: string;
ttlMs: number;
useMemory: boolean;
useCacheApi: boolean;
},
fetcher: () => Promise<Response>,
staleFallback?: Response,
): Promise<Response> {
try {
return await this.runNetworkRequest(cacheConfig, fetcher);
} catch (err) {
if (staleFallback) {
return staleFallback;
}
throw err;
}
}
private async runNetworkRequest(
{
key,
cacheName,
url,
cacheApiKey,
ttlMs,
useMemory,
useCacheApi,
}: {
key: string;
cacheName: string;
url: string;
cacheApiKey: string;
ttlMs: number;
useMemory: boolean;
useCacheApi: boolean;
},
fetcher: () => Promise<Response>,
): Promise<Response> {
const response = await fetcher();
if (!response.ok) {
return response;
}
const createdAtMs = Date.now();
const expiresAt = this.computeExpiresAt(createdAtMs, ttlMs);
if (useMemory) {
this.writeMemoryCache(key, response.clone(), expiresAt);
}
if (useCacheApi) {
const storable = this.toStorableResponse(response.clone(), createdAtMs);
await this.writeCacheApi(cacheName, url, cacheApiKey, storable);
}
return response;
}
private computeExpiresAt(createdAtMs: number, ttlMs: number): number {
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
return createdAtMs;
@ -437,7 +485,7 @@ class ResponseCacheManager {
context: RequestCacheContext,
): string | undefined {
const method = this.normalizeMethod(context.method);
if (method === "GET" || method === "HEAD") {
if (method === "GET") {
return `${method}:${context.url}`;
}

View file

@ -41,7 +41,7 @@ export class EventImpl<Args extends unknown[] = unknown[]> {
for (const handler of this.listeners) {
try {
const result = handler(...args);
if (result && typeof (result as Promise<void>).then === "function") {
if (result && typeof result.then === "function") {
pending.push(Promise.resolve(result));
}
} catch (exception) {

View file

@ -102,6 +102,11 @@ type DownloadWaiter = {
reject: (error: Error) => void;
};
type TranslateVideoImplOptions = {
disableLivelyVoice?: boolean;
retryAttempt?: number;
};
export class VOTTranslationHandler {
readonly videoHandler: VideoHandler;
readonly audioDownloader: AudioDownloader;
@ -308,6 +313,17 @@ export class VOTTranslationHandler {
});
}
private getVideoTranslationRetryDelayMs(
retryAttempt: number,
videoDurationSeconds: number,
): number {
if (retryAttempt > 0) {
return 25_000;
}
return videoDurationSeconds <= 10 * 60 ? 60_000 : 75_000;
}
async translateVideoImpl(
videoData: VideoData,
requestLang: RequestLang,
@ -315,10 +331,11 @@ export class VOTTranslationHandler {
translationHelp: TranslationHelp[] | null = null,
shouldSendFailedAudio = false,
signal = NEVER_ABORTED_SIGNAL,
disableLivelyVoice = false,
options: TranslateVideoImplOptions = {},
): Promise<
(TranslatedVideoTranslationResponse & { usedLivelyVoice: boolean }) | null
> {
const { disableLivelyVoice = false, retryAttempt = 0 } = options;
clearTimeout(this.videoHandler.autoRetry);
this.finishDownloadSuccess();
const requestLangForApi = this.videoHandler.getRequestLangForTranslation(
@ -339,53 +356,19 @@ export class VOTTranslationHandler {
requestLangForApi,
responseLang,
);
let useLivelyVoice =
!livelyDisabled &&
livelyVoiceAllowed &&
Boolean(this.videoHandler.data?.useLivelyVoice);
let res: VideoTranslationResponse | undefined;
// If server says lively voices are unavailable, immediately retry once
// without lively voices and keep that choice for subsequent retries.
for (let attempt = 0; attempt < 2; attempt++) {
try {
res = await this.videoHandler.votClient.translateVideo({
videoData,
requestLang: requestLangForApi,
responseLang,
translationHelp,
extraOpts: {
useLivelyVoice,
videoTitle: this.videoHandler.videoData?.title,
},
shouldSendFailedAudio,
});
} catch (err) {
if (useLivelyVoice && this.isLivelyVoiceUnavailableError(err)) {
debug.log(
"[translateVideoImpl] Lively voices are unavailable. Falling back to standard translation.",
err,
);
livelyDisabled = true;
useLivelyVoice = false;
continue;
}
throw err;
}
if (useLivelyVoice && this.isLivelyVoiceUnavailableError(res)) {
debug.log(
"[translateVideoImpl] Server responded that lively voices are unavailable. Falling back to standard translation.",
res,
);
livelyDisabled = true;
useLivelyVoice = false;
res = undefined;
continue;
}
break;
}
const translationAttempt =
await this.requestTranslationWithLivelyFallback({
videoData,
requestLangForApi,
responseLang,
translationHelp,
shouldSendFailedAudio,
livelyDisabled,
livelyVoiceAllowed,
});
livelyDisabled = translationAttempt.livelyDisabled;
const useLivelyVoice = translationAttempt.useLivelyVoice;
const res = translationAttempt.response;
if (!res) {
throw new Error("Failed to get translation response");
@ -439,7 +422,10 @@ export class VOTTranslationHandler {
translationHelp,
true,
signal,
livelyDisabled,
{
disableLivelyVoice: livelyDisabled,
retryAttempt,
},
);
}
} catch (err) {
@ -487,13 +473,80 @@ export class VOTTranslationHandler {
translationHelp,
shouldSendFailedAudio,
signal,
livelyDisabled,
{
disableLivelyVoice: livelyDisabled,
retryAttempt: retryAttempt + 1,
},
),
20000,
this.getVideoTranslationRetryDelayMs(retryAttempt, videoData.duration),
signal,
);
}
private async requestTranslationWithLivelyFallback({
videoData,
requestLangForApi,
responseLang,
translationHelp,
shouldSendFailedAudio,
livelyDisabled,
livelyVoiceAllowed,
}: {
videoData: VideoData;
requestLangForApi: RequestLang;
responseLang: ResponseLang;
translationHelp: TranslationHelp[] | null;
shouldSendFailedAudio: boolean;
livelyDisabled: boolean;
livelyVoiceAllowed: boolean;
}): Promise<{
response?: VideoTranslationResponse;
useLivelyVoice: boolean;
livelyDisabled: boolean;
}> {
let useLivelyVoice =
!livelyDisabled &&
livelyVoiceAllowed &&
Boolean(this.videoHandler.data?.useLivelyVoice);
while (true) {
try {
const response = await this.videoHandler.votClient.translateVideo({
videoData,
requestLang: requestLangForApi,
responseLang,
translationHelp,
extraOpts: {
useLivelyVoice,
videoTitle: this.videoHandler.videoData?.title,
},
shouldSendFailedAudio,
});
if (!useLivelyVoice || !this.isLivelyVoiceUnavailableError(response)) {
return { response, useLivelyVoice, livelyDisabled };
}
debug.log(
"[translateVideoImpl] Server responded that lively voices are unavailable. Falling back to standard translation.",
response,
);
} catch (err) {
if (!useLivelyVoice || !this.isLivelyVoiceUnavailableError(err)) {
throw err;
}
debug.log(
"[translateVideoImpl] Lively voices are unavailable. Falling back to standard translation.",
err,
);
}
livelyDisabled = true;
useLivelyVoice = false;
}
}
private waitForAudioDownloadCompletion(
signal: AbortSignal,
timeoutMs: number,

View file

@ -18,7 +18,7 @@ interface LifecycleUIManager {
votOverlayView: LifecycleOverlayView;
}
interface VideoLifecycleHost {
export interface VideoLifecycleHost {
video: HTMLVideoElement;
site: ServiceConf<VideoService>;
container: HTMLElement;
@ -42,8 +42,12 @@ interface VideoLifecycleHost {
getSubtitlesCacheKey(
videoId: string,
detectedLanguage: RequestLang,
responseLanguage: ResponseLang,
subtitleLanguage: string,
): string;
getPreferredSubtitlesLanguage(
detectedLanguage?: string,
responseLanguage?: string,
): string | undefined;
translationOrchestrator: {
reset(): void;
runAutoTranslationIfEligible(): Promise<void>;
@ -333,16 +337,25 @@ export class VideoLifecycleController {
return;
}
const cacheKey = this.host.getSubtitlesCacheKey(
this.host.videoData.videoId,
const subtitleLanguage = this.host.getPreferredSubtitlesLanguage(
this.host.videoData.detectedLanguage,
this.host.videoData.responseLanguage,
);
if (subtitleLanguage) {
const cacheKey = this.host.getSubtitlesCacheKey(
this.host.videoData.videoId,
this.host.videoData.detectedLanguage,
subtitleLanguage,
);
const cachedSubtitles = this.host.cacheManager.getSubtitles(cacheKey);
this.host.subtitles = cachedSubtitles ?? [];
this.host.subtitlesCacheKey =
cachedSubtitles !== undefined ? cacheKey : null;
const cachedSubtitles = this.host.cacheManager.getSubtitles(cacheKey);
this.host.subtitles = cachedSubtitles ?? [];
this.host.subtitlesCacheKey =
cachedSubtitles !== undefined ? cacheKey : null;
} else {
this.host.subtitles = [];
this.host.subtitlesCacheKey = null;
}
await this.host.updateSubtitlesLangSelect();
if (this.shouldAbortHandleSrcChanged(sessionId, "after subtitles update")) {

View file

@ -0,0 +1,99 @@
import type { ResponseLang } from "@vot.js/shared/types/data";
import type { VideoHandler } from "../index";
import type { OverlayMount } from "../types/uiManager";
import type { VideoLifecycleHost } from "./videoLifecycleController";
export function createVideoLifecycleHost(
handler: VideoHandler,
resolveOverlayMount: (container: HTMLElement) => OverlayMount,
): VideoLifecycleHost {
const self = () => handler;
return {
get video() {
return self().video;
},
get site() {
return self().site;
},
get container() {
return self().container;
},
set container(value: HTMLElement) {
if (self().container === value) {
return;
}
self().container = value;
self().uiManager.updateMount(resolveOverlayMount(value));
},
get firstPlay() {
return self().firstPlay;
},
set firstPlay(value: boolean) {
self().firstPlay = value;
},
stopTranslation: () => handler.stopTranslation(),
get uiManager() {
return self().uiManager as VideoLifecycleHost["uiManager"];
},
getVideoData: () => handler.getVideoData(),
cacheManager: {
getSubtitles: (key: string) => self().cacheManager.getSubtitles(key),
},
getSubtitlesCacheKey: (
videoId: string,
detectedLanguage: string,
subtitleLanguage: string,
) =>
handler.getSubtitlesCacheKey(videoId, detectedLanguage, subtitleLanguage),
getPreferredSubtitlesLanguage: (
detectedLanguage?: string,
responseLanguage?: string,
) =>
handler.getPreferredSubtitlesLanguage(detectedLanguage, responseLanguage),
updateSubtitlesLangSelect: () => handler.updateSubtitlesLangSelect(),
enableSubtitlesForCurrentLangPair: () =>
handler.enableSubtitlesForCurrentLangPair(),
setSelectMenuValues: (from: string, to: string) =>
handler.setSelectMenuValues(from, to),
get translateToLang() {
return self().translateToLang;
},
set translateToLang(value: string) {
self().translateToLang = value as ResponseLang;
},
get data() {
return self().data ?? {};
},
get subtitles() {
return self().subtitles;
},
set subtitles(value: any[]) {
self().subtitles = value;
},
get subtitlesCacheKey() {
return self().subtitlesCacheKey;
},
set subtitlesCacheKey(value: string | null) {
self().subtitlesCacheKey = value;
},
get videoData() {
return self().videoData;
},
set videoData(value: any) {
self().videoData = value;
},
get actionsAbortController() {
return self().actionsAbortController;
},
set actionsAbortController(value: AbortController) {
self().actionsAbortController = value;
},
resetActionsAbortController: (reason?: unknown) =>
handler.resetActionsAbortController(reason),
translationOrchestrator: handler.translationOrchestrator,
resetSubtitlesWidget: () => handler.resetSubtitlesWidget(),
queueOverlayAutoHide: () => handler.overlayVisibility?.queueAutoHide(),
};
}

View file

@ -343,14 +343,14 @@ export class VOTVideoManager {
this.setDetectedLanguageCache(videoData.videoId, cacheLanguage);
}
if (detectedLanguage === "auto") {
if (!detectedLanguage || detectedLanguage === "auto") {
return;
}
videoData.detectedLanguage = detectedLanguage;
if (this.videoHandler.translateFromLang === "auto") {
this.videoHandler.translateFromLang = detectedLanguage;
}
this.videoHandler.setSelectMenuValues(
detectedLanguage,
this.videoHandler.translateToLang,
);
}
async getVideoData() {
@ -408,7 +408,7 @@ export class VOTVideoManager {
title,
localizedTitle,
description,
downloadTitle: localizedTitle ?? title ?? videoId,
downloadTitle: localizedTitle ?? title ?? document.title ?? videoId,
} satisfies RuntimeVideoData;
if (sharedLanguageState.lastLoggedDetectedLanguage !== detectedLanguage) {
@ -475,8 +475,12 @@ export class VOTVideoManager {
*/
setVideoVolume(volume: number) {
const snapped = snapVolume01(volume);
const shouldUnmute = snapped > 0;
if (!isExternalVolumeHost(this.videoHandler.site.host)) {
if (shouldUnmute) {
this.videoHandler.video.muted = false;
}
this.videoHandler.video.volume = snapped;
return this;
}
@ -485,7 +489,14 @@ export class VOTVideoManager {
// Do NOT use a truthy check here, or setting volume to 0 (0%) will be treated
// as a failure.
try {
const player = YoutubeHelper.getPlayer() as
| { unMute?: () => void }
| undefined;
const result = YoutubeHelper.setVolume(snapped) as unknown;
if (shouldUnmute) {
player?.unMute?.();
this.videoHandler.video.muted = false;
}
const ok =
(typeof result === "boolean" && result) ||
(typeof result === "number" && Number.isFinite(result));
@ -494,6 +505,9 @@ export class VOTVideoManager {
// ignore - fall back to setting the HTMLMediaElement volume below.
}
if (shouldUnmute) {
this.videoHandler.video.muted = false;
}
this.videoHandler.video.volume = snapped;
return this;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,176 @@
import debug from "../utils/debug";
import {
ext,
notificationsClear,
notificationsCreate,
tabsUpdate,
windowsUpdate,
} from "./webext";
type GmNotificationSender = {
tab?: {
id?: number;
windowId?: number;
};
};
type GmNotificationDetails = {
title: string;
text: string;
silent: boolean;
timeout: number;
};
type GmNotificationMessage = {
type: "gm_notification";
details?: unknown;
};
function asErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (err && typeof err === "object") {
try {
return JSON.stringify(err);
} catch {
return Object.prototype.toString.call(err);
}
}
try {
return String(err);
} catch {
return "Unknown error";
}
}
function sendBridgeResponse(
sendResponse: ((value: unknown) => void) | undefined,
payload: unknown,
): void {
if (typeof sendResponse !== "function") return;
try {
sendResponse(payload);
} catch {
// ignore
}
}
export function isGmNotificationMessage(
msg: unknown,
): msg is GmNotificationMessage {
if (!msg || typeof msg !== "object") return false;
return (msg as { type?: unknown }).type === "gm_notification";
}
export function normalizeGmNotificationDetails(
details: unknown,
): GmNotificationDetails {
const raw =
details && typeof details === "object"
? (details as Record<string, unknown>)
: {};
const timeoutRaw = Number(raw.timeout ?? 0);
return {
title: raw.title != null ? String(raw.title) : "",
text: raw.text != null ? String(raw.text) : "",
silent: Boolean(raw.silent),
timeout: Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 0,
};
}
export function createBridgeNotificationId(
sender: GmNotificationSender,
): string {
const tabId = sender.tab?.id;
const windowId = sender.tab?.windowId;
const safeTab = typeof tabId === "number" ? tabId : -1;
const safeWin = typeof windowId === "number" ? windowId : -1;
const nonce =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}:${Math.random().toString(36).slice(2)}`;
return `vot:${safeTab}:${safeWin}:${nonce}`;
}
export function createBridgeNotificationOptions(
details: GmNotificationDetails,
): Record<string, unknown> {
const isFirefox =
typeof (ext?.runtime as { getBrowserInfo?: unknown })?.getBrowserInfo ===
"function";
const iconUrl = ext?.runtime?.getURL
? ext.runtime.getURL("icons/icon-128.png")
: "icons/icon-128.png";
const options: Record<string, unknown> = {
type: "basic",
iconUrl,
title: details.title || "VOT",
message: details.text,
};
if (!isFirefox) {
options.silent = details.silent;
}
return options;
}
export function registerBackgroundNotifications(): void {
ext?.runtime?.onMessage?.addListener?.(
(
msg: unknown,
sender: GmNotificationSender,
sendResponse: ((value: unknown) => void) | undefined,
) => {
if (!isGmNotificationMessage(msg)) return;
const details = normalizeGmNotificationDetails(msg.details);
const notificationId = createBridgeNotificationId(sender);
const options = createBridgeNotificationOptions(details);
void (async () => {
try {
await notificationsCreate(notificationId, options);
if (details.timeout > 0) {
setTimeout(() => {
void notificationsClear(notificationId);
}, details.timeout);
}
sendBridgeResponse(sendResponse, { ok: true });
} catch (error) {
debug.error(
"[VOT EXT][background] Failed to create notification",
error,
);
sendBridgeResponse(sendResponse, {
ok: false,
error: asErrorMessage(error),
});
}
})();
return true;
},
);
ext?.notifications?.onClicked?.addListener?.((notificationId: string) => {
if (!notificationId.startsWith("vot:")) return;
const parts = notificationId.split(":");
if (parts.length < 3) return;
const tabId = Number(parts[1]);
const windowId = Number(parts[2]);
if (Number.isFinite(windowId) && windowId >= 0) {
void windowsUpdate(windowId, { focused: true });
}
if (Number.isFinite(tabId) && tabId >= 0) {
void tabsUpdate(tabId, { active: true });
}
});
}

View file

@ -0,0 +1,121 @@
import { ext, storageGet, storageRemove, storageSet } from "./webext";
type GmStorageMessage = {
type: "gm_storage";
action?: string;
payload?: Record<string, unknown>;
};
function isGmStorageMessage(msg: unknown): msg is GmStorageMessage {
if (!msg || typeof msg !== "object") return false;
return (msg as { type?: unknown }).type === "gm_storage";
}
function normalizeStorageRequestKey(value: unknown): string {
switch (typeof value) {
case "string":
return value;
case "number":
case "boolean":
case "bigint":
return String(value);
default:
return "";
}
}
function asErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (err && typeof err === "object") {
try {
return JSON.stringify(err);
} catch {
return Object.prototype.toString.call(err);
}
}
try {
return String(err);
} catch {
return "Unknown error";
}
}
function sendBridgeResponse(
sendResponse: ((value: unknown) => void) | undefined,
payload: unknown,
): void {
if (typeof sendResponse !== "function") return;
try {
sendResponse(payload);
} catch {
// ignore
}
}
async function handleStorageRequest(
action: string,
payload: Record<string, unknown> | undefined,
): Promise<unknown> {
switch (action) {
case "gm_getValue": {
const key = normalizeStorageRequestKey(payload?.key);
const def = payload?.def;
const items = await storageGet<Record<string, unknown>>(key);
return Object.hasOwn(items, key) ? items[key] : def;
}
case "gm_setValue": {
const key = normalizeStorageRequestKey(payload?.key);
await storageSet({ [key]: payload?.value });
return true;
}
case "gm_deleteValue": {
const key = normalizeStorageRequestKey(payload?.key);
await storageRemove(key);
return true;
}
case "gm_listValues": {
const items = await storageGet<Record<string, unknown>>(null);
return Object.keys(items ?? {});
}
case "gm_getValues": {
const defaults = (payload?.defaults ?? {}) as Record<string, unknown>;
return await storageGet(defaults);
}
default:
throw new Error(`Unknown storage action: ${action}`);
}
}
export function registerBackgroundStorageBridge(): void {
ext?.runtime?.onMessage?.addListener?.(
(
msg: unknown,
_sender: unknown,
sendResponse: ((value: unknown) => void) | undefined,
) => {
if (!isGmStorageMessage(msg)) return;
void (async () => {
try {
const result = await handleStorageRequest(
String(msg.action ?? ""),
msg.payload,
);
sendBridgeResponse(sendResponse, { ok: true, result });
} catch (error) {
sendBridgeResponse(sendResponse, {
ok: false,
error: asErrorMessage(error),
});
}
})();
return true;
},
);
}

View file

@ -1,127 +0,0 @@
/**
* Small Base64 helpers used by the extension bridge.
*
* We keep these in a dedicated module to avoid duplicating the same logic in
* both the content script and the service worker.
*/
type FromBase64Options = {
alphabet?: "base64" | "base64url";
lastChunkHandling?: "loose" | "strict" | "stop-before-partial";
};
type Uint8ArrayPrototypeWithBase64 = {
toBase64?: (this: Uint8Array) => string;
};
type Uint8ArrayConstructorWithBase64 = Uint8ArrayConstructor & {
fromBase64?: (input: string, options?: FromBase64Options) => Uint8Array;
};
const BASE64URL_DECODE_OPTIONS: FromBase64Options = {
alphabet: "base64url",
};
const nativeToBase64 =
typeof (Uint8Array.prototype as Uint8ArrayPrototypeWithBase64).toBase64 ===
"function"
? (Uint8Array.prototype as Uint8ArrayPrototypeWithBase64).toBase64
: null;
const nativeFromBase64 = (Uint8Array as Uint8ArrayConstructorWithBase64)
.fromBase64;
function normalizeBase64Input(input: string): string {
// Allow surrounding / embedded whitespace and missing padding.
const withoutWhitespace = String(input).replaceAll(/\s+/g, "");
const remainder = withoutWhitespace.length % 4;
if (remainder === 1) {
throw new TypeError("Invalid base64 input.");
}
if (remainder === 0) return withoutWhitespace;
return withoutWhitespace + "=".repeat(4 - remainder);
}
function bytesToBinaryString(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return binary;
}
function decodeWithLegacyBase64Normalized(
normalizedBase64: string,
): Uint8Array {
const atobFn = globalThis.atob;
if (typeof atobFn !== "function") {
throw new TypeError("Base64 decoder is not available in this environment.");
}
const binary = atobFn(normalizedBase64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
function encodeWithLegacyBase64(bytes: Uint8Array): string {
const btoaFn = globalThis.btoa;
if (typeof btoaFn !== "function") {
throw new TypeError("Base64 encoder is not available in this environment.");
}
return btoaFn(bytesToBinaryString(bytes));
}
function decodeBase64ToBytes(input: string): Uint8Array {
const normalized = normalizeBase64Input(input);
const normalizedStandard = normalized
.replaceAll("-", "+")
.replaceAll("_", "/");
if (typeof nativeFromBase64 !== "function") {
return decodeWithLegacyBase64Normalized(normalizedStandard);
}
const hasUrlAlphabet = normalized.includes("-") || normalized.includes("_");
const hasStandardAlphabet =
normalized.includes("+") || normalized.includes("/");
if (hasUrlAlphabet && !hasStandardAlphabet) {
return nativeFromBase64(normalized, BASE64URL_DECODE_OPTIONS);
}
return nativeFromBase64(
hasUrlAlphabet && hasStandardAlphabet ? normalizedStandard : normalized,
);
}
function bytesViewToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const { buffer, byteOffset, byteLength } = bytes;
if (buffer instanceof ArrayBuffer) {
if (byteOffset === 0 && byteLength === buffer.byteLength) return buffer;
return buffer.slice(byteOffset, byteOffset + byteLength);
}
const out = new ArrayBuffer(byteLength);
new Uint8Array(out).set(bytes);
return out;
}
export function bytesToBase64(bytes: Uint8Array): string {
if (typeof nativeToBase64 === "function") {
return nativeToBase64.call(bytes);
}
return encodeWithLegacyBase64(bytes);
}
export function arrayBufferToBase64(ab: ArrayBuffer): string {
return bytesToBase64(new Uint8Array(ab));
}
export function base64ToBytes(b64: string): Uint8Array {
return decodeBase64ToBytes(b64);
}
export function base64ToArrayBuffer(b64: string): ArrayBuffer {
return bytesViewToArrayBuffer(base64ToBytes(b64));
}

View file

@ -1,4 +1,102 @@
import { base64ToBytes, bytesToBase64 } from "./base64";
type FromBase64Options = {
alphabet?: "base64" | "base64url";
lastChunkHandling?: "loose" | "strict" | "stop-before-partial";
};
type Uint8ArrayPrototypeWithBase64 = {
toBase64?: (this: Uint8Array) => string;
};
type Uint8ArrayConstructorWithBase64 = Uint8ArrayConstructor & {
fromBase64?: (input: string, options?: FromBase64Options) => Uint8Array;
};
const nativeToBase64 =
typeof (Uint8Array.prototype as Uint8ArrayPrototypeWithBase64).toBase64 ===
"function"
? (Uint8Array.prototype as Uint8ArrayPrototypeWithBase64).toBase64
: null;
const nativeFromBase64 = (Uint8Array as Uint8ArrayConstructorWithBase64)
.fromBase64;
const BASE64_URL_ALPHABET_RE = /[-_]/;
function normalizeBase64Input(input: string): string {
const withoutWhitespace = String(input).replaceAll(/\s+/g, "");
const remainder = withoutWhitespace.length % 4;
if (remainder === 1) {
throw new TypeError("Invalid base64 input.");
}
if (remainder === 0) return withoutWhitespace;
return withoutWhitespace + "=".repeat(4 - remainder);
}
function bytesToBinaryString(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return binary;
}
function bytesViewToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const { buffer, byteOffset, byteLength } = bytes;
if (buffer instanceof ArrayBuffer) {
if (byteOffset === 0 && byteLength === buffer.byteLength) return buffer;
return buffer.slice(byteOffset, byteOffset + byteLength);
}
const out = new ArrayBuffer(byteLength);
new Uint8Array(out).set(bytes);
return out;
}
export function bytesToBase64(bytes: Uint8Array): string {
if (typeof nativeToBase64 === "function") {
return nativeToBase64.call(bytes);
}
const btoaFn = globalThis.btoa;
if (typeof btoaFn !== "function") {
throw new TypeError("Base64 encoder is not available in this environment.");
}
return btoaFn(bytesToBinaryString(bytes));
}
export function arrayBufferToBase64(ab: ArrayBuffer): string {
return bytesToBase64(new Uint8Array(ab));
}
export function base64ToBytes(input: string): Uint8Array {
const normalized = normalizeBase64Input(input);
const normalizedStandard = normalized
.replaceAll("-", "+")
.replaceAll("_", "/");
if (typeof nativeFromBase64 === "function") {
const hasUrlAlphabet = BASE64_URL_ALPHABET_RE.test(normalized);
return nativeFromBase64(
hasUrlAlphabet ? normalizedStandard : normalized,
hasUrlAlphabet ? { alphabet: "base64" } : undefined,
);
}
const atobFn = globalThis.atob;
if (typeof atobFn !== "function") {
throw new TypeError("Base64 decoder is not available in this environment.");
}
const binary = atobFn(normalizedStandard);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
export function base64ToArrayBuffer(b64: string): ArrayBuffer {
return bytesViewToArrayBuffer(base64ToBytes(b64));
}
/**
* Shared helpers for serializing request bodies across the extension layers.
@ -134,11 +232,7 @@ function toUint8FromNumericArray(
}
function viewAsBytes(view: ArrayBufferView): Uint8Array {
return new Uint8Array(
view.buffer as ArrayBufferLike,
view.byteOffset,
view.byteLength,
);
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
}
function copyArrayBufferView(view: ArrayBufferView): Uint8Array {
@ -249,121 +343,142 @@ function tryCoerceByteLikeObjectToUint8Array(
return toUint8FromNumericArray(v as unknown[]);
}
// Node.js Buffer JSON form: { type: "Buffer", data: number[] }
if (obj.type === "Buffer" && Array.isArray(obj.data)) {
const fromBufferJson = toUint8FromNumericArray(obj.data);
if (fromBufferJson) return fromBufferJson;
}
const wrappedBytes = tryReadWrappedBytes(obj);
if (wrappedBytes) return wrappedBytes;
// Some runtimes wrap bytes as { data: number[] } / { bytes: number[] }.
if (Array.isArray(obj.data)) {
const fromData = toUint8FromNumericArray(obj.data);
if (fromData) return fromData;
}
if (Array.isArray(obj.bytes)) {
const fromBytes = toUint8FromNumericArray(obj.bytes);
if (fromBytes) return fromBytes;
}
// Base64 wrappers seen in bridge payloads.
if (typeof obj.b64 === "string") {
try {
return base64ToBytes(obj.b64);
} catch {
// ignore and continue
}
}
if (typeof obj.base64 === "string") {
try {
return base64ToBytes(obj.base64);
} catch {
// ignore and continue
}
}
const base64WrappedBytes = tryDecodeWrappedBase64(obj);
if (base64WrappedBytes) return base64WrappedBytes;
// TypedArray-like wrapper: { buffer, byteOffset, byteLength }.
const byteLength = toSafeLength(obj.byteLength);
const byteOffset = toSafeLength(obj.byteOffset ?? 0);
if (byteLength !== null && byteOffset !== null) {
const rawBuffer = obj.buffer;
if (isArrayBufferLike(rawBuffer)) {
try {
return new Uint8Array(
rawBuffer as ArrayBuffer,
byteOffset,
byteLength,
).slice();
} catch {
// ignore and continue
}
}
// In some cross-world cases `buffer` itself is coerced to a plain object.
if (rawBuffer && rawBuffer !== v) {
const recoveredBuffer = tryCoerceByteLikeObjectToUint8Array(
rawBuffer,
depth + 1,
);
if (recoveredBuffer) {
if (byteOffset > recoveredBuffer.byteLength) return null;
const end = Math.min(
recoveredBuffer.byteLength,
byteOffset + byteLength,
);
return recoveredBuffer.slice(byteOffset, end);
}
}
}
const typedArrayBytes = tryReadTypedArrayLikeObject(obj, v, depth);
if (typedArrayBytes) return typedArrayBytes;
// Array-like wrappers with numeric indexes + length.
const length = toSafeLength(obj.length);
if (length !== null) {
const indexed = obj as Record<number, unknown>;
const out = new Uint8Array(length);
for (let i = 0; i < length; i += 1) {
const byte = toByte(indexed[i]);
if (byte === null) {
// Not a true numeric array-like container.
return null;
}
out[i] = byte;
}
return out;
}
const arrayLikeBytes = tryReadArrayLikeObject(obj);
if (arrayLikeBytes) return arrayLikeBytes;
// A plain object with numeric keys: {"0": 10, "1": 20, ...}
if (isPlainObject(v)) {
const keys = Object.keys(obj);
if (!keys.length) return null;
// Only consider objects whose keys are all non-negative integers.
const indexes = new Array<number>(keys.length);
let max = -1;
for (let i = 0; i < keys.length; i += 1) {
const idx = parseNonNegativeIntegerKey(keys[i]);
if (idx === null || idx > MAX_RECOVERABLE_BYTES) return null;
indexes[i] = idx;
if (idx > max) max = idx;
}
// Sparse numeric object where only a few indexes are present.
// Keep old behavior for compatibility with prior bridge payloads.
const out = new Uint8Array(max + 1);
for (let i = 0; i < keys.length; i += 1) {
const key = keys[i];
const byte = toByte(obj[key]);
if (byte === null) return null;
out[indexes[i]] = byte;
}
return out;
return tryReadSparseNumericObject(obj);
}
return null;
}
function tryReadWrappedBytes(obj: Record<string, any>): Uint8Array | null {
const numericArrays = [
obj.type === "Buffer" && Array.isArray(obj.data) ? obj.data : null,
Array.isArray(obj.data) ? obj.data : null,
Array.isArray(obj.bytes) ? obj.bytes : null,
];
for (const source of numericArrays) {
if (!source) continue;
const bytes = toUint8FromNumericArray(source);
if (bytes) return bytes;
}
return null;
}
function tryDecodeWrappedBase64(obj: Record<string, any>): Uint8Array | null {
const candidates = [obj.b64, obj.base64];
for (const candidate of candidates) {
if (typeof candidate !== "string") continue;
try {
return base64ToBytes(candidate);
} catch {
// ignore and continue
}
}
return null;
}
function tryReadTypedArrayLikeObject(
obj: Record<string, any>,
originalValue: unknown,
depth: number,
): Uint8Array | null {
const byteLength = toSafeLength(obj.byteLength);
const byteOffset = toSafeLength(obj.byteOffset ?? 0);
if (byteLength === null || byteOffset === null) {
return null;
}
const rawBuffer = obj.buffer;
if (isArrayBufferLike(rawBuffer)) {
try {
return new Uint8Array(rawBuffer, byteOffset, byteLength).slice();
} catch {
// ignore and continue
}
}
if (!rawBuffer || rawBuffer === originalValue) {
return null;
}
const recoveredBuffer = tryCoerceByteLikeObjectToUint8Array(
rawBuffer,
depth + 1,
);
if (!recoveredBuffer) {
return null;
}
if (byteOffset > recoveredBuffer.byteLength) {
return null;
}
const end = Math.min(recoveredBuffer.byteLength, byteOffset + byteLength);
return recoveredBuffer.slice(byteOffset, end);
}
function tryReadArrayLikeObject(obj: Record<string, any>): Uint8Array | null {
const length = toSafeLength(obj.length);
if (length === null) {
return null;
}
const indexed = obj as Record<number, unknown>;
const out = new Uint8Array(length);
for (let i = 0; i < length; i += 1) {
const byte = toByte(indexed[i]);
if (byte === null) {
return null;
}
out[i] = byte;
}
return out;
}
function tryReadSparseNumericObject(
obj: Record<string, any>,
): Uint8Array | null {
const keys = Object.keys(obj);
if (!keys.length) return null;
const indexes = new Array<number>(keys.length);
let max = -1;
for (let i = 0; i < keys.length; i += 1) {
const idx = parseNonNegativeIntegerKey(keys[i]);
if (idx === null || idx > MAX_RECOVERABLE_BYTES) return null;
indexes[i] = idx;
if (idx > max) max = idx;
}
const out = new Uint8Array(max + 1);
for (let i = 0; i < keys.length; i += 1) {
const byte = toByte(obj[keys[i]]);
if (byte === null) return null;
out[indexes[i]] = byte;
}
return out;
}
export function coerceBodyToBytes(body: any): Uint8Array | null {
return tryCoerceByteLikeObjectToUint8Array(body);
}
@ -373,65 +488,14 @@ export function summarizeBodyForDebug(body: any): BodyDebugSummary {
const tag = safeObjectTag(body);
const ctor = safeConstructorName(body);
if (body === null || body === undefined) {
return { kind: "empty", jsType, tag, ctor };
}
const primitiveSummary = summarizePrimitiveBody(body, jsType, tag, ctor);
if (primitiveSummary) return primitiveSummary;
if (typeof body === "string") {
return { kind: "string", jsType, tag, ctor, textLength: body.length };
}
const serializedSummary = summarizeSerializedBody(body, jsType, tag, ctor);
if (serializedSummary) return serializedSummary;
if (isSerializedBodyEnvelope(body)) {
const kind =
typeof body.kind === "string" && body.kind ? body.kind : "bytes";
return {
kind: `serialized:${kind}`,
jsType,
tag,
ctor,
base64Length: body.b64.length,
mime: safeStringProp(body, "mime"),
};
}
if (isArrayBufferLike(body)) {
return {
kind: "ArrayBuffer",
jsType,
tag,
ctor,
byteLength: (body as ArrayBuffer).byteLength,
};
}
try {
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
return {
kind: ctor ? `TypedArray:${ctor}` : "TypedArray",
jsType,
tag,
ctor,
byteLength: view.byteLength,
};
}
} catch {
// ignore and continue
}
if (isBlobLike(body)) {
return {
kind: "BlobLike",
jsType,
tag,
ctor,
byteLength:
typeof (body as any).size === "number"
? Number((body as any).size)
: -1,
mime: safeStringProp(body, "type"),
};
}
const binarySummary = summarizeBinaryBody(body, jsType, tag, ctor);
if (binarySummary) return binarySummary;
const coerced = coerceBodyToBytes(body);
if (coerced) {
@ -459,6 +523,89 @@ export function summarizeBodyForDebug(body: any): BodyDebugSummary {
return { kind: "primitive", jsType, tag, ctor };
}
function summarizePrimitiveBody(
body: any,
jsType: string,
tag: string,
ctor: string,
): BodyDebugSummary | null {
if (body === null || body === undefined) {
return { kind: "empty", jsType, tag, ctor };
}
if (typeof body === "string") {
return { kind: "string", jsType, tag, ctor, textLength: body.length };
}
return null;
}
function summarizeSerializedBody(
body: any,
jsType: string,
tag: string,
ctor: string,
): BodyDebugSummary | null {
if (!isSerializedBodyEnvelope(body)) {
return null;
}
const kind = typeof body.kind === "string" && body.kind ? body.kind : "bytes";
return {
kind: `serialized:${kind}`,
jsType,
tag,
ctor,
base64Length: body.b64.length,
mime: safeStringProp(body, "mime"),
};
}
function summarizeBinaryBody(
body: any,
jsType: string,
tag: string,
ctor: string,
): BodyDebugSummary | null {
if (isArrayBufferLike(body)) {
return {
kind: "ArrayBuffer",
jsType,
tag,
ctor,
byteLength: body.byteLength,
};
}
try {
if (ArrayBuffer.isView(body)) {
return {
kind: ctor ? `TypedArray:${ctor}` : "TypedArray",
jsType,
tag,
ctor,
byteLength: body.byteLength,
};
}
} catch {
// ignore and continue
}
if (!isBlobLike(body)) {
return null;
}
return {
kind: "BlobLike",
jsType,
tag,
ctor,
byteLength:
typeof (body as any).size === "number" ? Number((body as any).size) : -1,
mime: safeStringProp(body, "type"),
};
}
/**
* Serialize a GM_xmlhttpRequest body so it can be transported over
* `postMessage` / extension messaging.
@ -540,18 +687,24 @@ export function decodeSerializedBody(body: any): BodyInit | undefined {
if (body === null || body === undefined) return undefined;
if (typeof body === "string") return body;
if (isSerializedBodyEnvelope(body)) {
const bytes = base64ToBytes(body.b64);
const kind =
typeof body.kind === "string" && body.kind ? body.kind : "bytes";
if (kind === "blob") {
const mime = body.mime;
const ab = bytes.buffer as ArrayBuffer;
return typeof mime === "string" && mime
? new Blob([ab], { type: mime })
: new Blob([ab]);
if (isArrayBufferLike(body)) {
return body as unknown as BodyInit;
}
try {
if (ArrayBuffer.isView(body)) {
return viewAsBytes(body) as unknown as BodyInit;
}
return bytes as unknown as BodyInit;
} catch {
// ignore and continue
}
if (isBlobLike(body)) {
return body as unknown as BodyInit;
}
if (isSerializedBodyEnvelope(body)) {
return decodeSerializedEnvelope(body);
}
// Recovery for unexpected cross-world payload shapes.
@ -567,3 +720,17 @@ export function decodeSerializedBody(body: any): BodyInit | undefined {
return String(body);
}
function decodeSerializedEnvelope(body: SerializedBodyEnvelope): BodyInit {
const bytes = base64ToBytes(body.b64);
const kind = typeof body.kind === "string" && body.kind ? body.kind : "bytes";
if (kind !== "blob") {
return bytes as unknown as BodyInit;
}
const mime = body.mime;
const ab = bytes.buffer as ArrayBuffer;
return typeof mime === "string" && mime
? new Blob([ab], { type: mime })
: new Blob([ab]);
}

View file

@ -1,228 +1,39 @@
import debug from "../utils/debug";
import { toErrorMessage } from "../utils/errors";
import { base64ToArrayBuffer } from "./base64";
import {
isBodySerializedForPort,
serializeBodyForPort,
summarizeBodyForDebug,
} from "./bodySerialization";
import { handleBridgeRequest } from "./bridgeRequestRuntime";
import { toPageMessage } from "./bridgeTransport";
import { abortBridgeXhr, startBridgeXhr } from "./bridgeXhr";
import {
type AnyObject,
type BridgeWireMessage,
isOurMessage,
PORT_NAME,
TYPE_NOTIFY,
TYPE_REQ,
TYPE_RES,
TYPE_XHR_ABORT,
TYPE_XHR_ACK,
TYPE_XHR_EVENT,
TYPE_XHR_START,
} from "./constants";
import { ext, storageGet, storageRemove, storageSet } from "./webext";
import { isYandexApiHostname, shouldStripYandexHeader } from "./yandexHeaders";
import { ext } from "./webext";
const BRIDGE_BOOT_KEY = "__VOT_EXT_BRIDGE_BOOTED__";
/**
* VOT Extension bridge (ISOLATED world content script).
*
* MAIN-world content scripts have direct access to the page JS context but do
* NOT have access to WebExtension APIs (runtime/storage/etc.).
*
* This file runs in the default (ISOLATED) content-script world and exposes a
* minimal "userscript" API surface to the MAIN-world bundle via
* globalThis.postMessage:
* - GM.getValue / setValue / deleteValue / listValues / getValues
* - GM_xmlhttpRequest (proxied through the background service worker)
* - GM_notification (proxied through the background service worker)
*/
type UaBrandVersion = { brand: string; version: string };
const UA_CH_CACHE_TTL_MS = 10 * 60 * 1000;
const UA_CH_HIGH_ENTROPY_HINTS = ["fullVersionList"];
const TERMINAL_XHR_EVENT_TYPES = new Set(["load", "error", "timeout", "abort"]);
const EMPTY_HEADERS = Object.freeze({}) as Readonly<Record<string, string>>;
let cachedUaChHeaders: Readonly<Record<string, string>> = EMPTY_HEADERS;
let cachedUaChHeadersExpiresAt = 0;
let cachedUaChHeadersPromise: Promise<Readonly<Record<string, string>>> | null =
null;
const ESCAPED_DOUBLE_QUOTE = String.raw`\"`;
// -- UA Client Hints helpers -------------------------------------------------
//
// Some endpoints (notably api.browser.yandex.ru) validate that requests look
// like they were initiated by a real Chromium tab. When we proxy requests via
// the extension service worker, Chromium may omit high-entropy UA-CH headers.
//
// We collect UA-CH from the tab (content-script context) and forward them as
// request headers. The background service worker then injects them using
// declarativeNetRequest.modifyHeaders because `sec-ch-ua*` headers are
// forbidden to set from fetch/XHR.
//
// This mirrors the overall architecture used by userscript managers such as
// ScriptCat (content-side API -> privileged background operations).
function escapeHeaderValue(value: string): string {
// Avoid breaking quoted header values.
return value.replaceAll('"', ESCAPED_DOUBLE_QUOTE);
}
function formatUaBrands(brands: UaBrandVersion[]): string {
return brands
.filter(
(b) => b && typeof b.brand === "string" && typeof b.version === "string",
)
.map(
(b) =>
`"${escapeHeaderValue(b.brand)}";v="${escapeHeaderValue(b.version)}"`,
)
.join(", ");
}
/**
* Return the minimal UA-CH header set required by the valid request capture.
*
* Important: do NOT include other high-entropy UA-CH headers (arch/bitness/
* platform-version/full-version/model). Those extra headers were present in
* the invalid request capture and must not be emitted.
*/
async function getUaChHeaders(): Promise<Record<string, string>> {
const uaData = (
navigator as Navigator & {
userAgentData?: {
brands?: UaBrandVersion[];
uaList?: UaBrandVersion[];
mobile?: boolean;
platform?: string;
getHighEntropyValues?: (
values: string[],
) => Promise<{ fullVersionList?: UaBrandVersion[] }>;
};
}
)?.userAgentData;
if (!uaData) return {};
const headers: Record<string, string> = {};
const brands: UaBrandVersion[] =
(Array.isArray(uaData.brands) && uaData.brands) ||
// Some Chromium variants used `uaList` historically.
(Array.isArray(uaData.uaList) && uaData.uaList) ||
[];
if (brands.length) headers["sec-ch-ua"] = formatUaBrands(brands);
if (typeof uaData.mobile === "boolean")
headers["sec-ch-ua-mobile"] = uaData.mobile ? "?1" : "?0";
if (typeof uaData.platform === "string" && uaData.platform)
headers["sec-ch-ua-platform"] = `"${escapeHeaderValue(uaData.platform)}"`;
// Only request the high entropy value required by the valid capture:
// `sec-ch-ua-full-version-list`.
try {
const high = await uaData.getHighEntropyValues?.(UA_CH_HIGH_ENTROPY_HINTS);
if (Array.isArray(high?.fullVersionList) && high.fullVersionList.length) {
headers["sec-ch-ua-full-version-list"] = formatUaBrands(
high.fullVersionList,
);
}
} catch {
// High entropy values are optional; ignore failures.
function injectPageModule(fileName: string): void {
const parent = document.head ?? document.documentElement;
if (!parent) {
console.error("[VOT Extension] bridge: missing document root");
return;
}
return headers;
}
function freezeHeaders(
headers: Record<string, string>,
): Readonly<Record<string, string>> {
return Object.freeze({ ...headers });
}
async function getCachedUaChHeaders(): Promise<
Readonly<Record<string, string>>
> {
const now = Date.now();
if (now < cachedUaChHeadersExpiresAt) {
return cachedUaChHeaders;
}
if (cachedUaChHeadersPromise !== null) {
return await cachedUaChHeadersPromise;
}
cachedUaChHeadersPromise = (async () => {
const headers = freezeHeaders(await getUaChHeaders());
cachedUaChHeaders = headers;
cachedUaChHeadersExpiresAt = Date.now() + UA_CH_CACHE_TTL_MS;
return headers;
})();
try {
return await cachedUaChHeadersPromise;
} finally {
cachedUaChHeadersPromise = null;
}
}
function ensureHeadersObject(details: AnyObject): Record<string, string> {
const raw = details?.headers;
if (!raw || typeof raw !== "object") {
const headers: Record<string, string> = {};
details.headers = headers;
return headers;
}
// Normalize in place and drop unsupported values.
const headers = raw as Record<string, unknown>;
for (const [name, value] of Object.entries(headers)) {
if (typeof value === "string") continue;
if (typeof value === "number" || typeof value === "boolean") {
headers[name] = String(value);
continue;
}
delete headers[name];
}
details.headers = headers;
return headers as Record<string, string>;
}
function stripYandexHeaders(headers: Record<string, string>): void {
for (const headerName of Object.keys(headers)) {
if (shouldStripYandexHeader(headerName)) {
delete headers[headerName];
}
}
}
function mergeHeadersIfMissing(
headers: Record<string, string>,
additions: Readonly<Record<string, string>>,
): void {
const existingNames = new Set<string>();
for (const name of Object.keys(headers)) {
existingNames.add(name.toLowerCase());
}
for (const [name, value] of Object.entries(additions)) {
if (!value) continue;
const normalizedName = name.toLowerCase();
if (existingNames.has(normalizedName)) continue;
headers[name] = value;
existingNames.add(normalizedName);
}
}
function getHostname(url: string): string {
if (!url) return "";
try {
return new URL(url).hostname;
} catch {
return "";
}
const script = document.createElement("script");
script.type = "module";
script.src = ext.runtime?.getURL(fileName);
script.addEventListener(
"error",
() => {
console.error(`[VOT Extension] bridge: failed to inject ${fileName}`);
},
{ once: true },
);
parent.appendChild(script);
}
function postToPage(payload: AnyObject) {
@ -234,396 +45,6 @@ function postToPage(payload: AnyObject) {
globalThis.postMessage(message, "*");
}
type XhrPortState = {
port: {
onMessage: { addListener: (fn: (msg: AnyObject) => void) => void };
onDisconnect: { addListener: (fn: () => void) => void };
postMessage: (msg: AnyObject) => void;
disconnect: () => void;
};
responseType: string;
chunks: ArrayBuffer[];
totalBytes: number;
settled: boolean;
};
const xhrPorts = new Map<string, XhrPortState>();
function disconnectPortSafely(port: XhrPortState["port"]): void {
try {
port.disconnect();
} catch {
// ignore
}
}
function settleXhrPort(requestId: string, state: XhrPortState): void {
state.settled = true;
disconnectPortSafely(state.port);
xhrPorts.delete(requestId);
}
function isRequestStateActive(
requestId: string,
expectedState: XhrPortState,
): boolean {
const currentState = xhrPorts.get(requestId);
return currentState === expectedState && !currentState.settled;
}
function toLifecycleState(kind: string): "in_flight" | "terminal" {
return kind === "progress" ? "in_flight" : "terminal";
}
function postXhrEvent(requestId: string, payload: AnyObject): void {
const kind = String(payload?.type ?? "");
postToPage({
type: TYPE_XHR_EVENT,
requestId,
payload: {
...payload,
state: toLifecycleState(kind),
},
});
}
function makeBridgeXhrError(details: AnyObject, error: string): AnyObject {
return {
finalUrl: String(details?.url || ""),
readyState: 4,
status: 0,
statusText: "",
responseHeaders: "",
response: null,
responseText: "",
error,
};
}
function concatArrayBuffers(
chunks: ArrayBuffer[],
totalBytes: number,
): ArrayBuffer {
const out = new Uint8Array(totalBytes);
let offset = 0;
for (const ab of chunks) {
const u8 = new Uint8Array(ab);
out.set(u8, offset);
offset += u8.byteLength;
}
return out.buffer;
}
function resolveBinaryResponseBuffer(
directResponse: unknown,
chunks: ArrayBuffer[],
totalBytes: number,
fallbackB64: unknown,
): ArrayBuffer {
if (directResponse instanceof ArrayBuffer) {
return directResponse;
}
if (totalBytes > 0) {
return concatArrayBuffers(chunks, totalBytes);
}
if (typeof fallbackB64 === "string" && fallbackB64.length > 0) {
return base64ToArrayBuffer(fallbackB64);
}
return new ArrayBuffer(0);
}
async function startXhr(requestId: string, details: AnyObject) {
const normalizedRequestId = String(requestId || "");
const safeDetails: AnyObject = details ?? {};
try {
if (!normalizedRequestId) {
throw new Error("Missing requestId for bridge XHR");
}
requestId = normalizedRequestId;
if (xhrPorts.has(requestId)) {
debug.warn("[VOT EXT][bridge] replacing active XHR request", {
requestId,
});
abortXhr(requestId);
}
debug.log("[VOT EXT][bridge] startXhr", {
requestId,
url: safeDetails?.url,
method: safeDetails?.method,
responseType: safeDetails?.responseType,
timeoutMs: Number(safeDetails?.timeout ?? 0),
headerCount:
safeDetails?.headers && typeof safeDetails.headers === "object"
? Object.keys(safeDetails.headers).length
: 0,
body: summarizeBodyForDebug(safeDetails?.data),
});
const connected = ext?.runtime?.connect?.({ name: PORT_NAME });
if (!connected || typeof connected !== "object") {
throw new Error("Bridge port is not available");
}
const port = connected as XhrPortState["port"];
const responseType = String(
safeDetails?.responseType || "text",
).toLowerCase();
const state: XhrPortState = {
port,
responseType,
chunks: [],
totalBytes: 0,
settled: false,
};
xhrPorts.set(requestId, state);
postToPage({
type: TYPE_XHR_ACK,
requestId,
payload: {
state: "acknowledged",
timeoutMs: Number(safeDetails?.timeout ?? 0),
responseType,
ts: Date.now(),
},
});
port.onMessage.addListener((msg: AnyObject) => {
const st = xhrPorts.get(requestId);
if (!st || st.settled) return;
if (!msg || typeof msg !== "object") return;
debug.log("[VOT EXT][bridge] port message", {
requestId,
kind: msg.type ?? "unknown",
state: msg.state ?? null,
status:
msg.response?.status ??
msg.error?.status ??
msg.progress?.status ??
null,
loaded: msg.progress?.loaded ?? null,
total: msg.progress?.total ?? null,
});
// Decode binary chunks coming from the service worker (sent as base64
// because extension messaging only supports JSON-serializable payloads).
if (msg.type === "progress" && msg.progress) {
const b64 = msg.progress.chunkB64;
if (typeof b64 === "string" && b64.length) {
const ab = base64ToArrayBuffer(b64);
// Important: `msg.progress.chunk` is posted to MAIN world with
// transferables. That detaches the transferred ArrayBuffer in this
// realm, so we must keep our own copy for final load aggregation.
const aggregateCopy = ab.slice(0);
st.chunks.push(aggregateCopy);
st.totalBytes += aggregateCopy.byteLength;
msg.progress.chunk = ab;
delete msg.progress.chunkB64;
}
}
if (msg.type === "load" && msg.response) {
const rt = String(
msg.response.responseType || st.responseType || "text",
).toLowerCase();
if (rt === "arraybuffer" || rt === "blob") {
const directResponse = msg.response.response;
const fallbackB64 = msg.response.responseB64;
const ab = resolveBinaryResponseBuffer(
directResponse,
st.chunks,
st.totalBytes,
fallbackB64,
);
delete msg.response.responseB64;
st.chunks.length = 0;
st.totalBytes = 0;
if (rt === "blob") {
const ct =
msg.response.contentType || msg.response.mime || undefined;
msg.response.response = ct
? new Blob([ab], { type: String(ct) })
: new Blob([ab]);
} else {
msg.response.response = ab;
}
}
}
postXhrEvent(requestId, msg);
// Close port for terminal events.
if (TERMINAL_XHR_EVENT_TYPES.has(String(msg.type ?? ""))) {
debug.log("[VOT EXT][bridge] terminal event", {
requestId,
kind: msg.type,
status: msg.response?.status ?? msg.error?.status ?? null,
});
settleXhrPort(requestId, st);
}
});
port.onDisconnect.addListener(() => {
const st = xhrPorts.get(requestId);
if (!st || st.settled) return;
debug.warn("[VOT EXT][bridge] port disconnected before terminal event", {
requestId,
url: safeDetails?.url ?? null,
});
settleXhrPort(requestId, st);
postXhrEvent(requestId, {
type: "error",
error: makeBridgeXhrError(
safeDetails,
"Bridge port disconnected before response",
),
});
});
const urlStr = String(safeDetails?.url ?? "");
const hostname = getHostname(urlStr);
if (isYandexApiHostname(hostname)) {
const headers = ensureHeadersObject(safeDetails);
stripYandexHeaders(headers);
const uaCh = await getCachedUaChHeaders();
if (!isRequestStateActive(requestId, state)) return;
mergeHeadersIfMissing(headers, uaCh);
debug.log("[VOT EXT][bridge] yandex header normalization", {
requestId,
url: urlStr,
headerCount: Object.keys(headers).length,
headerNames: Object.keys(headers),
});
}
if (!isRequestStateActive(requestId, state)) return;
// Chrome extension messaging uses JSON serialization, so prelude serializes
// the request body before crossing worlds. Keep a defensive fallback for
// unexpected callers that bypass prelude.
const data = isBodySerializedForPort(safeDetails?.data)
? safeDetails.data
: await serializeBodyForPort(safeDetails?.data);
const serializedBodySummary = summarizeBodyForDebug(data);
debug.log("[VOT EXT][bridge] serialized body", {
requestId,
url: safeDetails?.url ?? null,
from: summarizeBodyForDebug(safeDetails?.data),
to: serializedBodySummary,
});
// The request could be aborted while we're async-serializing (Blob -> bytes).
// If so, avoid starting a network request.
if (!isRequestStateActive(requestId, state)) return;
const serializedDetails: AnyObject = {
...safeDetails,
data,
responseType: safeDetails?.responseType,
};
debug.log("[VOT EXT][bridge] post start to background", {
requestId,
url: serializedDetails.url,
method: serializedDetails.method,
responseType: serializedDetails.responseType,
body: serializedBodySummary,
});
state.port.postMessage({ type: "start", details: serializedDetails });
} catch (error: unknown) {
const requestKey = normalizedRequestId || requestId;
const st = xhrPorts.get(requestKey);
if (st && !st.settled) {
settleXhrPort(requestKey, st);
}
const errorMessage = toErrorMessage(error);
debug.log("[VOT EXT][bridge] startXhr error", {
requestId: requestKey,
error: errorMessage,
lastError: ext?.runtime?.lastError ?? null,
});
if (requestKey) {
postXhrEvent(requestKey, {
type: "error",
error: makeBridgeXhrError(safeDetails, errorMessage),
});
}
}
}
function abortXhr(requestId: string) {
const st = xhrPorts.get(requestId);
if (!st || st.settled) return;
st.settled = true;
debug.warn("[VOT EXT][bridge] abortXhr", { requestId });
try {
st.port.postMessage({ type: "abort" });
} catch {
// ignore
}
disconnectPortSafely(st.port);
xhrPorts.delete(requestId);
}
async function handleRequest(
action: string,
payload: AnyObject,
): Promise<unknown> {
switch (action) {
case "handshake": {
// Provide manifest metadata to the MAIN-world prelude.
const manifest = ext?.runtime?.getManifest?.() ?? {};
const id = ext?.runtime?.id ?? null;
return { manifest, id };
}
case "gm_getValue": {
const key = String(payload?.key ?? "");
const def = payload?.def;
const items = await storageGet({ [key]: def });
return items[key];
}
case "gm_setValue": {
const key = String(payload?.key ?? "");
await storageSet({ [key]: payload?.value });
return true;
}
case "gm_deleteValue": {
const key = String(payload?.key ?? "");
await storageRemove(key);
return true;
}
case "gm_listValues": {
const items = await storageGet(null);
return Object.keys(items ?? {});
}
case "gm_getValues": {
const defaults = payload?.defaults ?? {};
const items = await storageGet(defaults);
return items;
}
default:
throw new Error(`Unknown bridge action: ${action}`);
}
}
function sendResponse(
id: string,
ok: boolean,
@ -633,66 +54,68 @@ function sendResponse(
postToPage({ type: TYPE_RES, id, ok, result, error });
}
// Guard: if the bridge cannot access extension APIs, there is nothing useful
// we can do.
const bridgeGlobal = globalThis as Record<string, unknown>;
if (bridgeGlobal[BRIDGE_BOOT_KEY]) {
debug.log("[VOT EXT][bridge] already initialized");
} else {
export function bootstrapExtensionBridge(): void {
const bridgeGlobal = globalThis as Record<string, unknown>;
if (bridgeGlobal[BRIDGE_BOOT_KEY]) {
debug.log("[VOT EXT][bridge] already initialized");
return;
}
bridgeGlobal[BRIDGE_BOOT_KEY] = true;
if (!ext?.runtime || !ext?.storage?.local) {
console.warn("[VOT Extension] bridge: missing WebExtension APIs");
} else {
globalThis.addEventListener("message", async (event) => {
if (event.source !== globalThis.window) return;
const data = event.data as BridgeWireMessage;
if (!isOurMessage(data)) return;
try {
if (data.type === TYPE_REQ) {
const id = String(data.id ?? "");
const action = String(data.action ?? "");
const payload = (data.payload ?? {}) as AnyObject;
const result = await handleRequest(action, payload);
sendResponse(id, true, result);
return;
}
if (data.type === TYPE_NOTIFY) {
// Relay to background so we can use the privileged notifications API.
ext?.runtime?.sendMessage?.({
type: "gm_notification",
details: data.details,
});
return;
}
if (data.type === TYPE_XHR_START) {
startXhr(
String(data.requestId ?? ""),
(data.details ?? {}) as AnyObject,
);
return;
}
if (data.type === TYPE_XHR_ABORT) {
abortXhr(String(data.requestId ?? ""));
return;
}
} catch (err: unknown) {
// Best-effort error reporting back to the page (only for REQ messages).
if (data?.type === TYPE_REQ) {
sendResponse(
String(data.id ?? ""),
false,
undefined,
err instanceof Error ? err.message : String(err),
);
} else {
console.error("[VOT Extension] bridge error", err);
}
}
});
return;
}
injectPageModule("prelude.module.js");
injectPageModule("content.module.js");
globalThis.addEventListener("message", async (event) => {
if (event.source !== globalThis.window) return;
const data = event.data as BridgeWireMessage;
if (!isOurMessage(data)) return;
try {
if (data.type === TYPE_REQ) {
const id = String(data.id ?? "");
const action = String(data.action ?? "");
const payload = data.payload ?? {};
const result = await handleBridgeRequest(action, payload);
sendResponse(id, true, result);
return;
}
if (data.type === TYPE_NOTIFY) {
ext?.runtime?.sendMessage?.({
type: "gm_notification",
details: data.details,
});
return;
}
if (data.type === TYPE_XHR_START) {
await startBridgeXhr(String(data.requestId ?? ""), data.details ?? {});
return;
}
if (data.type === TYPE_XHR_ABORT) {
abortBridgeXhr(String(data.requestId ?? ""));
return;
}
} catch (err: unknown) {
if (data?.type === TYPE_REQ) {
sendResponse(
String(data.id ?? ""),
false,
undefined,
err instanceof Error ? err.message : String(err),
);
} else {
console.error("[VOT Extension] bridge error", err);
}
}
});
}
bootstrapExtensionBridge();

View file

@ -0,0 +1,81 @@
import type { AnyObject } from "./constants";
import { ext } from "./webext";
export const GM_STORAGE_MESSAGE_TYPE = "gm_storage";
async function sendRuntimeMessage<T = unknown>(message: AnyObject): Promise<T> {
const sendMessage = ext?.runtime?.sendMessage;
if (typeof sendMessage !== "function") {
throw new TypeError("runtime.sendMessage is not available");
}
return await new Promise<T>((resolve, reject) => {
try {
const maybePromise = sendMessage(message, (response: unknown) => {
const runtimeError =
(
globalThis as {
chrome?: { runtime?: { lastError?: { message?: string } } };
}
).chrome?.runtime?.lastError?.message ?? null;
if (runtimeError) {
reject(new Error(runtimeError));
return;
}
resolve(response as T);
});
if (
maybePromise &&
typeof (maybePromise as Promise<T>).then === "function"
) {
void (maybePromise as Promise<T>).then(resolve, reject);
}
} catch (error) {
reject(error);
}
});
}
async function requestStorage(
action: string,
payload: AnyObject,
): Promise<unknown> {
const response = await sendRuntimeMessage<{
ok?: boolean;
result?: unknown;
error?: string;
}>({
type: GM_STORAGE_MESSAGE_TYPE,
action,
payload,
});
if (!response?.ok) {
throw new Error(response?.error || `Storage request failed: ${action}`);
}
return response.result;
}
export async function handleBridgeRequest(
action: string,
payload: AnyObject,
): Promise<unknown> {
switch (action) {
case "handshake": {
const manifest = ext?.runtime?.getManifest?.() ?? {};
const id = ext?.runtime?.id ?? null;
return { manifest, id };
}
case "gm_getValue":
case "gm_setValue":
case "gm_deleteValue":
case "gm_listValues":
case "gm_getValues":
return await requestStorage(action, payload);
default:
throw new Error(`Unknown bridge action: ${action}`);
}
}

642
src/extension/bridgeXhr.ts Normal file
View file

@ -0,0 +1,642 @@
import debug from "../utils/debug";
import { toErrorMessage } from "../utils/errors";
import {
base64ToArrayBuffer,
isBodySerializedForPort,
serializeBodyForPort,
summarizeBodyForDebug,
} from "./bodySerialization";
import { toPageMessage } from "./bridgeTransport";
import type { AnyObject } from "./constants";
import { PORT_NAME, TYPE_XHR_ACK, TYPE_XHR_EVENT } from "./constants";
import { ext, runtimeMessagesUseStructuredClone } from "./webext";
import { isYandexApiHostname, shouldStripYandexHeader } from "./yandexHeaders";
type UaBrandVersion = { brand: string; version: string };
type XhrPortState = {
port: {
onMessage: { addListener: (fn: (msg: AnyObject) => void) => void };
onDisconnect: { addListener: (fn: () => void) => void };
postMessage: (msg: AnyObject) => void;
disconnect: () => void;
};
responseType: string;
chunks: ArrayBuffer[];
totalBytes: number;
settled: boolean;
};
const UA_CH_CACHE_TTL_MS = 10 * 60 * 1000;
const UA_CH_HIGH_ENTROPY_HINTS = ["fullVersionList"];
const TERMINAL_XHR_EVENT_TYPES = new Set(["load", "error", "timeout", "abort"]);
const EMPTY_HEADERS = Object.freeze({}) as Readonly<Record<string, string>>;
const ESCAPED_DOUBLE_QUOTE = String.raw`\"`;
let cachedUaChHeaders: Readonly<Record<string, string>> = EMPTY_HEADERS;
let cachedUaChHeadersExpiresAt = 0;
let cachedUaChHeadersPromise: Promise<Readonly<Record<string, string>>> | null =
null;
const xhrPorts = new Map<string, XhrPortState>();
function escapeHeaderValue(value: string): string {
return value.replaceAll('"', ESCAPED_DOUBLE_QUOTE);
}
function formatUaBrands(brands: UaBrandVersion[]): string {
return brands
.filter(
(b) => b && typeof b.brand === "string" && typeof b.version === "string",
)
.map(
(b) =>
`"${escapeHeaderValue(b.brand)}";v="${escapeHeaderValue(b.version)}"`,
)
.join(", ");
}
async function getUaChHeaders(): Promise<Record<string, string>> {
const uaData = (
navigator as Navigator & {
userAgentData?: {
brands?: UaBrandVersion[];
uaList?: UaBrandVersion[];
mobile?: boolean;
platform?: string;
getHighEntropyValues?: (
values: string[],
) => Promise<{ fullVersionList?: UaBrandVersion[] }>;
};
}
)?.userAgentData;
if (!uaData) return {};
const headers: Record<string, string> = {};
const brands: UaBrandVersion[] =
(Array.isArray(uaData.brands) && uaData.brands) ||
(Array.isArray(uaData.uaList) && uaData.uaList) ||
[];
if (brands.length) headers["sec-ch-ua"] = formatUaBrands(brands);
if (typeof uaData.mobile === "boolean") {
headers["sec-ch-ua-mobile"] = uaData.mobile ? "?1" : "?0";
}
if (typeof uaData.platform === "string" && uaData.platform) {
headers["sec-ch-ua-platform"] = `"${escapeHeaderValue(uaData.platform)}"`;
}
try {
const high = await uaData.getHighEntropyValues?.(UA_CH_HIGH_ENTROPY_HINTS);
if (Array.isArray(high?.fullVersionList) && high.fullVersionList.length) {
headers["sec-ch-ua-full-version-list"] = formatUaBrands(
high.fullVersionList,
);
}
} catch {
// ignore optional high entropy lookup failures
}
return headers;
}
function freezeHeaders(
headers: Record<string, string>,
): Readonly<Record<string, string>> {
return Object.freeze({ ...headers });
}
async function getCachedUaChHeaders(): Promise<
Readonly<Record<string, string>>
> {
const now = Date.now();
if (now < cachedUaChHeadersExpiresAt) {
return cachedUaChHeaders;
}
if (cachedUaChHeadersPromise !== null) {
return await cachedUaChHeadersPromise;
}
cachedUaChHeadersPromise = (async () => {
const headers = freezeHeaders(await getUaChHeaders());
cachedUaChHeaders = headers;
cachedUaChHeadersExpiresAt = Date.now() + UA_CH_CACHE_TTL_MS;
return headers;
})();
try {
return await cachedUaChHeadersPromise;
} finally {
cachedUaChHeadersPromise = null;
}
}
function ensureHeadersObject(details: AnyObject): Record<string, string> {
const raw = details?.headers;
if (!raw || typeof raw !== "object") {
const headers: Record<string, string> = {};
details.headers = headers;
return headers;
}
const headers = raw as Record<string, unknown>;
for (const [name, value] of Object.entries(headers)) {
if (typeof value === "string") continue;
if (typeof value === "number" || typeof value === "boolean") {
headers[name] = String(value);
continue;
}
delete headers[name];
}
details.headers = headers;
return headers as Record<string, string>;
}
function stripYandexHeaders(headers: Record<string, string>): void {
for (const headerName of Object.keys(headers)) {
if (shouldStripYandexHeader(headerName)) {
delete headers[headerName];
}
}
}
function mergeHeadersIfMissing(
headers: Record<string, string>,
additions: Readonly<Record<string, string>>,
): void {
const existingNames = new Set<string>();
for (const name of Object.keys(headers)) {
existingNames.add(name.toLowerCase());
}
for (const [name, value] of Object.entries(additions)) {
if (!value) continue;
const normalizedName = name.toLowerCase();
if (existingNames.has(normalizedName)) continue;
headers[name] = value;
existingNames.add(normalizedName);
}
}
function getHostname(url: string): string {
if (!url) return "";
try {
return new URL(url).hostname;
} catch {
return "";
}
}
function postToPage(payload: AnyObject) {
const { message, transfer } = toPageMessage(payload);
if (transfer.length) {
globalThis.postMessage(message, "*", transfer);
return;
}
globalThis.postMessage(message, "*");
}
function disconnectPortSafely(port: XhrPortState["port"]): void {
try {
port.disconnect();
} catch {
// ignore
}
}
function settleXhrPort(requestId: string, state: XhrPortState): void {
state.settled = true;
disconnectPortSafely(state.port);
xhrPorts.delete(requestId);
}
function isRequestStateActive(
requestId: string,
expectedState: XhrPortState,
): boolean {
const currentState = xhrPorts.get(requestId);
return currentState === expectedState && !currentState.settled;
}
function toLifecycleState(kind: string): "in_flight" | "terminal" {
return kind === "progress" ? "in_flight" : "terminal";
}
function postXhrEvent(requestId: string, payload: AnyObject): void {
const kind = String(payload?.type ?? "");
postToPage({
type: TYPE_XHR_EVENT,
requestId,
payload: {
...payload,
state: toLifecycleState(kind),
},
});
}
function makeBridgeXhrError(details: AnyObject, error: string): AnyObject {
return {
finalUrl: String(details?.url || ""),
readyState: 4,
status: 0,
statusText: "",
responseHeaders: "",
response: null,
responseText: "",
error,
};
}
function concatArrayBuffers(
chunks: ArrayBuffer[],
totalBytes: number,
): ArrayBuffer {
const out = new Uint8Array(totalBytes);
let offset = 0;
for (const ab of chunks) {
const u8 = new Uint8Array(ab);
out.set(u8, offset);
offset += u8.byteLength;
}
return out.buffer;
}
function resolveBinaryResponseBuffer(
directResponse: unknown,
chunks: ArrayBuffer[],
totalBytes: number,
fallbackB64: unknown,
): ArrayBuffer {
if (directResponse instanceof ArrayBuffer) {
return directResponse;
}
try {
if (ArrayBuffer.isView(directResponse)) {
const view = directResponse;
const out = new Uint8Array(view.byteLength);
out.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
return out.buffer;
}
} catch {
// ignore and continue
}
if (totalBytes > 0) {
return concatArrayBuffers(chunks, totalBytes);
}
if (typeof fallbackB64 === "string" && fallbackB64.length > 0) {
return base64ToArrayBuffer(fallbackB64);
}
return new ArrayBuffer(0);
}
function logBridgeXhrStart(requestId: string, safeDetails: AnyObject): void {
debug.log("[VOT EXT][bridge] startXhr", {
requestId,
url: safeDetails?.url,
method: safeDetails?.method,
responseType: safeDetails?.responseType,
timeoutMs: Number(safeDetails?.timeout ?? 0),
headerCount:
safeDetails?.headers && typeof safeDetails.headers === "object"
? Object.keys(safeDetails.headers).length
: 0,
body: summarizeBodyForDebug(safeDetails?.data),
});
}
function createXhrPortState(
port: XhrPortState["port"],
responseType: string,
): XhrPortState {
return {
port,
responseType,
chunks: [],
totalBytes: 0,
settled: false,
};
}
function postBridgeXhrAck(
requestId: string,
safeDetails: AnyObject,
responseType: string,
): void {
postToPage({
type: TYPE_XHR_ACK,
requestId,
payload: {
state: "acknowledged",
timeoutMs: Number(safeDetails?.timeout ?? 0),
responseType,
ts: Date.now(),
},
});
}
function logBridgePortMessage(requestId: string, msg: AnyObject): void {
debug.log("[VOT EXT][bridge] port message", {
requestId,
kind: msg.type ?? "unknown",
state: msg.state ?? null,
status:
msg.response?.status ?? msg.error?.status ?? msg.progress?.status ?? null,
loaded: msg.progress?.loaded ?? null,
total: msg.progress?.total ?? null,
});
}
function applyBridgeProgressChunk(st: XhrPortState, msg: AnyObject): void {
if (msg.type !== "progress" || !msg.progress) {
return;
}
if (msg.progress.chunk instanceof ArrayBuffer) {
const aggregateCopy = msg.progress.chunk.slice(0);
st.chunks.push(aggregateCopy);
st.totalBytes += aggregateCopy.byteLength;
return;
}
const b64 = msg.progress.chunkB64;
if (typeof b64 !== "string" || !b64.length) {
return;
}
const ab = base64ToArrayBuffer(b64);
const aggregateCopy = ab.slice(0);
st.chunks.push(aggregateCopy);
st.totalBytes += aggregateCopy.byteLength;
msg.progress.chunk = ab;
delete msg.progress.chunkB64;
}
function applyBridgeBinaryLoadResponse(st: XhrPortState, msg: AnyObject): void {
if (msg.type !== "load" || !msg.response) {
return;
}
const rt = String(
msg.response.responseType || st.responseType || "text",
).toLowerCase();
if (rt !== "arraybuffer" && rt !== "blob") {
return;
}
const ab = resolveBinaryResponseBuffer(
msg.response.response,
st.chunks,
st.totalBytes,
msg.response.responseB64,
);
delete msg.response.responseB64;
st.chunks.length = 0;
st.totalBytes = 0;
if (rt === "blob") {
const ct = msg.response.contentType || msg.response.mime || undefined;
msg.response.response = ct
? new Blob([ab], { type: String(ct) })
: new Blob([ab]);
return;
}
msg.response.response = ab;
}
function settleBridgePortOnTerminalEvent(
requestId: string,
st: XhrPortState,
msg: AnyObject,
): void {
if (!TERMINAL_XHR_EVENT_TYPES.has(String(msg.type ?? ""))) {
return;
}
debug.log("[VOT EXT][bridge] terminal event", {
requestId,
kind: msg.type,
status: msg.response?.status ?? msg.error?.status ?? null,
});
settleXhrPort(requestId, st);
}
function handleBridgePortMessage(requestId: string, msg: AnyObject): void {
const st = xhrPorts.get(requestId);
if (!st || st.settled) return;
if (!msg || typeof msg !== "object") return;
logBridgePortMessage(requestId, msg);
applyBridgeProgressChunk(st, msg);
applyBridgeBinaryLoadResponse(st, msg);
postXhrEvent(requestId, msg);
settleBridgePortOnTerminalEvent(requestId, st, msg);
}
function handleBridgePortDisconnect(
requestId: string,
safeDetails: AnyObject,
): void {
const st = xhrPorts.get(requestId);
if (!st || st.settled) return;
debug.warn("[VOT EXT][bridge] port disconnected before terminal event", {
requestId,
url: safeDetails?.url ?? null,
});
settleXhrPort(requestId, st);
postXhrEvent(requestId, {
type: "error",
error: makeBridgeXhrError(
safeDetails,
"Bridge port disconnected before response",
),
});
}
function attachBridgePortListeners(
requestId: string,
port: XhrPortState["port"],
safeDetails: AnyObject,
): void {
port.onMessage.addListener((msg: AnyObject) => {
handleBridgePortMessage(requestId, msg);
});
port.onDisconnect.addListener(() => {
handleBridgePortDisconnect(requestId, safeDetails);
});
}
async function normalizeYandexBridgeHeaders(
requestId: string,
safeDetails: AnyObject,
state: XhrPortState,
): Promise<void> {
const urlStr = String(safeDetails?.url ?? "");
const hostname = getHostname(urlStr);
if (!isYandexApiHostname(hostname)) {
return;
}
const headers = ensureHeadersObject(safeDetails);
stripYandexHeaders(headers);
const uaCh = await getCachedUaChHeaders();
if (!isRequestStateActive(requestId, state)) return;
mergeHeadersIfMissing(headers, uaCh);
debug.log("[VOT EXT][bridge] yandex header normalization", {
requestId,
url: urlStr,
headerCount: Object.keys(headers).length,
headerNames: Object.keys(headers),
});
}
async function serializeBridgeRequestData(
safeDetails: AnyObject,
): Promise<AnyObject["data"]> {
if (isBodySerializedForPort(safeDetails?.data)) {
return safeDetails.data;
}
if (runtimeMessagesUseStructuredClone) {
return safeDetails?.data;
}
return await serializeBodyForPort(safeDetails?.data);
}
function buildSerializedBridgeDetails(
safeDetails: AnyObject,
data: AnyObject["data"],
): AnyObject {
return {
...safeDetails,
data,
responseType: safeDetails?.responseType,
};
}
function logSerializedBridgeBody(
requestId: string,
safeDetails: AnyObject,
data: AnyObject["data"],
): void {
const serializedBodySummary = summarizeBodyForDebug(data);
debug.log("[VOT EXT][bridge] serialized body", {
requestId,
url: safeDetails?.url ?? null,
from: summarizeBodyForDebug(safeDetails?.data),
to: serializedBodySummary,
});
}
function postBridgeStart(
requestId: string,
state: XhrPortState,
serializedDetails: AnyObject,
): void {
debug.log("[VOT EXT][bridge] post start to background", {
requestId,
url: serializedDetails.url,
method: serializedDetails.method,
responseType: serializedDetails.responseType,
body: summarizeBodyForDebug(serializedDetails.data),
});
state.port.postMessage({ type: "start", details: serializedDetails });
}
function handleStartXhrError(
requestId: string,
normalizedRequestId: string,
safeDetails: AnyObject,
error: unknown,
): void {
const requestKey = normalizedRequestId || requestId;
const st = xhrPorts.get(requestKey);
if (st && !st.settled) {
settleXhrPort(requestKey, st);
}
const errorMessage = toErrorMessage(error);
debug.log("[VOT EXT][bridge] startXhr error", {
requestId: requestKey,
error: errorMessage,
lastError: ext?.runtime?.lastError ?? null,
});
if (requestKey) {
postXhrEvent(requestKey, {
type: "error",
error: makeBridgeXhrError(safeDetails, errorMessage),
});
}
}
export async function startBridgeXhr(
requestId: string,
details: AnyObject,
): Promise<void> {
const normalizedRequestId = String(requestId || "");
const safeDetails: AnyObject = details ?? {};
try {
if (!normalizedRequestId) {
throw new Error("Missing requestId for bridge XHR");
}
requestId = normalizedRequestId;
if (xhrPorts.has(requestId)) {
debug.warn("[VOT EXT][bridge] replacing active XHR request", {
requestId,
});
abortBridgeXhr(requestId);
}
logBridgeXhrStart(requestId, safeDetails);
const connected = ext?.runtime?.connect?.({ name: PORT_NAME });
if (!connected || typeof connected !== "object") {
throw new Error("Bridge port is not available");
}
const port = connected as XhrPortState["port"];
const responseType = String(
safeDetails?.responseType || "text",
).toLowerCase();
const state = createXhrPortState(port, responseType);
xhrPorts.set(requestId, state);
postBridgeXhrAck(requestId, safeDetails, responseType);
attachBridgePortListeners(requestId, port, safeDetails);
await normalizeYandexBridgeHeaders(requestId, safeDetails, state);
if (!isRequestStateActive(requestId, state)) return;
const data = await serializeBridgeRequestData(safeDetails);
logSerializedBridgeBody(requestId, safeDetails, data);
if (!isRequestStateActive(requestId, state)) return;
const serializedDetails = buildSerializedBridgeDetails(safeDetails, data);
postBridgeStart(requestId, state, serializedDetails);
} catch (error: unknown) {
handleStartXhrError(requestId, normalizedRequestId, safeDetails, error);
}
}
export function abortBridgeXhr(requestId: string): void {
const st = xhrPorts.get(requestId);
if (!st || st.settled) return;
st.settled = true;
debug.warn("[VOT EXT][bridge] abortXhr", { requestId });
try {
st.port.postMessage({ type: "abort" });
} catch {
// ignore
}
disconnectPortSafely(st.port);
xhrPorts.delete(requestId);
}

View file

@ -1,9 +1,6 @@
import debug from "../utils/debug";
import { toErrorMessage } from "../utils/errors";
import {
serializeBodyForPort,
summarizeBodyForDebug,
} from "./bodySerialization";
import { summarizeBodyForDebug } from "./bodySerialization";
import { toBridgeMessage } from "./bridgeTransport";
import {
type AnyObject,
@ -224,34 +221,17 @@ function toSerializableXhrDetails(details: AnyObject): AnyObject {
};
}
async function toBridgeXhrDetails(details: AnyObject): Promise<AnyObject> {
const sourceBodySummary = summarizeBodyForDebug(details.data);
const serializedData = await serializeBodyForPort(details.data);
const serializedBodySummary = summarizeBodyForDebug(serializedData);
debug.log("[VOT EXT][prelude] GM_xmlhttpRequest body serialized", {
function toBridgeXhrDetails(details: AnyObject): AnyObject {
debug.log("[VOT EXT][prelude] GM_xmlhttpRequest body passthrough", {
url: details.url,
method: details.method,
from: sourceBodySummary,
to: serializedBodySummary,
body: summarizeBodyForDebug(details.data),
});
if (
typeof serializedData === "string" &&
/^\[object [^\]]+\]$/.test(serializedData.trim())
) {
debug.warn("[VOT EXT][prelude] suspicious serialized body string", {
url: details.url,
method: details.method,
serializedBody: serializedData,
sourceBody: sourceBodySummary,
});
}
return {
method: details.method,
url: details.url,
headers: details.headers,
data: serializedData,
data: details.data,
timeout: details.timeout,
responseType: details.responseType,
anonymous: details.anonymous,
@ -285,7 +265,7 @@ function makeXhrTerminalErrorPayload(
};
}
function installPageGmPolyfills() {
export function installPageGmPolyfills() {
// Legacy GM_notification (callback-based). Used by src/utils/notify.ts.
(globalThis as any).GM_notification = (details: unknown) => {
try {
@ -330,15 +310,14 @@ function installPageGmPolyfills() {
details: toSerializableXhrDetails(callbacks),
});
const startRequest = async () => {
const startRequest = () => {
try {
if (!active || !xhrCallbacks.has(requestId)) return;
// Serialize in MAIN world before crossing into the isolated bridge.
// This prevents Blob/TypedArray bodies from degrading during
// cross-world structured clone and turning into "[object Object]".
const requestDetails = await toBridgeXhrDetails(callbacks);
if (!active || !xhrCallbacks.has(requestId)) return;
// `window.postMessage()` already uses structured clone, so keep native
// bodies intact here and serialize only once at the runtime messaging
// boundary inside the isolated-world bridge.
const requestDetails = toBridgeXhrDetails(callbacks);
debug.log("[VOT EXT][prelude] GM_xmlhttpRequest post TYPE_XHR_START", {
requestId,
@ -477,7 +456,7 @@ function installPageGmPolyfills() {
};
}
function wireMessageHandlers() {
export function wireMessageHandlers() {
globalThis.addEventListener("message", (event) => {
if (event.source !== globalThis.window) return;
const data = event.data;
@ -610,29 +589,32 @@ function handleXhrEvent(data: AnyObject): void {
});
}
const preludeGlobal = globalThis as Record<string, unknown>;
if (preludeGlobal[PRELUDE_BOOT_KEY]) {
debug.log("[VOT EXT][prelude] already initialized");
} else {
preludeGlobal[PRELUDE_BOOT_KEY] = true;
export async function initializePrelude(): Promise<void> {
installPageGmPolyfills();
wireMessageHandlers();
const initializePrelude = async () => {
installPageGmPolyfills();
wireMessageHandlers();
// Best-effort handshake to populate GM_info with real manifest metadata.
try {
const { manifest } = await request<{ manifest: AnyObject }>("handshake");
const gmInfo = (globalThis as any).GM_info as AnyObject;
if (manifest?.name) gmInfo.script.name = manifest.name;
if (manifest?.version) {
gmInfo.script.version = manifest.version;
gmInfo.version = manifest.version;
}
} catch {
// ignore
// Best-effort handshake to populate GM_info with real manifest metadata.
try {
const { manifest } = await request<{ manifest: AnyObject }>("handshake");
const gmInfo = (globalThis as any).GM_info as AnyObject;
if (manifest?.name) gmInfo.script.name = manifest.name;
if (manifest?.version) {
gmInfo.script.version = manifest.version;
gmInfo.version = manifest.version;
}
};
} catch {
// ignore
}
}
export function bootstrapExtensionPrelude(): void {
const preludeGlobal = globalThis as Record<string, unknown>;
if (preludeGlobal[PRELUDE_BOOT_KEY]) {
debug.log("[VOT EXT][prelude] already initialized");
return;
}
preludeGlobal[PRELUDE_BOOT_KEY] = true;
void (async () => {
try {
@ -642,3 +624,5 @@ if (preludeGlobal[PRELUDE_BOOT_KEY]) {
}
})();
}
bootstrapExtensionPrelude();

View file

@ -57,6 +57,13 @@ export const ext: WebExtNamespace | null =
browserNamespace ?? chromeNamespace ?? null;
const isBrowserNamespace = !!browserNamespace && ext === browserNamespace;
const isFirefoxLike =
typeof (browserNamespace?.runtime as { getBrowserInfo?: unknown } | undefined)
?.getBrowserInfo === "function";
export const runtimeMessagesUseStructuredClone = isFirefoxLike;
export const runtimeMessagesUseJsonSerialization =
!runtimeMessagesUseStructuredClone;
export function lastErrorMessage(): string | null {
// `runtime.lastError` is a Chromium callback-era mechanism.
@ -103,7 +110,28 @@ async function callAsync<T>(
export async function storageGet<T = Record<string, unknown>>(
keys: unknown,
): Promise<T> {
const area = ext?.storage?.local;
const chromeArea = isFirefoxLike
? undefined
: chromeNamespace?.storage?.local;
if (chromeArea && typeof chromeArea.get === "function") {
return await new Promise<T>((resolve, reject) => {
try {
chromeArea.get(keys, (items: unknown) => {
const err = lastErrorMessage();
if (err) {
reject(new Error(err));
return;
}
resolve(items as T);
});
} catch (error) {
reject(error);
}
});
}
const area = browserNamespace?.storage?.local ?? ext?.storage?.local;
return await callAsync<T>(
area?.get?.bind(area) as ((...args: unknown[]) => unknown) | undefined,
[keys],
@ -113,7 +141,29 @@ export async function storageGet<T = Record<string, unknown>>(
export async function storageSet(
items: Record<string, unknown>,
): Promise<void> {
const area = ext?.storage?.local;
const chromeArea = isFirefoxLike
? undefined
: chromeNamespace?.storage?.local;
if (chromeArea && typeof chromeArea.set === "function") {
await new Promise<void>((resolve, reject) => {
try {
chromeArea.set(items, () => {
const err = lastErrorMessage();
if (err) {
reject(new Error(err));
return;
}
resolve();
});
} catch (error) {
reject(error);
}
});
return;
}
const area = browserNamespace?.storage?.local ?? ext?.storage?.local;
await callAsync<void>(
area?.set?.bind(area) as ((...args: unknown[]) => unknown) | undefined,
[items],
@ -124,7 +174,29 @@ export async function storageSet(
}
export async function storageRemove(keys: string | string[]): Promise<void> {
const area = ext?.storage?.local;
const chromeArea = isFirefoxLike
? undefined
: chromeNamespace?.storage?.local;
if (chromeArea && typeof chromeArea.remove === "function") {
await new Promise<void>((resolve, reject) => {
try {
chromeArea.remove(keys, () => {
const err = lastErrorMessage();
if (err) {
reject(new Error(err));
return;
}
resolve();
});
} catch (error) {
reject(error);
}
});
return;
}
const area = browserNamespace?.storage?.local ?? ext?.storage?.local;
await callAsync<void>(
area?.remove?.bind(area) as ((...args: unknown[]) => unknown) | undefined,
[keys],

10
src/global.d.ts vendored
View file

@ -5,7 +5,7 @@ const DEBUG_MODE: boolean;
* Use `typeof IS_EXTENSION !== "undefined"` checks before reading.
*/
const IS_EXTENSION: boolean;
const AVAILABLE_LOCALES: import("./localization/localizationProvider").LangOverride[];
const AVAILABLE_LOCALES: import("./types/localization").LangOverride[];
const REPO_BRANCH: "master" | "dev";
/**
@ -30,6 +30,8 @@ declare const GM: {
setValue?<T>(key: string, value: T): Promise<void>;
deleteValue?(key: string): Promise<void>;
listValues?<T extends string = string>(): Promise<T[]>;
xmlHttpRequest?(details: any): Promise<any> & { abort?: () => void };
xmlhttpRequest?(details: any): Promise<any> & { abort?: () => void };
// Some managers expose more APIs, but we only rely on the above.
};
declare function GM_getValue<T>(key: string, defaultValue?: T): T;
@ -48,5 +50,9 @@ interface Window {
webkitAudioContext?: typeof AudioContext;
}
// Allow side-effect style imports for styles in TS files (e.g. `import "./styles/main.scss"`).
// Allow style imports in TS files, including Vite's inline CSS string mode.
declare module "*.scss";
declare module "*.scss?inline" {
const content: string;
export default content;
}

View file

@ -1,7 +1,7 @@
{
"name": "[VOT] - Voice Over Translation",
"description": "A small extension that adds a Yandex Browser video translation to other browsers",
"version": "1.11.3",
"version": "1.11.4",
"author": "Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng",
"namespace": "vot",
"icon": "https://translate.yandex.ru/icons/favicon.ico",
@ -132,6 +132,10 @@
"*://iframe.mediadelivery.net/*",
"*://video.bunnycdn.com/*",
"*://*.weibo.com/*",
"*://*.jove.com/*",
"*://*.preservetube.com/*",
"*://*.mediafile.cc/*",
"*://projector.datacamp.com/*",
"*://*/*.mp4*",
"*://*/*.webm*"
],

View file

@ -7,11 +7,13 @@ import type { ClientSession, SessionModule } from "@vot.js/shared/types/secure";
import Chaimu from "chaimu/client";
import { initAudioContext } from "chaimu/player";
import { getOrCreateBootState } from "./bootstrap/bootState";
import { initIframeInteractor } from "./bootstrap/iframeInteractor";
import { ensureRuntimeActivated } from "./bootstrap/runtimeActivation";
import { bindObserverListeners } from "./bootstrap/videoObserverBinding";
import {
authServerUrl,
minLongWaitingCount,
proxyWorkerHost,
proxyWorkerHostMode1,
votBackendUrl,
workerHost,
} from "./config/config";
@ -22,12 +24,12 @@ import { resolveOverlayMountTargets } from "./core/overlayMountTargets";
import { VOTTranslationHandler } from "./core/translationHandler";
import { TranslationOrchestrator } from "./core/translationOrchestrator";
import { VideoLifecycleController } from "./core/videoLifecycleController";
import { createVideoLifecycleHost } from "./core/videoLifecycleHost";
import { VOTVideoManager } from "./core/videoManager";
import { localizationProvider } from "./localization/localizationProvider";
import type { ProcessedSubtitles } from "./subtitles/processor";
import type { SubtitleFontFamily } from "./subtitles/types";
import { SubtitlesWidget } from "./subtitles/widget";
import type { StorageData } from "./types/storage";
import type { ResponseLanguageSubtitles, StorageData } from "./types/storage";
import type { OverlayMount } from "./types/uiManager";
import { UIManager } from "./ui/manager";
import { isSameOverlayMount } from "./ui/mount";
@ -35,7 +37,7 @@ import { OverlayVisibilityController } from "./ui/overlayVisibilityController";
import debug from "./utils/debug";
import { resolveScopedFullscreenElement } from "./utils/dom";
import { getEnvironmentInfo as getEnvironmentInfoImpl } from "./utils/environment";
import { GM_fetch } from "./utils/gm";
import { GM_fetch, isSupportGMXhr } from "./utils/gm";
import { isIframe } from "./utils/iframeConnector";
import {
createIntervalIdleChecker,
@ -71,15 +73,22 @@ import {
// VideoHandler is large; keep the public API but move big feature areas into
// dedicated modules for cohesion.
import { init as initVideoHandler } from "./videoHandler/modules/init";
import {
isProxyClientEnabled,
resolveProxyWorkerHost,
shouldForceProxyClientGmXhr,
} from "./videoHandler/modules/proxyShared";
import {
changeSubtitlesLang as changeSubtitlesLangImpl,
enableSubtitlesForCurrentLangPair as enableSubtitlesForCurrentLangPairImpl,
ensureSubtitlesForCurrentLangPair as ensureSubtitlesForCurrentLangPairImpl,
loadSubtitles as loadSubtitlesImpl,
resolveSubtitlesLanguage,
toggleSubtitlesForCurrentLangPair as toggleSubtitlesForCurrentLangPairImpl,
updateSubtitlesLangSelect as updateSubtitlesLangSelectImpl,
} from "./videoHandler/modules/subtitles";
import {
applyManualVideoVolumeOverride as applyManualVideoVolumeOverrideImpl,
handleProxySettingsChanged as handleProxySettingsChangedImpl,
isMultiMethodS3 as isMultiMethodS3Impl,
isYouTubeHosts as isYouTubeHostsImpl,
@ -102,9 +111,7 @@ export { getEnvironmentInfo } from "./utils/environment";
export type { VideoData } from "./videoHandler/shared";
export { countryCode } from "./videoHandler/shared";
const RESPONSE_LANG_SET = new Set<string>(availableTTS as readonly string[]);
const isResponseLang = (value: string): value is ResponseLang =>
RESPONSE_LANG_SET.has(value);
const _RESPONSE_LANG_SET = new Set<string>(availableTTS as readonly string[]);
const RESOLVED_VOID_PROMISE: Promise<void> = Promise.resolve();
type InternalVideoVolumeSetHistoryEntry = {
@ -155,7 +162,6 @@ export class VideoHandler {
subtitlesLoadPromises = new Map<string, Promise<any[]>>();
downloadTranslationUrl: string | null = null;
translationRefreshTimeout?: ReturnType<typeof setTimeout>;
isRefreshingTranslation = false;
autoRetry?: ReturnType<typeof setTimeout>;
@ -363,14 +369,28 @@ export class VideoHandler {
* Bugfix: subtitles cache key must match the key used by loadSubtitles().
* @param {string} videoId
* @param {string} detectedLanguage
* @param {string} responseLanguage
* @param {string} subtitleLanguage
*/
getSubtitlesCacheKey(
videoId: string,
detectedLanguage: string,
responseLanguage: string,
subtitleLanguage: string,
): string {
return `${videoId}_${detectedLanguage}_${responseLanguage}_${Boolean(this.data?.useLivelyVoice)}`;
return `${videoId}_${detectedLanguage}_${subtitleLanguage}_${Boolean(this.data?.useLivelyVoice)}`;
}
getPreferredSubtitlesLanguage(
detectedLanguage: string = this.videoData?.detectedLanguage ?? "auto",
responseLanguage: string = this.videoData?.responseLanguage ??
this.translateToLang,
preference: ResponseLanguageSubtitles | undefined = this.data
?.responseLanguageSubtitles,
): string | undefined {
return resolveSubtitlesLanguage(
preference,
detectedLanguage,
responseLanguage,
);
}
isActionStale(actionContext?: { gen: number; videoId: string }): boolean {
@ -384,7 +404,7 @@ export class VideoHandler {
private updateVOTClientRequestSignal(): void {
if (!this.votClient) return;
this.votClient.fetchOpts = {
...(this.votClient.fetchOpts ?? {}),
...this.votClient.fetchOpts,
signal: this.actionsAbortController.signal,
};
}
@ -427,7 +447,6 @@ export class VideoHandler {
this.cacheManager = new CacheManager();
this.interactionChecker = createIntervalIdleChecker();
this.interactionChecker.start();
const self = () => this;
// Create helper instances
const mount = this.getOverlayMount(container);
this.uiManager = new UIManager({
@ -485,89 +504,9 @@ export class VideoHandler {
});
},
});
const lifecycleHost = {
get video() {
return self().video;
},
get site() {
return self().site;
},
get container() {
return self().container;
},
set container(value: HTMLElement) {
if (self().container === value) {
return;
}
self().container = value;
self().uiManager.updateMount(self().getOverlayMount(value));
},
get firstPlay() {
return self().firstPlay;
},
set firstPlay(value: boolean) {
self().firstPlay = value;
},
stopTranslation: () => this.stopTranslation(),
get uiManager() {
return self().uiManager as any;
},
getVideoData: () => this.getVideoData(),
cacheManager: {
getSubtitles: (key: string) => self().cacheManager.getSubtitles(key),
},
getSubtitlesCacheKey: (
videoId: string,
detectedLanguage: string,
responseLanguage: string,
) =>
this.getSubtitlesCacheKey(videoId, detectedLanguage, responseLanguage),
updateSubtitlesLangSelect: () => this.updateSubtitlesLangSelect(),
enableSubtitlesForCurrentLangPair: () =>
this.enableSubtitlesForCurrentLangPair(),
setSelectMenuValues: (from: string, to: string) =>
this.setSelectMenuValues(from, to),
get translateToLang() {
return self().translateToLang;
},
set translateToLang(value: string) {
if (isResponseLang(value)) self().translateToLang = value;
},
get data() {
return self().data ?? {};
},
get subtitles() {
return self().subtitles;
},
set subtitles(value: any[]) {
self().subtitles = value;
},
get subtitlesCacheKey() {
return self().subtitlesCacheKey;
},
set subtitlesCacheKey(value: string | null) {
self().subtitlesCacheKey = value;
},
get videoData() {
return self().videoData;
},
set videoData(value: any) {
self().videoData = value;
},
get actionsAbortController() {
return self().actionsAbortController;
},
set actionsAbortController(value: AbortController) {
self().actionsAbortController = value;
},
resetActionsAbortController: (reason?: unknown) =>
this.resetActionsAbortController(reason),
initVOTClient: () => this.initVOTClient(),
translationOrchestrator: this.translationOrchestrator,
resetSubtitlesWidget: () => this.resetSubtitlesWidget(),
queueOverlayAutoHide: () => this.overlayVisibility?.queueAutoHide(),
};
this.lifecycleController = new VideoLifecycleController(lifecycleHost);
this.lifecycleController = new VideoLifecycleController(
createVideoLifecycleHost(this, (value) => this.getOverlayMount(value)),
);
this.translationHandler = new VOTTranslationHandler(this);
this.videoManager = new VOTVideoManager(this);
}
@ -585,37 +524,41 @@ export class VideoHandler {
this.interactionChecker,
this.tooltipLayoutRoot,
);
if (this.data) {
// Smart layout is enabled by default for new users.
// When enabled, the widget will compute font-size / line length based on player size.
this.subtitlesWidget.setSmartLayout(
typeof this.data.subtitlesSmartLayout === "boolean"
? this.data.subtitlesSmartLayout
: true,
);
if (typeof this.data.subtitlesMaxLength === "number") {
this.subtitlesWidget.setMaxLength(this.data.subtitlesMaxLength);
}
if (typeof this.data.highlightWords === "boolean") {
this.subtitlesWidget.setHighlightWords(this.data.highlightWords);
}
if (typeof this.data.subtitlesFontSize === "number") {
this.subtitlesWidget.setFontSize(this.data.subtitlesFontSize);
}
if (typeof this.data.subtitlesFontFamily === "string") {
this.subtitlesWidget.setFontFamily(
this.data.subtitlesFontFamily as SubtitleFontFamily,
);
}
if (typeof this.data.subtitlesOpacity === "number") {
this.subtitlesWidget.setOpacity(this.data.subtitlesOpacity);
}
}
this.applySavedSubtitlesWidgetSettings(this.subtitlesWidget);
}
return this.subtitlesWidget;
}
private applySavedSubtitlesWidgetSettings(widget: SubtitlesWidget): void {
if (!this.data) {
return;
}
// Smart layout is enabled by default for new users.
// When enabled, the widget will compute font-size / line length based on player size.
widget.setSmartLayout(
typeof this.data.subtitlesSmartLayout === "boolean"
? this.data.subtitlesSmartLayout
: true,
);
if (typeof this.data.subtitlesMaxLength === "number") {
widget.setMaxLength(this.data.subtitlesMaxLength);
}
if (typeof this.data.highlightWords === "boolean") {
widget.setHighlightWords(this.data.highlightWords);
}
if (typeof this.data.subtitlesFontSize === "number") {
widget.setFontSize(this.data.subtitlesFontSize);
}
if (typeof this.data.subtitlesFontFamily === "string") {
widget.setFontFamily(this.data.subtitlesFontFamily);
}
if (typeof this.data.subtitlesOpacity === "number") {
widget.setOpacity(this.data.subtitlesOpacity);
}
}
/**
* Determines whether subtitles widget is initialized\.
* @returns {boolean}
@ -805,21 +748,31 @@ export class VideoHandler {
* @returns {VideoHandler} This instance.
*/
async initVOTClient() {
const transportHost = this.data?.translateProxyEnabled
? (this.data?.proxyWorkerHost ?? proxyWorkerHost)
: workerHost;
const proxyClientEnabled = isProxyClientEnabled(this.data ?? {});
const transportHost =
this.data?.translateProxyEnabled === 1
? proxyWorkerHostMode1
: proxyClientEnabled
? resolveProxyWorkerHost(this.data?.proxyWorkerHost)
: workerHost;
this.votOpts = {
fetchFn: GM_fetch,
fetchOpts: {
signal: this.actionsAbortController.signal,
// Proxy mode routes requests through the worker/backend where page-world
// fetch() adds an avoidable CORS preflight. GM transport skips that hop.
forceGmXhr: shouldForceProxyClientGmXhr({
...this.data,
gmXhrSupported: isSupportGMXhr,
}),
},
apiToken: this.data?.account?.token,
hostVOT: votBackendUrl,
host: transportHost,
};
this.votClient = new (
this.data?.translateProxyEnabled ? VOTWorkerClient : VOTClient
)(this.votOpts);
this.votClient = new (proxyClientEnabled ? VOTWorkerClient : VOTClient)(
this.votOpts,
);
this.votClient.sessions = await this.votSessionStorage.restore(
transportHost,
this.votClient.sessions,
@ -1093,6 +1046,12 @@ export class VideoHandler {
this.volumeLinkState.initialized = true;
}
clearVolumeLinkState(): void {
this.volumeLinkState.initialized = false;
this.volumeLinkState.lastVideoPercent = 0;
this.volumeLinkState.lastTranslationPercent = 0;
}
/**
* Checks if the video is muted.
* @returns {boolean} True if muted.
@ -1123,10 +1082,9 @@ export class VideoHandler {
/**
* Keeps translation and video sliders linked (syncVolume option).
*
* The implementation is delta-based: when the user changes one slider, the
* other slider moves by the same delta. This preserves the relative
* difference between volumes and works with "audio booster" (translation can
* exceed 100%).
* The implementation is delta-based inside the shared 0..100 link range.
* Translation booster values above 100 remain available only while link mode
* is disabled.
*/
syncVolumeWrapper(
fromType: "translation" | "video",
@ -1234,10 +1192,6 @@ export class VideoHandler {
clearTimeout(this.autoRetry);
this.autoRetry = undefined;
}
if (this.translationRefreshTimeout !== undefined) {
clearTimeout(this.translationRefreshTimeout);
this.translationRefreshTimeout = undefined;
}
// Cancel in-flight translation work.
this.resetActionsAbortController("stopTranslate");
};
@ -1278,52 +1232,17 @@ export class VideoHandler {
errorMessage = new VOTLocalizedError("TranslationDelayed");
}
debug.log("updateTranslationErrorMsg message", errorMessage);
if (errorMessage?.name === "VOTLocalizedError") {
this.transformBtn("error", errorMessage.localizedMessage);
} else if (errorMessage instanceof Error) {
this.transformBtn("error", errorMessage?.message);
} else if (
this.data?.translateAPIErrors &&
lang !== "ru" &&
!errorMessage?.includes(translationTake)
) {
const overlayView = this.uiManager.votOverlayView;
if (!overlayView?.votButton) {
return;
}
const messageStr = Array.isArray(errorMessage)
? errorMessage.join(" ")
: String(errorMessage);
const cacheKey = `${lang}:${messageStr}`;
const cached = this.errorTranslationCache.get(cacheKey);
if (cached) {
this.transformBtn("error", cached);
} else {
overlayView.votButton.loading = true;
const translatedMessage = await translate(messageStr, "ru", lang);
const translatedText = Array.isArray(translatedMessage)
? translatedMessage.join("\n")
: String(translatedMessage);
if (signal?.aborted) {
return;
}
this.errorTranslationCache.set(cacheKey, translatedText);
// Prevent unbounded growth.
if (this.errorTranslationCache.size > 50) {
const oldestKey = this.errorTranslationCache.keys().next().value;
if (oldestKey) this.errorTranslationCache.delete(oldestKey);
}
this.transformBtn("error", translatedText);
}
if (signal?.aborted) {
return;
}
} else {
const msg = Array.isArray(errorMessage)
? errorMessage.join("\n")
: String(errorMessage ?? "");
this.transformBtn("error", msg);
const resolvedMessage = await this.resolveTranslationErrorDisplayMessage(
errorMessage,
translationTake,
lang,
signal,
);
if (signal?.aborted || resolvedMessage === null) {
return;
}
this.transformBtn("error", resolvedMessage);
if (signal?.aborted) {
return;
}
@ -1341,6 +1260,90 @@ export class VideoHandler {
}
}
private async resolveTranslationErrorDisplayMessage(
errorMessage: any,
translationTake: string,
lang: string,
signal?: AbortSignal,
): Promise<string | null> {
if (errorMessage?.name === "VOTLocalizedError") {
return errorMessage.localizedMessage;
}
if (errorMessage instanceof Error) {
return errorMessage.message;
}
if (
!this.shouldTranslateErrorMessage(errorMessage, translationTake, lang)
) {
return this.stringifyTranslationError(errorMessage);
}
return await this.getTranslatedErrorMessage(errorMessage, lang, signal);
}
private shouldTranslateErrorMessage(
errorMessage: any,
translationTake: string,
lang: string,
): boolean {
return (
Boolean(this.data?.translateAPIErrors) &&
lang !== "ru" &&
!errorMessage?.includes(translationTake)
);
}
private stringifyTranslationError(errorMessage: any): string {
return Array.isArray(errorMessage)
? errorMessage.join("\n")
: String(errorMessage ?? "");
}
private async getTranslatedErrorMessage(
errorMessage: any,
lang: string,
signal?: AbortSignal,
): Promise<string | null> {
const overlayView = this.uiManager.votOverlayView;
if (!overlayView?.votButton) {
return null;
}
const messageStr = Array.isArray(errorMessage)
? errorMessage.join(" ")
: String(errorMessage);
const cacheKey = `${lang}:${messageStr}`;
const cached = this.errorTranslationCache.get(cacheKey);
if (cached) {
return cached;
}
overlayView.votButton.loading = true;
const translatedMessage = await translate(messageStr, "ru", lang);
if (signal?.aborted) {
return null;
}
const translatedText = Array.isArray(translatedMessage)
? translatedMessage.join("\n")
: String(translatedMessage);
this.errorTranslationCache.set(cacheKey, translatedText);
this.trimErrorTranslationCache();
return translatedText;
}
private trimErrorTranslationCache(): void {
if (this.errorTranslationCache.size <= 50) {
return;
}
const oldestKey = this.errorTranslationCache.keys().next().value;
if (oldestKey) {
this.errorTranslationCache.delete(oldestKey);
}
}
/**
* Called after translation is updated.
* @param {string} audioUrl The URL of the translation audio.
@ -1486,6 +1489,10 @@ export class VideoHandler {
return this.callModule(setupAudioSettingsImpl);
}
applyManualVideoVolumeOverride(volume: number) {
return this.callModule(applyManualVideoVolumeOverrideImpl, volume);
}
/**
* Stops translation and synchronizes volume.
*/
@ -1636,6 +1643,7 @@ async function main(): Promise<void> {
isIframe: isIframe(),
href: String(globalThis.location.href || ""),
origin: globalThis.location.origin,
authOrigin: authServerUrl,
});
if (bootstrapMode === "skip") {
@ -1643,11 +1651,15 @@ async function main(): Promise<void> {
return;
}
logBootstrap("Loading extension");
if (bootstrapMode === "top-full") {
await ensureRuntimeActivated("top-frame", logBootstrap);
// Some hosts exchange iframe video identifiers via postMessage before a
// playable <video> is observed, so keep this bridge available eagerly.
initIframeInteractor();
logBootstrap("Loading extension", { mode: bootstrapMode });
if (bootstrapMode === "auth-eager") {
await ensureRuntimeActivated("auth-page", logBootstrap);
} else {
logBootstrap("Lazy iframe bootstrap enabled; waiting for video detection");
logBootstrap("Lazy bootstrap enabled; waiting for video detection");
}
bindObserverListeners({
@ -1663,11 +1675,14 @@ async function main(): Promise<void> {
videoObserver.enable();
}
if (bootState.status === "booting" || bootState.status === "booted") {
logBootstrap("bootstrap already initialized, skipping duplicate run", {
status: bootState.status,
});
} else {
export function bootstrapContentScript(): Promise<void> {
if (bootState.status === "booting" || bootState.status === "booted") {
logBootstrap("bootstrap already initialized, skipping duplicate run", {
status: bootState.status,
});
return bootState.promise ?? Promise.resolve();
}
const runBootstrap = async () => {
try {
await main();
@ -1681,4 +1696,7 @@ if (bootState.status === "booting" || bootState.status === "booted") {
bootState.status = "booting";
bootState.promise = runBootstrap();
return bootState.promise;
}
void bootstrapContentScript();

View file

@ -1,64 +1,64 @@
{
"af": "94d6b887a8225047329d1e308e086346",
"am": "6cf99ab3b7145a9c0f02d877e9829ca3",
"ar": "28ff314a71d009bed1930ff68c532b3d",
"az": "444205d02a8aa26b01a175288fa0a8a1",
"bg": "c953d4175d24392b38a811dfc69b81b3",
"bn": "fd22204ad1282c3569ffd64e920fb962",
"bs": "bee55cc58822ecdfe1b4cdc4819e1ac2",
"ca": "23aa371a84a57797a49031309ced72e6",
"cs": "77123b295a0db7cbf39e27af85f0e87d",
"cy": "4506e25808b21fececedc7bd6fa7655f",
"da": "9f96d6570e7047cb86f8efeaeb9b6bb7",
"de": "4983284d47542cf37fee5bbe89332614",
"el": "39017512a3896422d0a1aee70202cbb0",
"en": "3664136e95e2e14d5f510f25dc8a4f22",
"es": "0499949851036c805394fc4c7021925a",
"et": "0dbbc4994507ab60d24e5d5d8e93a447",
"eu": "f49ed34d0125b885eba7e9944e904e5c",
"fa": "99e130b745604572e91362fdeed8c85e",
"fi": "1a52a608d3c39932eb980a26faa2c459",
"fr": "340161ff5dd844be732e8bce1bf177f3",
"gl": "a5ee9147fec6b9ab1b91dc7609c32af3",
"hi": "ba070955bd1dee4e4ab7b7afcb1ff38b",
"hr": "d2b691af05cf19bbb1f69d26b6402d90",
"hu": "2bdf25e501d4c16934727cc6e1ac4380",
"hy": "5c4a6ebcbcb224abf652450f8b22ff7c",
"id": "e3e9c474d0ec165115a781ecaaec6613",
"it": "f689740f96864ae91139247cc6d3f753",
"ja": "fb960457cf6f5de26a1e9fa4e9878ea6",
"jv": "6b9d213340358e62acfb8c6fb264f26c",
"kk": "cabad36b3ba722dd92924547af4dffa8",
"km": "e01796113ea706f93947d36982798d1b",
"kn": "d513b4398cd8ea5b5a392a6e13480d5c",
"ko": "c7cc0769487f28854fb710c46ab28852",
"lo": "2ebd0470ac43ad1f7cc70d2588bfb314",
"mk": "5b1f8ac176c019b6643dc79cf1120c17",
"ml": "bfd1e496da8f49f2cc84f80003064211",
"mn": "1c1567d85985e4f59546733a7f736d55",
"ms": "b0e05df28c9987080631b183f1a445d3",
"mt": "d8904b9f7f7c5ad85163ffb294667c9c",
"my": "2b5f20739b2346673d5418d46f2c0ebf",
"ne": "c4741d6d3625d960ef0f97e3edbcaa0e",
"nl": "d891759b13607e145ddc98ae54d940e5",
"pa": "7b6173370ec32be7843f1a081ea47e73",
"pl": "1d7edd12edf1828e32ce6920da63e399",
"pt": "d7663e5584b86ec02ed000f9759d1591",
"ro": "69a69cfb4b65f67ad02f2ba12c294ab2",
"ru": "2ac4f2582b8d5e88fca2b1c4e30dc328",
"si": "d18528269228adde34a8c7ee77f6f221",
"sk": "cfdd005ef17810c6cf0d9ff8c627ad39",
"sl": "6b5cc321f74b8df7bc2e3991969901c5",
"sq": "4b58e5d3610c8a0fe4c54c3964ff1bc3",
"sr": "39e27553cdc87cb4849ffc281b4bc8d2",
"su": "add4e65e723c16cfd9d98b8616bef2cc",
"sv": "8a7bef0f0f8988c03b83094e85ddc95d",
"sw": "4b3449a14d337b9d4dd637175a85b267",
"tr": "6af4895c4f7fbb95950b39a385740b73",
"uk": "8761938ed708967521214f57bbfa4c78",
"ur": "d17266a52c153b3d531cdd236b57e2d2",
"uz": "e99edd631475195ef81ae27dfd16767b",
"vi": "01b8657f5ec9e3f3eaca87ffde2d8cc4",
"zh": "a04e0d4c529646c85e0908398076822f",
"zu": "9cbe0115895f3e06aeb84c1e535fce80"
"af": "fcc5de6528d70dd4f2fd23240b8f629a",
"am": "0a75f4bb5fde1c370b5ffe8f2437e3f9",
"ar": "264d0b2f026fff6045d2495db71c0700",
"az": "31bbcf176786e4aef183d182dce066b6",
"bg": "aedc31589a9ceca8ea64f32f727f59ff",
"bn": "119f524cf753f16a39ba2e3733f113cb",
"bs": "21f188dab699a14ca696a8198c0ad115",
"ca": "298b6d879f759bc3a870875a4e735dfe",
"cs": "b54bb61190a7104a9a21c83bf0282357",
"cy": "21c60e5d7a139f5ff67dcd1c6a875726",
"da": "30bc737f5991ca75037c6dfa55acbade",
"de": "3c1b1922903bd9596ddbaf9c4b632907",
"el": "fd3826626a8b4d8ad082f310592ad862",
"en": "da3f3c1400713eb6a6551d93c1ad78de",
"es": "80ae855ae0758a20855e3f2f5d4613e5",
"et": "ae5b0426e3d56f687453f074d1585c80",
"eu": "011d44085f97a5ae7f4dc791056cc76f",
"fa": "d4558e786f6149da6fef89ed67dcc9b8",
"fi": "87a031b5ee0b3d2c5e28bddbc663b590",
"fr": "d5ff0bd0c8ca6401e927d00f7b38856e",
"gl": "549e985a1d9713d4c934ac6bcbe0c48c",
"hi": "0586e0b98b4034f3aae0526eb13112e3",
"hr": "b28785b41748f7ceb9ab60b83d9b0db7",
"hu": "0c02946e86d6bcab3931b2e5c4da133a",
"hy": "c976cfce6b28b5e41821bce89b724129",
"id": "223cd9221530fbe50926157ff398d2ce",
"it": "9a732e38b8e0c53f474ea906644c32c1",
"ja": "7a01a22e115e332f1b39784600c71505",
"jv": "0c5f3e230b5125f51054d62d5dd7af06",
"kk": "53a00955aff82b00f3ad9a5dc0b6dbf6",
"km": "99fbe3dbabdaea6acccd8a3c0dda88fd",
"kn": "1832d23f3eb75ea6c57e2783e8ed0730",
"ko": "27443d73d56725f236f82bf1aaf469d3",
"lo": "5bbebee7ad9b14704331be6fab6252aa",
"mk": "c29f8aa319e75e65860b5f92e4cf0f4d",
"ml": "6300c210b3668d0f9974cbe6cefe30e6",
"mn": "b49bafa9eeca80fc2d40fa9395a8ca0c",
"ms": "042b412adf30522f9a8040b496d0c13c",
"mt": "2ea6b88985b202a3c53d9783c921ed61",
"my": "b48fa0af2f6eb7d1cb0904d3d7d4e402",
"ne": "96cedcb3c1e3cdd1c333308ab48ab466",
"nl": "89207e5f4c0ff6333930d898f1861124",
"pa": "232a0ad0ab4a7b32a23bcf1af0c561e5",
"pl": "c4815c79cf869c00f85544a0f3e604c8",
"pt": "4c15a08ce81b980832ecec7f7efc9b49",
"ro": "f4ff17aa8adfd91cfa3beb85ad20743b",
"ru": "1a29757b10a76695476a2cb333e2a0b6",
"si": "2b84d175d6d9d4c589de1fa39ab75594",
"sk": "0dfb6dcb9c6a39ec70340d88a4679364",
"sl": "d5361e799f6c265f718a0ec54568917c",
"sq": "67b98b070b607c81a2e4d9857e010222",
"sr": "279eef54e06d7f7be3058e8d7a0d0a32",
"su": "43655b7e126c08e4cd247f539567bc8f",
"sv": "e3f55e1d56868ed23529da79de4d404b",
"sw": "0d98eeffee4bedc8b82c750a415eddf2",
"tr": "5b1855f15ba6db8d738a70b21df7a723",
"uk": "d693a7da2fc8d4cad8af6f74697a2485",
"ur": "efd055867beb2f589c3ec397402588a1",
"uz": "3235b2b2a826f2950e29def207c9b49e",
"vi": "ec062cb3e07a07f0dcd624c6195a3051",
"zh": "eda02fb56b4a62d84b36d7e5ce24bb10",
"zu": "4aeb5727ff4779c790985963d0081e71"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Slim onderskrif uitleg",
"smartDucking": "Aanpasbare volume",
"VOTAutoSubtitles": "Onderskrifte op open",
"VOTSubtitlesFont": "Onderskrif skrif tipe"
"VOTSubtitlesFont": "Onderskrif skrif tipe",
"VOTDefaultSubtitlesLanguage": "Verstek ondertiteltaal",
"VOTOriginalVideoLanguage": "Oorspronklike video taal"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "ዘመናዊ ንዑስ ርዕስ አቀማመጥ",
"smartDucking": "የመላመድ ድምጽ",
"VOTAutoSubtitles": "ክፍት ላይ ንዑስ ርዕሶች",
"VOTSubtitlesFont": "የፊደል ቅርጽ"
"VOTSubtitlesFont": "የፊደል ቅርጽ",
"VOTDefaultSubtitlesLanguage": "ነባሪ የትርጉም ቋንቋ",
"VOTOriginalVideoLanguage": "የመጀመሪያው የቪዲዮ ቋንቋ"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "تخطيط الترجمة الذكية",
"smartDucking": "حجم التكيف",
"VOTAutoSubtitles": "ترجمات على فتح",
"VOTSubtitlesFont": "خط الترجمة"
"VOTSubtitlesFont": "خط الترجمة",
"VOTDefaultSubtitlesLanguage": "لغة الترجمة الافتراضية",
"VOTOriginalVideoLanguage": "لغة الفيديو الأصلية"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Düşüncəli Altyazı düzeni",
"smartDucking": "Adaptiv həcm",
"VOTAutoSubtitles": "Açıldıqda altyazılar",
"VOTSubtitlesFont": "Altyazı şrifti"
"VOTSubtitlesFont": "Altyazı şrifti",
"VOTDefaultSubtitlesLanguage": "Varsayılan Altyazı dili",
"VOTOriginalVideoLanguage": "Orijinal videonun dili"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Интелигентно оформление на субтитрите",
"smartDucking": "Адаптивен обем",
"VOTAutoSubtitles": "Субтитри на открито",
"VOTSubtitlesFont": "Шрифт за субтитрите"
"VOTSubtitlesFont": "Шрифт за субтитрите",
"VOTDefaultSubtitlesLanguage": "Език на субтитрите по подразбиране",
"VOTOriginalVideoLanguage": "Оригинален видео език"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "স্মার্ট সাবটাইটেল বিন্যাস",
"smartDucking": "অভিযোজিত ভলিউম",
"VOTAutoSubtitles": "সাবটাইটেল খোলা",
"VOTSubtitlesFont": "সাবটাইটেল ফন্ট"
"VOTSubtitlesFont": "সাবটাইটেল ফন্ট",
"VOTDefaultSubtitlesLanguage": "ডিফল্ট সাবটাইটেল ভাষা",
"VOTOriginalVideoLanguage": "মূল ভিডিও ভাষা"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Smart izgled titlova",
"smartDucking": "Adaptivni volumen",
"VOTAutoSubtitles": "Subtitles on open",
"VOTSubtitlesFont": "Font za titlove"
"VOTSubtitlesFont": "Font za titlove",
"VOTDefaultSubtitlesLanguage": "Uobičajeni jezik titlova",
"VOTOriginalVideoLanguage": "Originalni video jezik"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Disseny de subtítols intel ligent",
"smartDucking": "Volum adaptatiu",
"VOTAutoSubtitles": "Subtítols en obert",
"VOTSubtitlesFont": "Tipus de lletra dels subtítols"
"VOTSubtitlesFont": "Tipus de lletra dels subtítols",
"VOTDefaultSubtitlesLanguage": "Idioma predeterminat dels subtítols",
"VOTOriginalVideoLanguage": "Idioma original del vídeo"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Inteligentní rozložení titulků",
"smartDucking": "Adaptivní objem",
"VOTAutoSubtitles": "Titulky na open",
"VOTSubtitlesFont": "Písmo titulků"
"VOTSubtitlesFont": "Písmo titulků",
"VOTDefaultSubtitlesLanguage": "Výchozí jazyk titulků",
"VOTOriginalVideoLanguage": "Původní jazyk videa"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Cynllun is-deitl Smart",
"smartDucking": "Cyfrol addasol",
"VOTAutoSubtitles": "Isdeitlau ar agor",
"VOTSubtitlesFont": "Ffont isdeitlau"
"VOTSubtitlesFont": "Ffont isdeitlau",
"VOTDefaultSubtitlesLanguage": "Iaith isdeitlau rhagosodedig",
"VOTOriginalVideoLanguage": "Iaith fideo gwreiddiol"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Smart undertekstlayout",
"smartDucking": "Adaptiv volumen",
"VOTAutoSubtitles": "Undertekster på åben",
"VOTSubtitlesFont": "Undertekstfont"
"VOTSubtitlesFont": "Undertekstfont",
"VOTDefaultSubtitlesLanguage": "Standard undertekstsprog",
"VOTOriginalVideoLanguage": "Original video sprog"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Intelligentes Untertitel-Layout",
"smartDucking": "Adaptives Volumen",
"VOTAutoSubtitles": "Untertitel auf open",
"VOTSubtitlesFont": "Schriftart für Untertitel"
"VOTSubtitlesFont": "Schriftart für Untertitel",
"VOTDefaultSubtitlesLanguage": "Standard-Untertitelsprache",
"VOTOriginalVideoLanguage": "Originalsprache des Videos"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Έξυπνη διάταξη υποτίτλων",
"smartDucking": "Προσαρμοστικός όγκος",
"VOTAutoSubtitles": "Υπότιτλοι σε ανοιχτό",
"VOTSubtitlesFont": "Γραμματοσειρά υποτίτλων"
"VOTSubtitlesFont": "Γραμματοσειρά υποτίτλων",
"VOTDefaultSubtitlesLanguage": "Προεπιλεγμένη γλώσσα υποτίτλων",
"VOTOriginalVideoLanguage": "Αρχική γλώσσα βίντεο"
}

View file

@ -32,6 +32,8 @@
"VOTNoVideoIDFound": "No video ID found",
"VOTSubtitles": "Subtitles",
"VOTSubtitlesDisabled": "Disabled",
"VOTDefaultSubtitlesLanguage": "Default subtitle language",
"VOTOriginalVideoLanguage": "Original video language",
"VOTSubtitlesMaxLength": "Subtitles max length",
"VOTHighlightWords": "Highlight words",
"VOTTranslatedFrom": "translated from",

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Diseño inteligente de subtítulos",
"smartDucking": "Volumen adaptable",
"VOTAutoSubtitles": "Subtítulos en abierto",
"VOTSubtitlesFont": "Fuente de subtítulos"
"VOTSubtitlesFont": "Fuente de subtítulos",
"VOTDefaultSubtitlesLanguage": "Idioma de subtítulos predeterminado",
"VOTOriginalVideoLanguage": "Idioma del video original"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Nutikas subtiitrite paigutus",
"smartDucking": "Adaptiivne maht",
"VOTAutoSubtitles": "Subtiitrid avatud",
"VOTSubtitlesFont": "Subtiitrite font"
"VOTSubtitlesFont": "Subtiitrite font",
"VOTDefaultSubtitlesLanguage": "Vaikimisi Subtiitrite keel",
"VOTOriginalVideoLanguage": "Originaal video keel"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Azpitituluen diseinu adimenduna",
"smartDucking": "Bolumen egokitzailea",
"VOTAutoSubtitles": "Azpitituluak irekita",
"VOTSubtitlesFont": "Azpitituluaren letra-tipoa"
"VOTSubtitlesFont": "Azpitituluaren letra-tipoa",
"VOTDefaultSubtitlesLanguage": "Azpitituluen hizkuntza lehenetsia",
"VOTOriginalVideoLanguage": "Jatorrizko bideo hizkuntza"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "طرح زیرنویس هوشمند",
"smartDucking": "حجم سازگاری",
"VOTAutoSubtitles": "زیرنویس در open",
"VOTSubtitlesFont": "فونت زیرنویس"
"VOTSubtitlesFont": "فونت زیرنویس",
"VOTDefaultSubtitlesLanguage": "زبان زیرنویس پیش فرض",
"VOTOriginalVideoLanguage": "زبان اصلی ویدئو"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Älykäs tekstityksen asettelu",
"smartDucking": "Mukautuva äänenvoimakkuus",
"VOTAutoSubtitles": "Tekstitys auki",
"VOTSubtitlesFont": "Tekstityksen kirjasin"
"VOTSubtitlesFont": "Tekstityksen kirjasin",
"VOTDefaultSubtitlesLanguage": "Tekstityksen oletuskieli",
"VOTOriginalVideoLanguage": "Alkuperäinen videon kieli"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Disposition intelligente des sous-titres",
"smartDucking": "Volume adaptatif",
"VOTAutoSubtitles": "Sous-titres à l'ouverture",
"VOTSubtitlesFont": "Police des sous-titres"
"VOTSubtitlesFont": "Police des sous-titres",
"VOTDefaultSubtitlesLanguage": "Langue des sous-titres par défaut",
"VOTOriginalVideoLanguage": "Langue originale de la vidéo"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Deseño de subtítulos intelixentes",
"smartDucking": "Volume adaptativo",
"VOTAutoSubtitles": "Subtítulos en open",
"VOTSubtitlesFont": "Fonte de subtítulos"
"VOTSubtitlesFont": "Fonte de subtítulos",
"VOTDefaultSubtitlesLanguage": "Linguaxe de subtítulos predeterminada",
"VOTOriginalVideoLanguage": "Linguaxe de vídeo orixinal"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "स्मार्ट उपशीर्षक लेआउट",
"smartDucking": "अनुकूली मात्रा",
"VOTAutoSubtitles": "उपशीर्षक पर खुला",
"VOTSubtitlesFont": "उपशीर्षक फ़ॉन्ट"
"VOTSubtitlesFont": "उपशीर्षक फ़ॉन्ट",
"VOTDefaultSubtitlesLanguage": "डिफ़ॉल्ट उपशीर्षक भाषा",
"VOTOriginalVideoLanguage": "मूल वीडियो भाषा"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Promišljeni izgled titlova",
"smartDucking": "Prilagodljiva glasnoća",
"VOTAutoSubtitles": "Titlovi prilikom otvaranja",
"VOTSubtitlesFont": "Font titlova"
"VOTSubtitlesFont": "Font titlova",
"VOTDefaultSubtitlesLanguage": "Zadani jezik titlova",
"VOTOriginalVideoLanguage": "Jezik izvornog videa"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Intelligens felirat elrendezés",
"smartDucking": "Adaptív kötet",
"VOTAutoSubtitles": "Feliratok nyitva",
"VOTSubtitlesFont": "Felirat betűtípus"
"VOTSubtitlesFont": "Felirat betűtípus",
"VOTDefaultSubtitlesLanguage": "Alapértelmezett felirat nyelv",
"VOTOriginalVideoLanguage": "Eredeti videó nyelv"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Մտածված ենթագրերի դասավորություն",
"smartDucking": "Հարմարվողական ծավալ",
"VOTAutoSubtitles": "Ենթագրերը բացելիս",
"VOTSubtitlesFont": "Ենթագրերի տառատեսակ"
"VOTSubtitlesFont": "Ենթագրերի տառատեսակ",
"VOTDefaultSubtitlesLanguage": "Ենթագրերի լռելյայն լեզու",
"VOTOriginalVideoLanguage": "Բնօրինակ տեսանյութի լեզուն"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Tata letak subtitle cerdas",
"smartDucking": "Volume adaptif",
"VOTAutoSubtitles": "Subtitle terbuka",
"VOTSubtitlesFont": "Font teks terjemahan"
"VOTSubtitlesFont": "Font teks terjemahan",
"VOTDefaultSubtitlesLanguage": "Bahasa subtitle default",
"VOTOriginalVideoLanguage": "Bahasa video asli"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Layout intelligente dei sottotitoli",
"smartDucking": "Volume adattivo",
"VOTAutoSubtitles": "Sottotitoli su open",
"VOTSubtitlesFont": "Carattere sottotitoli"
"VOTSubtitlesFont": "Carattere sottotitoli",
"VOTDefaultSubtitlesLanguage": "Lingua predefinita dei sottotitoli",
"VOTOriginalVideoLanguage": "Lingua video originale"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "スマート字幕レイアウト",
"smartDucking": "適応ボリューム",
"VOTAutoSubtitles": "オープン時の字幕",
"VOTSubtitlesFont": "字幕フォント"
"VOTSubtitlesFont": "字幕フォント",
"VOTDefaultSubtitlesLanguage": "デフォルトの字幕言語",
"VOTOriginalVideoLanguage": "オリジナルビデオ言語"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Tata letak subtitle pinter",
"smartDucking": "Volume adaptif",
"VOTAutoSubtitles": "Subtitle ing mbukak",
"VOTSubtitlesFont": "Font Subtitle"
"VOTSubtitlesFont": "Font Subtitle",
"VOTDefaultSubtitlesLanguage": "Basa subtitle standar",
"VOTOriginalVideoLanguage": "Basa video asli"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Ақылды субтитр орналасуы",
"smartDucking": "Адаптивті көлем",
"VOTAutoSubtitles": "Ашық субтитрлер",
"VOTSubtitlesFont": "Субтитр қаріпі"
"VOTSubtitlesFont": "Субтитр қаріпі",
"VOTDefaultSubtitlesLanguage": "Әдепкі субтитр тілі",
"VOTOriginalVideoLanguage": "Түпнұсқа бейне тілі"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "ប្លង់ចំណងជើងរងរបស់ Smart",
"smartDucking": "កម្រិតសំឡេងសម្របសម្រួល",
"VOTAutoSubtitles": "បើកចំណងជើងរង",
"VOTSubtitlesFont": "ពុម្ពអក្សរចំណងជើងរង"
"VOTSubtitlesFont": "ពុម្ពអក្សរចំណងជើងរង",
"VOTDefaultSubtitlesLanguage": "ភាសាចំណងជើងរងលំនាំដើម",
"VOTOriginalVideoLanguage": "ភាសាវីដេអូដើម"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "ಸ್ಮಾರ್ಟ್ ಉಪಶೀರ್ಷಿಕೆ ಲೇಔಟ್",
"smartDucking": "ಹೊಂದಾಣಿಕೆಯ ಪರಿಮಾಣ",
"VOTAutoSubtitles": "ತೆರೆಯ ಮೇಲೆ ಉಪಶೀರ್ಷಿಕೆಗಳು",
"VOTSubtitlesFont": "ಉಪಶೀರ್ಷಿಕೆ ಫಾಂಟ್"
"VOTSubtitlesFont": "ಉಪಶೀರ್ಷಿಕೆ ಫಾಂಟ್",
"VOTDefaultSubtitlesLanguage": "ಡೀಫಾಲ್ಟ್ ಉಪಶೀರ್ಷಿಕೆ ಭಾಷೆ",
"VOTOriginalVideoLanguage": "ಮೂಲ ವೀಡಿಯೊ ಭಾಷೆ"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "스마트 자막 레이아웃",
"smartDucking": "적응 형 볼륨",
"VOTAutoSubtitles": "자막 열기",
"VOTSubtitlesFont": "자막 글꼴"
"VOTSubtitlesFont": "자막 글꼴",
"VOTDefaultSubtitlesLanguage": "기본 자막 언어",
"VOTOriginalVideoLanguage": "원본 비디오 언어"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "ຮູບແບບຄໍາບັນຍາຍອັດສະລິຍະ",
"smartDucking": "ປະລິມານການປັບຕົວ",
"VOTAutoSubtitles": "ຄໍາບັນຍາຍເປີດ",
"VOTSubtitlesFont": "ຕົວອັກສອນບັນຍາຍ"
"VOTSubtitlesFont": "ຕົວອັກສອນບັນຍາຍ",
"VOTDefaultSubtitlesLanguage": "ພາສາຄໍາບັນຍາຍໃນຕອນຕົ້ນ",
"VOTOriginalVideoLanguage": "ພາສາວິດີໂອຕົ້ນສະບັບ"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Паметен поднаслов распоред",
"smartDucking": "Адаптивен волумен",
"VOTAutoSubtitles": "Преводи на отворено",
"VOTSubtitlesFont": "Титл фонт"
"VOTSubtitlesFont": "Титл фонт",
"VOTDefaultSubtitlesLanguage": "Стандарден јазик за превод",
"VOTOriginalVideoLanguage": "Оригинален видео јазик"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "സ്മാർട്ട് സബ്ടൈറ്റിൽ ലേഔട്ട്",
"smartDucking": "അഡാപ്റ്റീവ് വോള്യം",
"VOTAutoSubtitles": "സബ്ടൈറ്റിലുകൾ ഓപ്പൺ",
"VOTSubtitlesFont": "സബ്ടൈറ്റില് അക്ഷരസഞ്ചയം"
"VOTSubtitlesFont": "സബ്ടൈറ്റില് അക്ഷരസഞ്ചയം",
"VOTDefaultSubtitlesLanguage": "സ്വതവേയുള്ള സബ്ടൈറ്റില് ഭാഷ",
"VOTOriginalVideoLanguage": "യഥാർത്ഥ വീഡിയോ ഭാഷ"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Smart subtitle зохион байгуулалт",
"smartDucking": "Дасан зохицох хэмжээ",
"VOTAutoSubtitles": "Нээлттэй дээр хадмалтай",
"VOTSubtitlesFont": "Хадмал үсгийн фонт"
"VOTSubtitlesFont": "Хадмал үсгийн фонт",
"VOTDefaultSubtitlesLanguage": "Анхдагч хадмал хэл",
"VOTOriginalVideoLanguage": "Анхны Видео хэл"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Susun atur sari kata pintar",
"smartDucking": "Isipadu adaptif",
"VOTAutoSubtitles": "Subtitle di buka",
"VOTSubtitlesFont": "Font subtajuk"
"VOTSubtitlesFont": "Font subtajuk",
"VOTDefaultSubtitlesLanguage": "Bahasa sari kata lalai",
"VOTOriginalVideoLanguage": "Bahasa video asal"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Tqassim ta'sottotitolu intelliġenti",
"smartDucking": "Volum adattiv",
"VOTAutoSubtitles": "Sottotitoli fuq miftuħa",
"VOTSubtitlesFont": "Font tas-sottotitolu"
"VOTSubtitlesFont": "Font tas-sottotitolu",
"VOTDefaultSubtitlesLanguage": "Lingwa tas-sottotitolu Default",
"VOTOriginalVideoLanguage": "Lingwa tal-vidjo oriġinali"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "စမတ်စာတန်းထိုး layout ကို",
"smartDucking": "အလိုက်သင့်ပြောင်းလဲနိုင်သောပမာဏ",
"VOTAutoSubtitles": "Open မှာစာတန်းထိုးထားတယ်။",
"VOTSubtitlesFont": "စာတန်းထိုးစာလုံး"
"VOTSubtitlesFont": "စာတန်းထိုးစာလုံး",
"VOTDefaultSubtitlesLanguage": "မူလစာတန်းထိုးဘာသာစကား",
"VOTOriginalVideoLanguage": "မူရင်းဗီဒီယိုဘာသာစကား"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "स्मार्ट उपशीर्षक लेआउट",
"smartDucking": "अनुकूलनशील मात्रा",
"VOTAutoSubtitles": "उपशीर्षकहरू खोल्नुहोस्",
"VOTSubtitlesFont": "उपशीर्षक फन्ट"
"VOTSubtitlesFont": "उपशीर्षक फन्ट",
"VOTDefaultSubtitlesLanguage": "पूर्वनिर्धारित उपशीर्षक भाषा",
"VOTOriginalVideoLanguage": "मूल भिडियो भाषा"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Smart subtitle layout",
"smartDucking": "Adaptief volume",
"VOTAutoSubtitles": "Ondertitels op open",
"VOTSubtitlesFont": "Ondertiteling lettertype"
"VOTSubtitlesFont": "Ondertiteling lettertype",
"VOTDefaultSubtitlesLanguage": "Standaard ondertiteltaal",
"VOTOriginalVideoLanguage": "Originele videotaal"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "ਸਮਾਰਟ ਉਪਸਿਰਲੇਖ ਲੇਆਉਟ",
"smartDucking": "ਅਨੁਕੂਲ ਵਾਲੀਅਮ",
"VOTAutoSubtitles": "ਓਪਨ ' ਤੇ ਉਪਸਿਰਲੇਖ",
"VOTSubtitlesFont": "ਉਪਸਿਰਲੇਖ ਫੋਂਟ"
"VOTSubtitlesFont": "ਉਪਸਿਰਲੇਖ ਫੋਂਟ",
"VOTDefaultSubtitlesLanguage": "ਮੂਲ ਉਪਸਿਰਲੇਖ ਭਾਸ਼ਾ",
"VOTOriginalVideoLanguage": "ਮੂਲ ਵੀਡੀਓ ਭਾਸ਼ਾ"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Inteligentny układ napisów",
"smartDucking": "Adaptacyjna objętość",
"VOTAutoSubtitles": "Napisy na open",
"VOTSubtitlesFont": "Czcionka napisów"
"VOTSubtitlesFont": "Czcionka napisów",
"VOTDefaultSubtitlesLanguage": "Domyślny język napisów",
"VOTOriginalVideoLanguage": "Oryginalny język wideo"
}

View file

@ -241,5 +241,7 @@
"subtitlesSmartLayout": "Smart subtitle layout",
"smartDucking": "Volume adaptativo",
"VOTAutoSubtitles": "Legendas em aberto",
"VOTSubtitlesFont": "Fonte do subtítulo"
"VOTSubtitlesFont": "Fonte do subtítulo",
"VOTDefaultSubtitlesLanguage": "Idioma predefinido das legendas",
"VOTOriginalVideoLanguage": "Língua do vídeo Original"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Aspect inteligent de subtitrare",
"smartDucking": "Volum adaptiv",
"VOTAutoSubtitles": "Subtitrari pe open",
"VOTSubtitlesFont": "Font subtitrare"
"VOTSubtitlesFont": "Font subtitrare",
"VOTDefaultSubtitlesLanguage": "Limba implicită pentru subtitrare",
"VOTOriginalVideoLanguage": "Limba video originală"
}

View file

@ -30,6 +30,8 @@
"VOTNoVideoIDFound": "Не найден идентификатор (ID) видео",
"VOTSubtitles": "Субтитры",
"VOTSubtitlesDisabled": "Отключены",
"VOTDefaultSubtitlesLanguage": "Язык субтитров по умолчанию",
"VOTOriginalVideoLanguage": "Язык оригинального видео",
"VOTSubtitlesMaxLength": "Максимальная длина субтитров",
"VOTHighlightWords": "Подсвечивать слова",
"VOTTranslatedFrom": "переведено с",

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "ස්මාර්ට් උපසිරැසි සැකැස්ම",
"smartDucking": "අනුවර්තන පරිමාව",
"VOTAutoSubtitles": "විවෘත උපසිරැසි",
"VOTSubtitlesFont": "උපසිරැසි අකුරු"
"VOTSubtitlesFont": "උපසිරැසි අකුරු",
"VOTDefaultSubtitlesLanguage": "පෙරනිමි උපසිරැසි භාෂාව",
"VOTOriginalVideoLanguage": "මුල් වීඩියෝ භාෂාව"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Inteligentné rozloženie titulkov",
"smartDucking": "Adaptívny objem",
"VOTAutoSubtitles": "Titulky otvorené",
"VOTSubtitlesFont": "Písmo titulkov"
"VOTSubtitlesFont": "Písmo titulkov",
"VOTDefaultSubtitlesLanguage": "Predvolený jazyk titulkov",
"VOTOriginalVideoLanguage": "Pôvodný jazyk videa"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Pametna postavitev podnapisov",
"smartDucking": "Prilagodljiva prostornina",
"VOTAutoSubtitles": "Podnapisi na odprtem",
"VOTSubtitlesFont": "Pisava podnapisov"
"VOTSubtitlesFont": "Pisava podnapisov",
"VOTDefaultSubtitlesLanguage": "Privzeti jezik podnapisov",
"VOTOriginalVideoLanguage": "Izvirni video jezik"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Paraqitja e zgjuar e titrave",
"smartDucking": "Vëllimi adaptiv",
"VOTAutoSubtitles": "Titra në open",
"VOTSubtitlesFont": "Shkronja e titrave"
"VOTSubtitlesFont": "Shkronja e titrave",
"VOTDefaultSubtitlesLanguage": "Gjuha e parazgjedhur e titrave",
"VOTOriginalVideoLanguage": "Gjuha origjinale e videos"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Паметан изглед титлова",
"smartDucking": "Адаптивна јачина звука",
"VOTAutoSubtitles": "Титлови приликом отварања",
"VOTSubtitlesFont": "Фонт титлова"
"VOTSubtitlesFont": "Фонт титлова",
"VOTDefaultSubtitlesLanguage": "Подразумевани језик титлова",
"VOTOriginalVideoLanguage": "Језик оригиналног видеа"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Tata perenah subjudul pinter",
"smartDucking": "Volume adaptif",
"VOTAutoSubtitles": "Subjudul dina buka",
"VOTSubtitlesFont": "Font subjudul"
"VOTSubtitlesFont": "Font subjudul",
"VOTDefaultSubtitlesLanguage": "Basa subjudul standar",
"VOTOriginalVideoLanguage": "Basa video asli"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Smart textning layout",
"smartDucking": "Adaptiv volym",
"VOTAutoSubtitles": "Undertexter på open",
"VOTSubtitlesFont": "Textning teckensnitt"
"VOTSubtitlesFont": "Textning teckensnitt",
"VOTDefaultSubtitlesLanguage": "Standard undertextspråk",
"VOTOriginalVideoLanguage": "Original video språk"
}

View file

@ -242,5 +242,7 @@
"subtitlesSmartLayout": "Mpangilio wa manukuu mahiri",
"smartDucking": "Kiasi cha kubadilika",
"VOTAutoSubtitles": "Subtitles juu ya wazi",
"VOTSubtitlesFont": "Fonti ya kichwa kidogo"
"VOTSubtitlesFont": "Fonti ya kichwa kidogo",
"VOTDefaultSubtitlesLanguage": "Default lugha ya kichwa kidogo",
"VOTOriginalVideoLanguage": "Lugha ya awali ya video"
}

Some files were not shown because too many files have changed in this diff Show more