Release v1.11.5: UI rewrite and bugfixes

Bump release to 1.11.5 with several fixes and refactors: rewrite of UI mounting, simplified audio loading, and multiple site compatibility fixes (VKVideo subtitles, mobile audio downloads, archive.org, Coursehunter, Yandex.Disk /d, Reddit, Odysee, YouTube Embed, proxy auto-enable logic). Update changelog, bump userscript/extension versions and publish new Chrome/Firefox build artifacts; update firefox updates manifest. Add shadow DOM mount support and other UI/module improvements across source files, plus general dependency and build updates.
This commit is contained in:
NullVerdict 2026-04-12 01:57:06 +04:00
parent 1adeac38e6
commit 34e54405c8
36 changed files with 10520 additions and 696 deletions

View file

@ -10,6 +10,7 @@
"@vot.js/shared": "^2.4.12",
"bowser": "^2.14.1",
"chaimu": "^1.0.6",
"lit": "^3.3.2",
"lit-html": "^3.3.2",
},
"devDependencies": {
@ -17,12 +18,11 @@
"@types/bun": "^1.3.11",
"@vot.js/core": "^2.4.12",
"crx3": "^2.0.0",
"lefthook": "^2.1.4",
"lightningcss": "^1.32.0",
"npm-run-all2": "^8.0.4",
"sass": "^1.98.0",
"typescript": "^5.9.3",
"vite": "^8.0.1",
"sass": "^1.99.0",
"typescript": "^6.0.2",
"vite": "^8.0.5",
"zip-a-folder": "^6.1.0",
},
},
@ -36,45 +36,49 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.1", "", {}, "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA=="],
"@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="],
"@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=="],
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
"@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" }, ""],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.12", "", { "os": "android", "cpu": "arm64" }, "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q=="],
"@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=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.12", "", { "os": "none", "cpu": "arm64" }, "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA=="],
"@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=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.12", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg=="],
"@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=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.12", "", { "os": "win32", "cpu": "x64" }, "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
"@toil/gm-types": ["@toil/gm-types@1.0.4", "", { "peerDependencies": { "typescript": "^5.8.2" } }, "sha512-DbqZsYYrVXaBB4usDN3/HbkIhk2M+ECKv06lEScSoCMJUZ1qbtpbYLIqioEk2GE9adwWjNpH+y/Bf0ij3wQxhw=="],
@ -132,28 +136,6 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@4.0.0", "", {}, "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA=="],
"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.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw=="],
"lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-K1ncIMEe84fe+ss1hQNO7rIvqiKy2TJvTFpkypvqFodT7mJXZn7GLKYTIXdIuyPAYthRa9DwFnx5uMoHwD2F1Q=="],
"lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-PVUhjOhVN71YaYsVdQyNbFZ4a2jFB2Tg5hKrrn9kaWpx64aLz/XivLjwr8sEuTaP1GRlEWBpW6Bhrcsyo39qFw=="],
"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.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iWN0pGnTjrIvNIcSI1vQBJXUbybTqJ5CLMniPA0olabMXQfPDrdMKVQe+mgdwHK+E3/Y0H0ZNL3lnOj6Sk6szA=="],
"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.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-oYUoK6AIJNEr9lUSpIMj6g7sWzotvtc3ryw7yoOyQM6uqmEduw73URV/qGoUcm4nqqmR93ZalZwR2r3Gd61zvw=="],
"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.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-hSww7z+QX4YMnw2lK7DMrs3+w7NtxksuMKOkCKGyxUAC/0m1LAICo0ZbtdDtZ7agxRQQQ/SEbzFRhU5ysNcbjA=="],
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-eE68LwnogxwcPgGsbVGPGxmghyMGmU9SdGwcc+uhGnUxPz1jL89oECMWJNc36zjVK24umNeDAzB5KA3lw1MuWw=="],
"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.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@ -178,6 +160,10 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lit": ["lit@3.3.2", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ=="],
"lit-element": ["lit-element@4.2.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w=="],
"lit-html": ["lit-html@3.3.2", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw=="],
"lzma": ["lzma@2.3.2", "", { "bin": { "lzma.js": "bin/lzma.js" } }, "sha512-DcfiawQ1avYbW+hsILhF38IKAlnguc/fjHrychs9hdxe4qLykvhT5VTGNs5YRWgaNePh7NTxGD4uv4gKsRomCQ=="],
@ -216,9 +202,9 @@
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
"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=="],
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
"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=="],
"sass": ["sass@1.99.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-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, ""],
@ -234,11 +220,11 @@
"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=="],
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"undici-types": ["undici-types@5.26.5", "", {}, ""],
"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=="],
"vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "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 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
@ -252,6 +238,8 @@
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, ""],
"vite/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, ""],
}
}

View file

@ -1,4 +1,19 @@
# 1.11.4
# 1.11.5
- Переписан механизм монтирования UI.
- Упрощена логика загрзуки аудио.
- Исправлено получение субтитров на VKVideo (#1424)
- Исправлена загрузка аудио на мобильных устройствах (#1646)
- Улучшена/исправлена работа archive.org (#1636)
- Исправлена работа Coursehunter (#1476)
- Исправлена работа /d/ для Яндекс Диска (#1572)
- Исправлена работа Reddit (#1613)
- Исправлена работа Odysee (#1643)
- Исправлена работа YouTube Embed (#1637)
- Исправлена логика автоматического включения проксирования (#1638)
- Обновлены зависимости и проведена оптимизация/очистка кода
# 1.11.4
- Добавлена поддержка Mediafile Cloud (#1603), Jove (#1593), Datacamp (#1606), PreserveTube (#1366)
- Добавлена настройка "Язык субтитров по умолчанию": можно выбрать автоопределение, язык оригинального видео или конкретный язык

Binary file not shown.

Binary file not shown.

View file

@ -3,8 +3,13 @@
"vot-extension@firefox": {
"updates": [
{
<<<<<<< Updated upstream
"version": "1.11.4",
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox-1.11.4.xpi"
=======
"version": "1.11.5",
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox-1.11.5.xpi"
>>>>>>> Stashed changes
}
]
}

123
dist/vot-min.user.js vendored

File diff suppressed because one or more lines are too long

9650
dist/vot.user.js vendored

File diff suppressed because one or more lines are too long

View file

@ -18,13 +18,13 @@
"@toil/translate": "^1.0.8",
"@types/bun": "^1.3.11",
"@vot.js/core": "^2.4.12",
"sass": "^1.98.0",
"crx3": "^2.0.0",
"lefthook": "^2.1.4",
"lefthook": "^2.1.5",
"lightningcss": "^1.32.0",
"npm-run-all2": "^8.0.4",
"sass": "^1.99.0",
"typescript": "^5.9.3",
"vite": "^8.0.1",
"vite": "^8.0.5",
"zip-a-folder": "^6.1.0"
},
"scripts": {
@ -50,6 +50,7 @@
"@vot.js/shared": "^2.4.12",
"bowser": "^2.14.1",
"chaimu": "^1.0.6",
"lit": "^3.3.2",
"lit-html": "^3.3.2"
}
}

View file

@ -73,6 +73,14 @@ Limitations:
- Doesn't work in the video preview
- To ensure that the script works, you need to [enable the "Bypass Media CSP" setting](https://github.com/ilyhalight/voice-over-translation/wiki/%5BEN%5D-FAQ) in the extension or delete the CSP in another way
## Preservetube
Status: [✅] Working
Available (sub)domains:
- `preservetube.com`
## Zdf
Status: [✅] Working
@ -510,6 +518,7 @@ Available (sub)domains:
- `dailymotion.com`
- `www.dailymotion.com`
- `player.dailymotion.com`
- `geo*.dailymotion.com` (embedded player, on www.dailymotion.com it works)
- `dai.ly`
@ -694,9 +703,7 @@ Status: [✅] Working
Available (sub)domains:
- `kodik.info`
- `kodik.biz`
- `kodik.cc`
- `kodikplayer.com`
## Patreon
@ -829,6 +836,16 @@ Limitations:
- You must be logged in to the site
## Datacamp
Status: [✅] Working
Available (sub)domains:
- `datacamp.com`
- `campus.datacamp.com`
- `projector.datacamp.com`
## Coursera
Status: [✅] Working
@ -859,6 +876,15 @@ Available paths:
- /video/VIDEO_ID/VIDEO_NAME
- /embed/VIDEO_ID
## Jove
Status: [✅] Working
Available (sub)domains:
- `app.jove.com`
- `www.jove.com`
## Linkedin
Status: [✅] Working
@ -905,6 +931,16 @@ Available paths:
- /video/watch/VIDEO_ID
## Bunnystream
Status: [✅] Working
Available (sub)domains:
- `video.bunnycdn.com`
- `iframe.mediadelivery.net`
- `*b-cdn.net`
## Cloudflarestream
Status: [✅] Working
@ -1108,6 +1144,15 @@ Available paths:
- /content/i2cs/*
## Mediafile
Status: [✅] Working
Available (sub)domains:
- `mediafile.cc`
- `www.mediafile.cc`
## Direct link to MP4/WEBM
Status: [✅] Working

View file

@ -73,6 +73,14 @@
- Не работает в предпросмотре видео
- Для гарантированной работы скрипта необходимо [включить настройку "Обход Media CSP"](https://github.com/ilyhalight/voice-over-translation/wiki/%5BRU%5D-FAQ) в расширение или удалить CSP другим способом
## Preservetube
Статус: [✅] Работает
Доступные (под)домены:
- `preservetube.com`
## Zdf
Статус: [✅] Работает
@ -510,6 +518,7 @@
- `dailymotion.com`
- `www.dailymotion.com`
- `player.dailymotion.com`
- `geo*.dailymotion.com` (встраиваемый плеер, на www.dailymotion.com работает)
- `dai.ly`
@ -694,9 +703,7 @@
Доступные (под)домены:
- `kodik.info`
- `kodik.biz`
- `kodik.cc`
- `kodikplayer.com`
## Patreon
@ -829,6 +836,16 @@
- Необходимо быть авторизованным на сайте
## Datacamp
Статус: [✅] Работает
Доступные (под)домены:
- `datacamp.com`
- `campus.datacamp.com`
- `projector.datacamp.com`
## Coursera
Статус: [✅] Работает
@ -859,6 +876,15 @@
- /video/VIDEO_ID/VIDEO_NAME
- /embed/VIDEO_ID
## Jove
Статус: [✅] Работает
Доступные (под)домены:
- `app.jove.com`
- `www.jove.com`
## Linkedin
Статус: [✅] Работает
@ -905,6 +931,16 @@
- /video/watch/VIDEO_ID
## Bunnystream
Статус: [✅] Работает
Доступные (под)домены:
- `video.bunnycdn.com`
- `iframe.mediadelivery.net`
- `*b-cdn.net`
## Cloudflarestream
Статус: [✅] Работает
@ -1108,6 +1144,15 @@
- /content/i2cs/*
## Mediafile
Статус: [✅] Работает
Доступные (под)домены:
- `mediafile.cc`
- `www.mediafile.cc`
## Direct link to MP4/WEBM
Статус: [✅] Работает

View file

@ -635,7 +635,7 @@ function getSupportedSites() {
async function main() {
const supportedSites = getSupportedSites();
const langs = ["ru", "en"];
for await (const lang of langs) {
for (const lang of langs) {
const mdText = `${genMarkdown(supportedSites, lang).join("\n\n")}\n`;
await Bun.write(

View file

@ -16,8 +16,8 @@ Use `AudioDownloader.downloadAudioToUint8Array(...)`:
const downloader = new AudioDownloader({ fetchImplementation });
const result = await downloader.downloadAudioToUint8Array({
videoId: "memM8flkwrA",
client: "ANDROID",
videoQuality: "bestefficiency",
client: "ANDROID_VR",
audioQuality: "bestefficiency",
signal,
});
@ -27,6 +27,6 @@ const result = await downloader.downloadAudioToUint8Array({
The module is intended to run in browser context and returns audio bytes for
further chunking/upload by the parent audio downloader strategy.
Supported `videoQuality` values:
Supported `audioQuality` values:
- `best`
- `bestefficiency`

View file

@ -10,5 +10,4 @@ export {
AudioDownloader,
buildClientAttemptOrder,
extractVideoId,
YtWatchContextForbiddenError,
} from "./src/AudioDownloader";

View file

@ -6,27 +6,13 @@ import {
} from "./internal/format-selection";
export type AudioDownloadQuality = ProgressiveQuality;
export type AudioDownloadClient =
| "IOS"
| "WEB"
| "MWEB"
| "ANDROID"
| "ANDROID_VR"
| "YTMUSIC"
| "YTMUSIC_ANDROID"
| "YTSTUDIO_ANDROID"
| "TV"
| "TV_SIMPLY"
| "TV_EMBEDDED"
| "YTKIDS"
| "WEB_EMBEDDED"
| "WEB_CREATOR";
export type AudioDownloadClient = "ANDROID_VR";
export interface AudioStreamRequest {
videoId?: string;
videoUrl?: string;
client?: AudioDownloadClient;
videoQuality?: AudioDownloadQuality;
audioQuality?: AudioDownloadQuality;
signal?: AbortSignal;
}
@ -59,16 +45,6 @@ export interface AudioDownloaderOptions {
fetchImplementation?: typeof fetch;
}
export class YtWatchContextForbiddenError extends Error {
readonly status: number;
constructor(status = 403) {
super(`Failed to load watch page: ${status}`);
this.name = "YtWatchContextForbiddenError";
this.status = status;
}
}
interface WatchContext {
apiKey: string;
clientVersion: string;
@ -108,16 +84,8 @@ interface ResolvedPlayableFormat {
const VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/;
const YT_BASE = "https://www.youtube.com";
const ANDROID_CLIENT_VERSION = "19.44.38";
const ANDROID_VR_CLIENT_VERSION = "1.60.19";
const IOS_CLIENT_VERSION = "19.45.4";
const CLIENT_FALLBACK_ORDER: readonly AudioDownloadClient[] = [
"ANDROID_VR",
"ANDROID",
"IOS",
"WEB",
"MWEB",
];
const CLIENTS: readonly AudioDownloadClient[] = ["ANDROID_VR"];
const DEFAULT_HEADERS = {
accept: "*/*",
origin: YT_BASE,
@ -131,64 +99,21 @@ function withSignal(signal: AbortSignal | undefined): RequestInit {
function resolveInnertubeClient(
requestedClient: AudioDownloadClient | undefined,
watchContext: WatchContext,
videoId: string,
): Record<string, unknown> {
switch (requestedClient) {
case "ANDROID":
case "YTMUSIC_ANDROID":
case "YTSTUDIO_ANDROID":
return {
clientName: "ANDROID",
clientVersion: ANDROID_CLIENT_VERSION,
hl: "en",
gl: "US",
androidSdkVersion: 34,
osName: "Android",
osVersion: "14",
platform: "MOBILE",
};
case "ANDROID_VR":
return {
clientName: "ANDROID_VR",
clientVersion: ANDROID_VR_CLIENT_VERSION,
hl: "en",
gl: "US",
androidSdkVersion: 31,
osName: "Android",
osVersion: "12",
platform: "MOBILE",
};
case "IOS":
return {
clientName: "IOS",
clientVersion: IOS_CLIENT_VERSION,
hl: "en",
gl: "US",
platform: "MOBILE",
osName: "iPhone",
osVersion: "18.0.0.22A3354",
deviceMake: "Apple",
deviceModel: "iPhone16,2",
};
case "MWEB":
return {
clientName: "MWEB",
clientVersion: watchContext.clientVersion,
hl: "en",
gl: "US",
originalUrl: `${YT_BASE}/watch?v=${videoId}`,
};
default:
return {
clientName: "WEB",
clientVersion: watchContext.clientVersion,
hl: "en",
gl: "US",
utcOffsetMinutes: 0,
originalUrl: `${YT_BASE}/watch?v=${videoId}`,
};
if (requestedClient !== undefined && requestedClient !== "ANDROID_VR") {
throw new Error(`Unsupported Innertube client: ${requestedClient}`);
}
return {
clientName: "ANDROID_VR",
clientVersion: ANDROID_VR_CLIENT_VERSION,
hl: "en",
gl: "US",
androidSdkVersion: 31,
osName: "Android",
osVersion: "12",
platform: "MOBILE",
};
}
export function extractVideoId(input: string): string {
@ -416,8 +341,8 @@ export function buildClientAttemptOrder(
requestedClient: AudioDownloadClient | undefined,
): AudioDownloadClient[] {
const ordered = requestedClient
? [requestedClient, ...CLIENT_FALLBACK_ORDER]
: [...CLIENT_FALLBACK_ORDER];
? [requestedClient, ...CLIENTS]
: [...CLIENTS];
const seen = new Set<AudioDownloadClient>();
return ordered.filter((client) => {
@ -507,7 +432,7 @@ export class AudioDownloader {
return this.withResolvedPlayableAudioFormat(
request,
request.videoQuality ?? "best",
request.audioQuality ?? "best",
"Chunk mode requires an adaptive audio stream format",
"Unable to resolve streamable format for chunk mode",
async ({ resolved, signal }) => {
@ -577,7 +502,7 @@ export class AudioDownloader {
): Promise<AudioStreamResult> {
return this.withResolvedPlayableAudioFormat(
request,
request.videoQuality ?? "bestefficiency",
request.audioQuality ?? "bestefficiency",
"Selected stream is not audio-only",
"Unable to download playable stream format",
async ({ resolved, signal }) => {
@ -673,10 +598,7 @@ export class AudioDownloader {
quality,
);
const streamUrl = this.resolveFormatUrl(
chosenFormat,
watchContext.clientVersion,
);
const streamUrl = this.resolveFormatUrl(chosenFormat);
return {
videoId,
@ -766,20 +688,12 @@ export class AudioDownloader {
};
}
private resolveFormatUrl(
format: InnertubeFormat,
clientVersion: string,
): string {
private resolveFormatUrl(format: InnertubeFormat): string {
if (!format.url) {
throw new Error("Selected format does not contain a direct stream URL");
}
const streamUrl = new URL(format.url);
const client = streamUrl.searchParams.get("c");
if (client === "WEB") {
streamUrl.searchParams.set("cver", clientVersion);
}
streamUrl.searchParams.set("cpn", makeCPN());
return streamUrl.toString();
}
@ -794,9 +708,6 @@ export class AudioDownloader {
...withSignal(signal),
});
if (!response.ok) {
if (response.status === 403) {
throw new YtWatchContextForbiddenError(response.status);
}
throw new Error(`Failed to load watch page: ${response.status}`);
}
@ -847,11 +758,7 @@ export class AudioDownloader {
requestedClient: AudioDownloadClient | undefined,
signal?: AbortSignal,
): Promise<InnertubePlayerResponse> {
const client = resolveInnertubeClient(
requestedClient,
watchContext,
videoId,
);
const client = resolveInnertubeClient(requestedClient);
if (watchContext.visitorData) {
client.visitorData = watchContext.visitorData;
}

View file

@ -8,7 +8,6 @@ import {
type AudioChunkStreamResult,
type AudioStreamRequest,
AudioDownloader as YtAudioDownloader,
YtWatchContextForbiddenError,
} from "./index";
const DEFAULT_YT_AUDIO_QUALITY = "bestefficiency";
@ -54,17 +53,6 @@ function createYtAudioFetch({
});
}
function isWatchContextForbiddenError(error: unknown): boolean {
if (error instanceof YtWatchContextForbiddenError) {
return true;
}
return (
error instanceof Error &&
/failed to load watch page:\s*403/i.test(error.message)
);
}
export async function getAudioFromYtAudio(
{ videoId, signal }: GetAudioFromAPIOptions,
deps: YtAudioStrategyDeps = {},
@ -84,7 +72,7 @@ export async function getAudioFromYtAudio(
const streamResult = await downloader.downloadAudioToChunkStream(
{
videoId,
videoQuality: DEFAULT_YT_AUDIO_QUALITY,
audioQuality: DEFAULT_YT_AUDIO_QUALITY,
signal,
},
{ chunkSize },
@ -101,13 +89,6 @@ export async function getAudioFromYtAudio(
getMediaBuffers: streamResult.getMediaBuffers,
};
} catch (error) {
if (isWatchContextForbiddenError(error)) {
// 403 on watch-page key fetch is not recoverable in current context.
// Skip buffered fallback so upper layer can immediately trigger
// fail-audio-js instead of spending time on redundant retries.
throw error;
}
console.warn(
"[VOT] ytAudio streaming mode failed, falling back to buffered mode",
error,
@ -116,7 +97,7 @@ export async function getAudioFromYtAudio(
const result = await downloader.downloadAudioToUint8Array({
videoId,
videoQuality: DEFAULT_YT_AUDIO_QUALITY,
audioQuality: DEFAULT_YT_AUDIO_QUALITY,
signal,
});

View file

@ -477,8 +477,29 @@ function handlePromiseResponse(data: AnyObject): boolean {
pending.delete(id);
clearTimeout(item.timeoutId);
<<<<<<< Updated upstream
if (data.ok) item.resolve(data.result);
else item.reject(new Error(toErrorMessage(data.error ?? "Bridge error")));
=======
if (data.ok) {
debug.log("[VOT EXT][prelude] GM API response", {
requestId: id,
action: item.action,
ok: true,
resultType: Array.isArray(data.result) ? "array" : typeof data.result,
});
item.resolve(data.result);
} else {
const errorMessage = toErrorMessage(data.error ?? "Bridge error");
debug.warn("[VOT EXT][prelude] GM API response", {
requestId: id,
action: item.action,
ok: false,
error: errorMessage,
});
item.reject(new Error(errorMessage));
}
>>>>>>> Stashed changes
return true;
}

View file

@ -1,7 +1,11 @@
{
"name": "[VOT] - Voice Over Translation",
"description": "A small extension that adds a Yandex Browser video translation to other browsers",
<<<<<<< Updated upstream
"version": "1.11.4",
=======
"version": "1.11.5",
>>>>>>> Stashed changes
"author": "Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng",
"namespace": "vot",
"icon": "https://translate.yandex.ru/icons/favicon.ico",
@ -74,9 +78,7 @@
"*://*.archive.org/*",
"*://*.patreon.com/*",
"*://*.reddit.com/*",
"*://*.kodik.info/*",
"*://*.kodik.biz/*",
"*://*.kodik.cc/*",
"*://*.kodikplayer.com/*",
"*://*.kick.com/*",
"*://developer.apple.com/*",
"*://dev.epicgames.com/*",

View file

@ -35,7 +35,6 @@ import { UIManager } from "./ui/manager";
import { isSameOverlayMount } from "./ui/mount";
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, isSupportGMXhr } from "./utils/gm";
import { isIframe } from "./utils/iframeConnector";
@ -83,6 +82,10 @@ import {
enableSubtitlesForCurrentLangPair as enableSubtitlesForCurrentLangPairImpl,
ensureSubtitlesForCurrentLangPair as ensureSubtitlesForCurrentLangPairImpl,
loadSubtitles as loadSubtitlesImpl,
<<<<<<< Updated upstream
=======
refreshAutoSubtitlesForCurrentLangPair as refreshAutoSubtitlesForCurrentLangPairImpl,
>>>>>>> Stashed changes
resolveSubtitlesLanguage,
toggleSubtitlesForCurrentLangPair as toggleSubtitlesForCurrentLangPairImpl,
updateSubtitlesLangSelect as updateSubtitlesLangSelectImpl,
@ -275,7 +278,12 @@ export class VideoHandler {
private getFullscreenOverlayRoot(): HTMLElement | null {
const doc = document as DocumentWithFullscreen;
const fullscreenEl = doc.fullscreenElement ?? doc.webkitFullscreenElement;
return resolveScopedFullscreenElement(fullscreenEl, [this.container]);
return fullscreenEl instanceof HTMLElement &&
(fullscreenEl === this.container ||
fullscreenEl.contains(this.container) ||
this.container.contains(fullscreenEl))
? fullscreenEl
: null;
}
private getOverlayMountPoints(container: HTMLElement = this.container): {
@ -322,13 +330,12 @@ export class VideoHandler {
private getOverlayMount(
container: HTMLElement = this.container,
): OverlayMount {
const { root, portalContainer, subtitlesMountContainer, fullscreenRoot } =
const { root, portalContainer, subtitlesMountContainer } =
this.getOverlayMountPoints(container);
return {
root,
portalContainer,
subtitlesMountContainer,
tooltipLayoutRoot: fullscreenRoot ?? this.tooltipLayoutRoot,
};
}
@ -520,9 +527,8 @@ export class VideoHandler {
const { subtitlesMountContainer } = this.getOverlayMountPoints();
this.subtitlesWidget = new SubtitlesWidget(
this.video,
subtitlesMountContainer,
this.uiManager.votOverlayView?.root ?? subtitlesMountContainer,
this.interactionChecker,
this.tooltipLayoutRoot,
);
this.applySavedSubtitlesWidgetSettings(this.subtitlesWidget);
}
@ -590,24 +596,6 @@ export class VideoHandler {
return this.getOverlayMountPoints().portalContainer;
}
/**
* Determines the root element used for tooltip layout calculations.
* @returns {HTMLElement | undefined}
*/
get tooltipLayoutRoot() {
switch (this.site.host) {
case "kickstarter": {
return document.getElementById("react-project-header") ?? undefined;
}
case "custom": {
return undefined;
}
default: {
return this.container;
}
}
}
/**
* Returns the container element for event listeners.
* @returns {HTMLElement} The event container.
@ -897,6 +885,17 @@ export class VideoHandler {
}
/**
<<<<<<< Updated upstream
=======
* Re-evaluates the active subtitles track for the current language pair,
* but only when auto-subtitles are enabled.
*/
refreshAutoSubtitlesForCurrentLangPair() {
return this.callModuleAsync(refreshAutoSubtitlesForCurrentLangPairImpl);
}
/**
>>>>>>> Stashed changes
* Toggles subtitles for the current video.
*
* - If subtitles are enabled, this disables them.

View file

@ -176,3 +176,15 @@ html.vot-keyboard-nav vot-block *:focus-visible {
z-index: 2147483647;
}
}
// The overlay mount itself stays click-through so host-page controls remain
// usable around the injected UI. Explicitly re-enable hit-testing for the
// actual interactive overlay surfaces inside the shadow root.
.vot-overlay-root {
pointer-events: none;
& > .vot-segmented-button,
& > .vot-menu {
pointer-events: auto;
}
}

View file

@ -1,21 +1,11 @@
import { resolveScopedFullscreenElement } from "../utils/dom";
type DocumentWithFullscreenElement = Document & {
webkitFullscreenElement?: Element | null;
};
type FullscreenLayerControllerOptions = {
video?: HTMLVideoElement;
container: HTMLElement;
};
export class FullscreenLayerController {
private readonly video?: HTMLVideoElement;
private container: HTMLElement;
private fullscreenLayer: HTMLElement | null = null;
constructor({ video, container }: FullscreenLayerControllerOptions) {
this.video = video;
constructor({ container }: FullscreenLayerControllerOptions) {
this.container = container;
}
@ -24,93 +14,21 @@ export class FullscreenLayerController {
}
getWidgetParentElement(): HTMLElement {
return this.shouldUseFullscreenViewportLayer()
? this.ensureFullscreenLayer()
: this.container;
return this.container;
}
getLayoutRootElement(): HTMLElement {
return this.fullscreenLayer?.isConnected
? this.fullscreenLayer
: this.container;
return this.container;
}
syncWidgetContainer(widgetContainer: HTMLElement | null): void {
const widgetParent = this.getWidgetParentElement();
if (
widgetParent === this.container &&
getComputedStyle(this.container).position === "static"
) {
if (getComputedStyle(this.container).position === "static") {
this.container.style.position = "relative";
}
if (widgetContainer && widgetContainer.parentElement !== widgetParent) {
widgetParent.appendChild(widgetContainer);
}
if (
widgetParent === this.container &&
this.fullscreenLayer?.parentElement
) {
this.fullscreenLayer.remove();
this.fullscreenLayer = null;
if (widgetContainer && widgetContainer.parentElement !== this.container) {
this.container.appendChild(widgetContainer);
}
}
release(): void {
if (!this.fullscreenLayer) return;
this.fullscreenLayer.remove();
this.fullscreenLayer = null;
}
private getActiveFullscreenElement(): HTMLElement | null {
const doc = document as DocumentWithFullscreenElement;
const fullscreenEl = doc.fullscreenElement ?? doc.webkitFullscreenElement;
return resolveScopedFullscreenElement(
fullscreenEl,
[this.container, this.video],
{
allowDocumentViewport: true,
},
);
}
private isCurrentVideoInFullscreenSession(): boolean {
const fullscreenEl = this.getActiveFullscreenElement();
if (!fullscreenEl) return false;
if (
fullscreenEl === this.container ||
fullscreenEl.contains(this.container) ||
this.container.contains(fullscreenEl)
) {
return true;
}
return Boolean(
this.video &&
(fullscreenEl === this.video ||
fullscreenEl.contains(this.video) ||
this.video.contains(fullscreenEl)),
);
}
private shouldUseFullscreenViewportLayer(): boolean {
return this.isCurrentVideoInFullscreenSession();
}
private ensureFullscreenLayer(): HTMLElement {
if (!this.fullscreenLayer) {
const layer = document.createElement("vot-block");
layer.classList.add("vot-subtitles-layer");
this.fullscreenLayer = layer;
}
if (this.fullscreenLayer.parentElement !== this.container) {
this.container.appendChild(this.fullscreenLayer);
}
return this.fullscreenLayer;
}
release(): void {}
}

View file

@ -11,6 +11,12 @@ import type {
} from "../types/subtitles";
import UI from "../ui";
import Tooltip from "../ui/components/tooltip";
import {
createShadowMount,
destroyShadowMount,
reparentShadowMount,
type ShadowMount,
} from "../ui/shadowMount";
import type { IntervalIdleChecker } from "../utils/intervalIdleChecker";
import { votStorage } from "../utils/storage";
import { translate } from "../utils/translateApis";
@ -102,7 +108,7 @@ export class SubtitlesWidget {
private readonly video?: HTMLVideoElement;
private container: HTMLElement;
private readonly fullscreenLayerController: FullscreenLayerController;
private tooltipLayoutRoot?: HTMLElement;
private tooltipMount?: ShadowMount;
private subtitlesContainer: HTMLElement | null = null;
private subtitlesBlock: HTMLElement | null = null;
private renderedHighlightEls: HTMLSpanElement[] = [];
@ -218,16 +224,13 @@ export class SubtitlesWidget {
video: HTMLVideoElement | undefined,
container: HTMLElement,
intervalIdleChecker: IntervalIdleChecker,
tooltipLayoutRoot: HTMLElement | undefined = undefined,
) {
this.video = video;
this.container = container;
this.fullscreenLayerController = new FullscreenLayerController({
video,
container,
});
this.intervalIdleChecker = intervalIdleChecker;
this.tooltipLayoutRoot = tooltipLayoutRoot;
this.useVideoFrameCallbacks =
!!this.video &&
typeof this.video.requestVideoFrameCallback === "function";
@ -241,26 +244,19 @@ export class SubtitlesWidget {
});
this.bindEvents();
}
public updateMount({
container,
tooltipLayoutRoot,
}: {
container: HTMLElement;
tooltipLayoutRoot?: HTMLElement;
}): void {
public updateMount({ container }: { container: HTMLElement }): void {
const containerChanged = this.container !== container;
const tooltipRootChanged = this.tooltipLayoutRoot !== tooltipLayoutRoot;
this.container = container;
this.fullscreenLayerController.updateContainer(container);
this.tooltipLayoutRoot = tooltipLayoutRoot;
this.syncWidgetMount();
if (containerChanged || tooltipRootChanged) {
if (containerChanged) {
const parentElement = this.getTokenTooltipParentElement();
this.tokenTooltip?.updateMount({
parentElement: this.getTokenTooltipParentElement(),
layoutRoot: this.tooltipLayoutRoot ?? document.documentElement,
parentElement,
layoutRoot: this.tooltipMount?.host,
});
}
@ -491,23 +487,51 @@ export class SubtitlesWidget {
}
}
private syncGuideLayerMount(): void {
const widgetParent =
this.fullscreenLayerController.getWidgetParentElement();
const guidesLayer = this.ensureGuidesLayer();
if (guidesLayer.parentElement !== widgetParent) {
widgetParent.appendChild(guidesLayer);
if (guidesLayer.parentElement !== this.container) {
this.container.appendChild(guidesLayer);
}
}
private syncWidgetMount(): void {
this.fullscreenLayerController.syncWidgetContainer(this.subtitlesContainer);
this.fullscreenLayerController.syncWidgetContainer(null);
if (
this.subtitlesContainer &&
this.subtitlesContainer.parentElement !== this.container
) {
this.container.appendChild(this.subtitlesContainer);
}
if (this.tooltipMount) {
reparentShadowMount(this.tooltipMount, this.container);
}
this.syncGuideLayerMount();
}
private ensureTooltipMount(): ShadowMount {
if (!this.tooltipMount) {
this.tooltipMount = createShadowMount({
parent: this.container,
rootClasses: ["vot-portal-local"],
hostStyles: {
position: "absolute",
inset: "0",
display: "block",
"pointer-events": "none",
},
rootStyles: {
position: "relative",
display: "block",
width: "100%",
height: "100%",
"pointer-events": "none",
},
});
} else {
reparentShadowMount(this.tooltipMount, this.container);
}
return this.tooltipMount;
}
private getTokenTooltipParentElement(): HTMLElement {
const widgetParent =
this.fullscreenLayerController.getWidgetParentElement();
return widgetParent === this.container
? document.documentElement
: widgetParent;
return this.ensureTooltipMount().root;
}
private createSubtitlesContainer(): HTMLElement {
if (this.subtitlesContainer) {
@ -519,7 +543,8 @@ export class SubtitlesWidget {
this.syncWidgetMount();
container.addEventListener("pointerdown", this.onPointerDownBound, {
signal: this.abortController.signal,
passive: true,
passive: false,
capture: true,
});
this.syncVisualStyleVars();
this.insetCacheReady = false;
@ -677,16 +702,17 @@ export class SubtitlesWidget {
this.dragDocListenersAttached = true;
document.addEventListener("pointermove", this.onPointerMoveBound, {
passive: false,
capture: true,
});
document.addEventListener("pointerup", this.onPointerUpBound);
document.addEventListener("pointercancel", this.onPointerUpBound);
document.addEventListener("pointerup", this.onPointerUpBound, true);
document.addEventListener("pointercancel", this.onPointerUpBound, true);
}
private detachDragDocumentListeners(): void {
if (!this.dragDocListenersAttached) return;
this.dragDocListenersAttached = false;
document.removeEventListener("pointermove", this.onPointerMoveBound);
document.removeEventListener("pointerup", this.onPointerUpBound);
document.removeEventListener("pointercancel", this.onPointerUpBound);
document.removeEventListener("pointermove", this.onPointerMoveBound, true);
document.removeEventListener("pointerup", this.onPointerUpBound, true);
document.removeEventListener("pointercancel", this.onPointerUpBound, true);
}
private onResize(): void {
this.syncWidgetMount();
@ -822,6 +848,7 @@ export class SubtitlesWidget {
return;
if (!event.isPrimary) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
event.stopPropagation();
const layout = this.getLayoutSize();
const { rect: containerRect, w, h, scaleX, scaleY } = layout;
if (!w || !h) return;
@ -849,11 +876,6 @@ export class SubtitlesWidget {
this.dragging.offset.y = anchorY - pointerY;
this.hideSnapGuides();
this.attachDragDocumentListeners();
const captureEl: Element | null =
this.subtitlesBlock ?? (target instanceof Element ? target : null);
try {
(captureEl as HTMLElement | null)?.setPointerCapture(event.pointerId);
} catch {}
}
private onPointerUp(event: PointerEvent): void {
if (this.dragging.pointerId === null) return;
@ -886,6 +908,9 @@ export class SubtitlesWidget {
this.dragging.moved = true;
this.suppressTokenClicksUntil = performance.now() + 450;
this.releaseTooltip();
try {
this.subtitlesContainer?.setPointerCapture(event.pointerId);
} catch {}
} else {
this.dragging.moved = true;
}
@ -1488,6 +1513,8 @@ export class SubtitlesWidget {
}
this.tokenTooltip?.release();
this.tokenTooltip = undefined;
destroyShadowMount(this.tooltipMount);
this.tooltipMount = undefined;
return this;
}
private clearPendingSchedulerState(): void {
@ -1588,13 +1615,14 @@ export class SubtitlesWidget {
this.subtitlesBlock?.offsetWidth ?? 0,
Math.min(globalThis.innerWidth * 0.6, 320),
);
const tooltipMount = this.ensureTooltipMount();
return new Tooltip({
target,
anchor: this.subtitlesBlock ?? target,
layoutRoot: this.tooltipLayoutRoot,
content,
parentElement: this.getTokenTooltipParentElement(),
parentElement: tooltipMount.root,
layoutRoot: tooltipMount.host,
offset: { x: 4, y: 12 },
maxWidth: tooltipMaxWidth,
borderRadius: 12,
@ -2042,6 +2070,8 @@ export class SubtitlesWidget {
this.subtitlesContainer.remove();
this.subtitlesContainer = null;
}
destroyShadowMount(this.tooltipMount);
this.tooltipMount = undefined;
this.fullscreenLayerController.release();
if (this.safeAreaProbeEl) {
this.safeAreaProbeEl.remove();

View file

@ -12,7 +12,6 @@ export type OverlayMount = {
root: HTMLElement;
portalContainer: HTMLElement;
subtitlesMountContainer: HTMLElement;
tooltipLayoutRoot?: HTMLElement;
};
export type UIManagerProps = {

View file

@ -1,28 +1,8 @@
import type { TemplateResult } from "lit-html";
import { render } from "lit-html";
import { localizationProvider } from "./localization/localizationProvider";
import mainScss from "./styles/main.scss?inline";
import type { LitHtml } from "./types/components/shared";
function injectMainStyles(css: string): HTMLStyleElement | HTMLElement {
const gmAddStyle = (
globalThis as typeof globalThis & {
GM_addStyle?: (styleText: string) => HTMLStyleElement | HTMLElement;
}
).GM_addStyle;
if (typeof gmAddStyle === "function") {
return gmAddStyle(css);
}
const style = document.createElement("style");
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
return style;
}
injectMainStyles(mainScss);
declare global {
interface Window {
__votKeyboardNavInitialized?: boolean;

View file

@ -1,6 +1,7 @@
import { EventImpl } from "../../core/eventImpl";
import type { DialogProps } from "../../types/components/dialog";
import UI from "../../ui";
import { getDeepActiveElement } from "../../utils/dom";
import { CLOSE_ICON } from "../icons";
import {
addComponentEventListener,
@ -156,7 +157,7 @@ export default class Dialog {
open() {
// Temp dialogs are created visible; still run focus/keyboard setup.
this.previouslyFocused ??= document.activeElement;
this.previouslyFocused ??= getDeepActiveElement(document);
this.hidden = false;
this.attachKeydownTrap();
@ -340,7 +341,9 @@ export default class Dialog {
const first = focusables[0];
const last = focusables.at(-1) ?? first;
const active = document.activeElement;
const active = getDeepActiveElement(
this.container.getRootNode() as Document | ShadowRoot,
);
if (e.shiftKey) {
if (active === first || active === this.box) {

View file

@ -30,6 +30,8 @@ export default class Tooltip {
pageHeight!: number;
globalOffsetX!: number;
globalOffsetY!: number;
renderOffsetX!: number;
renderOffsetY!: number;
maxWidth?: number;
backgroundColor?: string;
borderRadius?: number;
@ -224,7 +226,7 @@ export default class Tooltip {
// Layout bounds may be computed against a player container while the
// tooltip itself is mounted into a portal attached elsewhere in the DOM.
// We therefore calculate in layout-root coordinates and later convert
// back to viewport coordinates for the rendered tooltip.
// back into the tooltip parent's coordinate space before rendering.
if (this.layoutRoot === document.documentElement) {
this.globalOffsetX = 0;
this.globalOffsetY = 0;
@ -234,6 +236,11 @@ export default class Tooltip {
this.globalOffsetY = top;
}
const { left: parentLeft, top: parentTop } =
this.parentElement.getBoundingClientRect();
this.renderOffsetX = parentLeft;
this.renderOffsetY = parentTop;
this.pageWidth =
this.layoutRoot.clientWidth || document.documentElement.clientWidth;
this.pageHeight =
@ -423,8 +430,8 @@ export default class Tooltip {
this.position = resolvedPosition;
return {
top: coords.top + this.globalOffsetY,
left: coords.left + this.globalOffsetX,
top: coords.top + this.globalOffsetY - this.renderOffsetY,
left: coords.left + this.globalOffsetX - this.renderOffsetX,
};
}

View file

@ -9,9 +9,7 @@ import { serializeProcessedSubtitles } from "../subtitles/standards";
import type { Status } from "../types/components/votButton";
import type { StorageData } from "../types/storage";
import type { OverlayMount, UIManagerProps } from "../types/uiManager";
import ui from "../ui";
import debug from "../utils/debug";
import { resolveScopedFullscreenElement } from "../utils/dom";
import { downloadTranslation } from "../utils/download";
import { GM_fetch } from "../utils/gm";
import type { IntervalIdleChecker } from "../utils/intervalIdleChecker";
@ -23,6 +21,12 @@ import {
downloadBlob,
} from "../utils/utils";
import { applyOverlayMountUpdate } from "./mount";
import {
createShadowMount,
destroyShadowMount,
reparentShadowMount,
type ShadowMount,
} from "./shadowMount";
import { handleTranslationButtonCommand } from "./translationCommands";
import { OverlayView } from "./views/overlay";
import { SettingsView } from "./views/settings";
@ -36,6 +40,7 @@ export class UIManager {
data: Partial<StorageData>;
votGlobalPortal?: HTMLElement;
private globalPortalMount?: ShadowMount;
/**
* Contains all elements over video player e.g. button, menu and etc
*/
@ -65,8 +70,8 @@ export class UIManager {
return this.mount.portalContainer;
}
get tooltipLayoutRoot(): HTMLElement | undefined {
return this.mount.tooltipLayoutRoot;
getSubtitlesMountContainer(): HTMLElement {
return this.votOverlayView?.root ?? this.mount.subtitlesMountContainer;
}
isInitialized(): this is {
@ -84,8 +89,11 @@ export class UIManager {
this.initialized = true;
this.votGlobalPortal = ui.createPortal();
this.getGlobalPortalHost(this.mount).appendChild(this.votGlobalPortal);
this.globalPortalMount = createShadowMount({
parent: this.getGlobalPortalHost(this.mount),
rootClasses: ["vot-portal"],
});
this.votGlobalPortal = this.globalPortalMount.root;
this.votOverlayView = new OverlayView({
mount: this.mount,
@ -105,22 +113,25 @@ export class UIManager {
});
this.votSettingsView.initUI();
this.videoHandler?.subtitlesWidget?.updateMount({
container: this.getSubtitlesMountContainer(),
});
return this;
}
updateMount(mount: OverlayMount) {
const globalPortalHost = this.getGlobalPortalHost(mount);
if (this.votGlobalPortal?.parentElement !== globalPortalHost) {
globalPortalHost.appendChild(this.votGlobalPortal);
}
reparentShadowMount(
this.globalPortalMount,
this.getGlobalPortalHost(mount),
);
this.mount = applyOverlayMountUpdate(this.mount, mount, (nextMount) => {
this.votOverlayView?.updateMount(nextMount);
});
this.videoHandler?.subtitlesWidget?.updateMount({
container: mount.subtitlesMountContainer,
tooltipLayoutRoot: mount.tooltipLayoutRoot,
container: this.getSubtitlesMountContainer(),
});
return this;
@ -131,11 +142,11 @@ export class UIManager {
webkitFullscreenElement?: Element | null;
};
const fullscreenEl = doc.fullscreenElement ?? doc.webkitFullscreenElement;
const isCurrentVideoFullscreen = Boolean(
resolveScopedFullscreenElement(fullscreenEl, [mount.root], {
allowDocumentViewport: true,
}),
);
const isCurrentVideoFullscreen =
fullscreenEl instanceof HTMLElement &&
(fullscreenEl === mount.root ||
fullscreenEl.contains(mount.root) ||
mount.root.contains(fullscreenEl));
return isCurrentVideoFullscreen ? mount.root : document.documentElement;
}
@ -167,11 +178,13 @@ export class UIManager {
}
try {
const isPiPActive =
this.videoHandler.video === document.pictureInPictureElement;
await (isPiPActive
? document.exitPictureInPicture()
: this.videoHandler.video.requestPictureInPicture());
// this.videoHandler.video.disablePictureInPicture = false;
const inPiP = document.pictureInPictureElement != null;
if (inPiP) {
await document.exitPictureInPicture();
} else {
await this.videoHandler.video.requestPictureInPicture();
}
} catch (err) {
debug.warn("[VOT] Failed to toggle Picture-in-Picture", err);
}
@ -675,7 +688,9 @@ export class UIManager {
// Each view is now idempotent and releases events before DOM.
this.votOverlayView.release();
this.votSettingsView.release();
this.votGlobalPortal.remove();
destroyShadowMount(this.globalPortalMount);
this.globalPortalMount = undefined;
this.votGlobalPortal = undefined;
this.initialized = false;
return this;

View file

@ -13,8 +13,7 @@ export function isSameOverlayMount(
return (
previous.root === next.root &&
previous.portalContainer === next.portalContainer &&
previous.subtitlesMountContainer === next.subtitlesMountContainer &&
previous.tooltipLayoutRoot === next.tooltipLayoutRoot
previous.subtitlesMountContainer === next.subtitlesMountContainer
);
}
@ -34,17 +33,3 @@ export function applyOverlayMountUpdate(
onChanged(next);
return next;
}
/**
* Tooltip geometry must be refreshed when either the button/root is reparented
* or the layout root itself changes.
*/
export function didTooltipMountContextChange(
previous: OverlayMount,
next: OverlayMount,
): boolean {
return (
previous.root !== next.root ||
previous.tooltipLayoutRoot !== next.tooltipLayoutRoot
);
}

View file

@ -1,4 +1,5 @@
import debug from "../utils/debug";
import { containsCrossShadow, getDeepActiveElement } from "../utils/dom";
import type { IntervalIdleChecker } from "../utils/intervalIdleChecker";
import type { OverlayView } from "./views/overlay";
@ -140,7 +141,7 @@ export class OverlayVisibilityController {
if (
relatedNode &&
(currentNode?.contains(relatedNode) ||
((currentNode && containsCrossShadow(currentNode, relatedNode)) ||
this.deps.isInteractiveNode(relatedNode))
) {
return;
@ -164,7 +165,7 @@ export class OverlayVisibilityController {
typeof document !== "undefined" &&
typeof document.hasFocus === "function";
if (canCheckFocus && document.hasFocus()) {
active = document.activeElement;
active = getDeepActiveElement(document);
}
if (active && this.deps.isInteractiveNode(active)) {
debug.log("[OverlayVisibility] skip hide (focus inside overlay)");

142
src/ui/shadowMount.ts Normal file
View file

@ -0,0 +1,142 @@
import mainScss from "../styles/main.scss?inline";
type InlineStyleMap = Partial<Record<string, string>>;
export type ShadowMount = {
host: HTMLElement;
root: HTMLElement;
shadowRoot: ShadowRoot;
};
type CreateShadowMountOptions = {
parent: HTMLElement;
hostTag?: string;
rootTag?: string;
hostClasses?: string[];
rootClasses?: string[];
hostStyles?: InlineStyleMap;
rootStyles?: InlineStyleMap;
delegatesFocus?: boolean;
};
const shadowScopedCssText = scopeCssForShadowRoots(mainScss);
let sharedShadowStyleSheet: CSSStyleSheet | null | undefined;
function scopeCssForShadowRoots(cssText: string): string {
return cssText
.replace(/:root\b/g, ":host")
.replace(/html\.vot-keyboard-nav/g, ":host-context(.vot-keyboard-nav)")
.replace(/:fullscreen(?=\s|,)/g, ":host-context(:fullscreen)")
.replace(
/:-webkit-full-screen(?=\s|,)/g,
":host-context(:-webkit-full-screen)",
);
}
function applyInlineStyles(
element: HTMLElement,
styles: InlineStyleMap | undefined,
): void {
if (!styles) {
return;
}
for (const [name, value] of Object.entries(styles)) {
if (typeof value !== "string") {
continue;
}
element.style.setProperty(name, value);
}
}
function getSharedShadowStyleSheet(): CSSStyleSheet | null {
if (sharedShadowStyleSheet !== undefined) {
return sharedShadowStyleSheet;
}
const canUseConstructableSheets =
typeof CSSStyleSheet !== "undefined" &&
typeof CSSStyleSheet.prototype.replaceSync === "function";
if (!canUseConstructableSheets) {
sharedShadowStyleSheet = null;
return sharedShadowStyleSheet;
}
const sheet = new CSSStyleSheet();
sheet.replaceSync(shadowScopedCssText);
sharedShadowStyleSheet = sheet;
return sharedShadowStyleSheet;
}
function adoptScopedStyles(shadowRoot: ShadowRoot): void {
const sharedSheet = getSharedShadowStyleSheet();
if (sharedSheet) {
if (!shadowRoot.adoptedStyleSheets.includes(sharedSheet)) {
shadowRoot.adoptedStyleSheets = [
...shadowRoot.adoptedStyleSheets,
sharedSheet,
];
}
return;
}
const style = document.createElement("style");
style.textContent = shadowScopedCssText;
shadowRoot.append(style);
}
export function createShadowMount({
parent,
hostTag = "vot-shadow-host",
rootTag = "vot-block",
hostClasses = [],
rootClasses = [],
hostStyles,
rootStyles,
delegatesFocus = false,
}: CreateShadowMountOptions): ShadowMount {
const host = document.createElement(hostTag);
if (hostClasses.length > 0) {
host.classList.add(...hostClasses);
}
applyInlineStyles(host, hostStyles);
const shadowRoot = host.attachShadow({
mode: "open",
delegatesFocus,
});
adoptScopedStyles(shadowRoot);
const root = document.createElement(rootTag);
if (rootClasses.length > 0) {
root.classList.add(...rootClasses);
}
applyInlineStyles(root, rootStyles);
shadowRoot.append(root);
parent.append(host);
return {
host,
root,
shadowRoot,
};
}
export function reparentShadowMount(
mount: ShadowMount | undefined,
parent: HTMLElement,
): void {
if (!mount) {
return;
}
if (mount.host.parentElement !== parent) {
parent.append(mount.host);
}
}
export function destroyShadowMount(mount: ShadowMount | undefined): void {
mount?.host.remove();
}

View file

@ -25,7 +25,12 @@ import Tooltip from "../components/tooltip";
import VOTButton from "../components/votButton";
import VOTMenu from "../components/votMenu";
import { SETTINGS_ICON, SUBTITLES_ICON } from "./../icons";
import { didTooltipMountContextChange } from "../mount";
import {
createShadowMount,
destroyShadowMount,
reparentShadowMount,
type ShadowMount,
} from "../shadowMount";
export class OverlayView {
private static readonly BIG_CONTAINER_WIDTH_PX = 550;
@ -52,6 +57,7 @@ export class OverlayView {
private readonly data: Partial<StorageData>;
private readonly videoHandler?: VideoHandler;
private readonly intervalIdleChecker: IntervalIdleChecker;
private overlayMount?: ShadowMount;
private readonly events: {
[K in keyof OverlayViewEventMap]: EventImpl<OverlayViewEventMap[K]>;
@ -113,26 +119,20 @@ export class OverlayView {
}
get root(): HTMLElement {
return this.mount.root;
return this.overlayMount?.root ?? this.mount.root;
}
get portalContainer(): HTMLElement {
return this.mount.portalContainer;
}
get tooltipLayoutRoot(): HTMLElement | undefined {
return this.mount.tooltipLayoutRoot;
}
/**
* Update mount points (root/tooltipLayoutRoot) when the player container changes.
* Update mount points when the player container changes.
* Moves already-mounted UI nodes and rebinds root-bound listeners (dragging).
*/
updateMount(nextMount: OverlayMount): this {
const prevRoot = this.mount.root;
const nextRoot = nextMount.root;
const prevTooltipRoot = this.mount.tooltipLayoutRoot;
const nextTooltipRoot = nextMount.tooltipLayoutRoot;
this.mount = nextMount;
@ -140,34 +140,23 @@ export class OverlayView {
return this;
}
// Move mounted nodes to new containers.
if (prevRoot !== nextRoot) {
if (this.votButton) {
nextRoot.appendChild(this.votButton.container);
}
if (this.votMenu) {
nextRoot.appendChild(this.votMenu.container);
if (this.overlayMount) {
reparentShadowMount(this.overlayMount, nextRoot);
} else {
if (this.votButton) {
nextRoot.appendChild(this.votButton.container);
}
if (this.votMenu) {
nextRoot.appendChild(this.votMenu.container);
}
}
}
// Tooltip geometry depends on both the layout root and the overlay root:
// some fullscreen transitions only reparent the button/root while keeping
// the same tooltipLayoutRoot, so force a refresh for either change.
if (
this.votButtonTooltip &&
didTooltipMountContextChange(
{
root: prevRoot,
portalContainer: this.mount.portalContainer,
subtitlesMountContainer: this.mount.subtitlesMountContainer,
tooltipLayoutRoot: prevTooltipRoot,
},
nextMount,
)
) {
// If tooltipLayoutRoot becomes undefined, fall back to documentElement.
if (this.votButtonTooltip && prevRoot !== nextRoot) {
this.votButtonTooltip.updateMount({
layoutRoot: nextTooltipRoot ?? document.documentElement,
parentElement: this.root,
layoutRoot: this.overlayMount?.host,
});
}
@ -256,6 +245,23 @@ export class OverlayView {
}
this.initialized = true;
this.overlayMount = createShadowMount({
parent: this.mount.root,
rootClasses: ["vot-overlay-root"],
hostStyles: {
position: "absolute",
inset: "0",
display: "block",
"pointer-events": "none",
},
rootStyles: {
position: "relative",
display: "block",
width: "100%",
height: "100%",
"pointer-events": "none",
},
});
// #region Shared logic
const { position, direction } = this.calcButtonLayout(buttonPosition);
@ -282,8 +288,8 @@ export class OverlayView {
autoLayout: false,
hidden: direction === "row",
bordered: false,
parentElement: this.globalPortal,
layoutRoot: this.tooltipLayoutRoot,
parentElement: this.root,
layoutRoot: this.overlayMount.host,
});
// #endregion VOT Button
@ -598,9 +604,11 @@ export class OverlayView {
// Keep menu open while interacting with dialogs spawned from it
// (language picker, etc.).
const isInsideDialog =
target instanceof HTMLElement &&
!!target.closest(".vot-dialog-container");
const isInsideDialog = path.some(
(node) =>
node instanceof HTMLElement &&
node.classList.contains("vot-dialog-container"),
);
if (
isInsideMenu ||
@ -997,6 +1005,8 @@ export class OverlayView {
this.votButton?.remove();
this.votMenu?.remove();
this.votButtonTooltip?.release();
destroyShadowMount(this.overlayMount);
this.overlayMount = undefined;
}
private doReleaseUIEvents(): void {

View file

@ -2,7 +2,7 @@ type DebugMethod = (...text: unknown[]) => void;
const noop: DebugMethod = () => {};
const log: DebugMethod = DEBUG_MODE
const log: DebugMethod = !DEBUG_MODE
? (...text: unknown[]) => {
console.log(
"%c[VOT DEBUG]",
@ -12,7 +12,7 @@ const log: DebugMethod = DEBUG_MODE
}
: noop;
const warn: DebugMethod = DEBUG_MODE
const warn: DebugMethod = !DEBUG_MODE
? (...text: unknown[]) => {
console.warn(
"%c[VOT DEBUG]",
@ -22,7 +22,7 @@ const warn: DebugMethod = DEBUG_MODE
}
: noop;
const error: DebugMethod = DEBUG_MODE
const error: DebugMethod = !DEBUG_MODE
? (...text: unknown[]) => {
console.error(
"%c[VOT DEBUG]",

View file

@ -8,6 +8,22 @@ function getComposableParent(node: Node | null): Node | null {
return node.parentNode ?? null;
}
export function getDeepActiveElement(
root: Document | ShadowRoot = document,
): Element | null {
let activeElement: Element | null = root.activeElement;
while (activeElement instanceof HTMLElement && activeElement.shadowRoot) {
const nestedActiveElement = activeElement.shadowRoot.activeElement;
if (!nestedActiveElement) {
break;
}
activeElement = nestedActiveElement;
}
return activeElement;
}
/**
* Checks whether `target` is a descendant of `container` in the composed tree
* (crossing ShadowRoot boundaries via hosts).

View file

@ -1,5 +1,3 @@
// Minimal HTTP method type for GM_xmlhttpRequest compatibility.
// (Avoid pulling external typings just for this union.)
type HttpMethod =
| "GET"
| "POST"
@ -13,6 +11,7 @@ type HttpMethod =
import { executeWithResponseCache } from "../core/cacheManager";
import type { FetchOpts } from "../types/utils/gm";
import { createTimeoutSignal } from "./abort";
import { browserInfo } from "./browserInfo";
import debug from "./debug";
import { getErrorMessage, isAbortError, makeAbortError } from "./errors";
import { getHeaders } from "./utils";
@ -20,11 +19,13 @@ import { getHeaders } from "./utils";
const YANDEX_API_HOST = "api.browser.yandex.ru";
const GOOGLEVIDEO_HOST_SUFFIX = "googlevideo.com";
const HEADER_LINE_RE = /^([\w-]+):\s*(.+)$/;
// Matches statusText reason-phrase: printable ASCII except control chars
const URL_SCHEME_RE = /^[a-zA-Z][a-zA-Z\d+.-]*:/;
type RequestUrlLike = string | URL | Request;
type GmXhrResponse = {
finalUrl?: string;
response?: Blob;
response?: Blob | null;
responseHeaders?: string;
status?: number;
statusText?: string;
@ -61,8 +62,8 @@ function hasSupportedGmXhr(): boolean {
export const isProxyOnlyExtension =
!(typeof IS_EXTENSION !== "undefined" && IS_EXTENSION) &&
!!scriptHandler &&
!hasSupportedGmXhr();
(browserInfo.browser?.name === "Safari" ||
!["Tampermonkey", "Violentmonkey"].includes(scriptHandler));
export const isSupportGM4 =
typeof GM !== "undefined" || (globalThis as any).GM !== undefined;
export const isSupportGMXhr = hasSupportedGmXhr();
@ -75,9 +76,7 @@ function getRequestHost(url: string): string | undefined {
if (!URL_SCHEME_RE.test(normalizedUrl)) {
try {
return new URL(`https://${normalizedUrl}`).hostname.toLowerCase();
} catch {
// fall through
}
} catch {}
}
return undefined;
}
@ -104,8 +103,6 @@ function shouldUseGmXhr(
);
}
// These endpoints are routinely blocked by page-world CORS. Going through
// native fetch first only adds noisy console errors and an extra failed hop.
return (
isHostOrSubdomain(host, YANDEX_API_HOST) ||
isHostOrSubdomain(host, GOOGLEVIDEO_HOST_SUFFIX)
@ -155,65 +152,87 @@ function getGmXhrErrorMessage(error: unknown): string {
error?: unknown;
statusText?: unknown;
};
if (typeof maybeError?.error === "string") {
if (
typeof maybeError?.error === "string" &&
maybeError.error.trim().length > 0
) {
return maybeError.error;
}
if (typeof maybeError?.statusText === "string") {
if (
typeof maybeError?.statusText === "string" &&
maybeError.statusText.trim().length > 0 &&
maybeError.statusText !== '""' &&
maybeError.statusText !== "''"
) {
return maybeError.statusText;
}
return getErrorMessage(error) || "Unknown error";
return getErrorMessage(error) || "Unknown GM XHR error";
}
async function gmXhrFetch(
function buildResponse(resp: GmXhrResponse, urlStr: string): Response {
const status = resp.status;
const statusText = typeof resp.statusText === "string" ? resp.statusText : "";
const body = resp.response instanceof Blob ? resp.response : null;
const responseHeaders = parseResponseHeaders(resp.responseHeaders);
const response = new Response(body, {
status,
statusText,
headers: responseHeaders,
});
Object.defineProperty(response, "url", {
value: resp.finalUrl ?? urlStr,
});
return response;
}
async function executeCallbackGmXhr(
gmXhr: GmXhrCallbackApi,
urlStr: string,
timeout: number,
fetchOptions: Omit<FetchOpts, "timeout">,
method: string,
headers: Record<string, string>,
): Promise<Response> {
<<<<<<< Updated upstream
const headers = getHeaders(fetchOptions.headers);
const callbackGmXhr = getCallbackGmXhr();
const promiseGmXhr = getPromiseGmXhr();
=======
return new Promise((resolve, reject) => {
let settled = false;
let onAbort: (() => void) | undefined;
>>>>>>> Stashed changes
if (callbackGmXhr) {
return await new Promise((resolve, reject) => {
let settled = false;
let onAbort: (() => void) | undefined;
const cleanupAbort = () => {
if (onAbort) {
fetchOptions.signal?.removeEventListener("abort", onAbort);
}
};
const failOnce = (error: Error) => {
const cleanupAbort = () => {
if (onAbort) {
fetchOptions.signal?.removeEventListener("abort", onAbort);
}
};
const failOnce = (error: Error) => {
if (settled) return;
settled = true;
cleanupAbort();
reject(error);
};
const request = gmXhr({
method: method as HttpMethod,
url: urlStr,
responseType: "blob" as any,
data: fetchOptions.body as any,
timeout,
headers,
onload: (resp: GmXhrResponse) => {
if (settled) return;
settled = true;
cleanupAbort();
reject(error);
};
const request = callbackGmXhr({
method: (fetchOptions.method || "GET") as HttpMethod,
url: urlStr,
responseType: "blob" as any,
data: fetchOptions.body as any,
timeout,
headers,
onload: (resp: GmXhrResponse) => {
if (settled) return;
settled = true;
cleanupAbort();
const responseHeaders = parseResponseHeaders(resp.responseHeaders);
const response = new Response(resp.response as Blob, {
status: resp.status,
statusText:
typeof resp.statusText === "string" ? resp.statusText : "",
headers: responseHeaders,
});
Object.defineProperty(response, "url", {
value: resp.finalUrl ?? urlStr,
});
<<<<<<< Updated upstream
resolve(response);
},
ontimeout: () => failOnce(new Error("Timeout")),
@ -227,26 +246,92 @@ async function gmXhrFetch(
request?.abort?.();
} catch {
// ignore abort races
=======
try {
const response = buildResponse(resp, urlStr);
debug.log("[GM_fetch] GM_xmlhttpRequest completed", {
url: response.url,
method,
status: response.status,
statusText: response.statusText,
});
resolve(response);
} catch (buildErr) {
// Constructing Response failed even after sanitization — surface error
debug.warn("[GM_fetch] GM_xmlhttpRequest response build failed", {
url: urlStr,
method,
error: getErrorMessage(buildErr),
rawStatus: resp.status,
rawStatusText: resp.statusText,
});
reject(
buildErr instanceof Error
? buildErr
: new Error(getErrorMessage(buildErr)),
);
>>>>>>> Stashed changes
}
},
ontimeout: () => {
debug.warn("[GM_fetch] GM_xmlhttpRequest timed out", {
url: urlStr,
method,
timeout,
});
failOnce(new Error("Timeout"));
},
onerror: (error: unknown) => {
// Safari can deliver an empty-string error — fall back to generic message
const message = getGmXhrErrorMessage(error);
debug.warn("[GM_fetch] GM_xmlhttpRequest failed", {
url: urlStr,
method,
error: message,
});
failOnce(new Error(message));
},
onabort: () => {
debug.warn("[GM_fetch] GM_xmlhttpRequest aborted", {
url: urlStr,
method,
});
failOnce(makeAbortError());
};
if (fetchOptions.signal) {
fetchOptions.signal.addEventListener("abort", onAbort, { once: true });
if (fetchOptions.signal.aborted) {
onAbort();
return;
}
}
},
});
}
if (!promiseGmXhr) {
throw new TypeError("GM_xmlhttpRequest is not available");
}
onAbort = () => {
try {
request?.abort?.();
} catch {}
failOnce(makeAbortError());
};
<<<<<<< Updated upstream
const request = promiseGmXhr({
method: (fetchOptions.method || "GET") as HttpMethod,
=======
if (fetchOptions.signal) {
fetchOptions.signal.addEventListener("abort", onAbort, { once: true });
if (fetchOptions.signal.aborted) {
onAbort();
return;
}
}
});
}
async function executePromiseGmXhr(
gmXhr: GmXhrPromiseApi,
urlStr: string,
timeout: number,
fetchOptions: Omit<FetchOpts, "timeout">,
method: string,
headers: Record<string, string>,
): Promise<Response> {
const request = gmXhr({
method: method as HttpMethod,
>>>>>>> Stashed changes
url: urlStr,
responseType: "blob" as any,
data: fetchOptions.body as any,
@ -264,9 +349,7 @@ async function gmXhrFetch(
abortHandler = () => {
try {
request.abort?.();
} catch {
// ignore abort races
}
} catch {}
reject(makeAbortError());
};
@ -279,17 +362,8 @@ async function gmXhrFetch(
});
const resp = (await Promise.race([request, abortPromise])) as GmXhrResponse;
const responseHeaders = parseResponseHeaders(resp.responseHeaders);
const response = new Response(resp.response as Blob, {
status: resp.status,
statusText: typeof resp.statusText === "string" ? resp.statusText : "",
headers: responseHeaders,
});
Object.defineProperty(response, "url", {
value: resp.finalUrl ?? urlStr,
});
const response = buildResponse(resp, urlStr);
return response;
} finally {
@ -299,6 +373,68 @@ async function gmXhrFetch(
}
}
async function gmXhrFetch(
urlStr: string,
timeout: number,
fetchOptions: Omit<FetchOpts, "timeout">,
): Promise<Response> {
const headers = getHeaders(fetchOptions.headers);
const method = (fetchOptions.method || "GET").toUpperCase();
debug.log("[GM_fetch] GM_xmlhttpRequest start", {
url: urlStr,
method,
timeout,
headerCount: Object.keys(headers).length,
});
const callbackGmXhr = getCallbackGmXhr();
if (callbackGmXhr) {
debug.log("[GM_fetch] attempting callback-style GM_xmlhttpRequest");
try {
return await executeCallbackGmXhr(
callbackGmXhr,
urlStr,
timeout,
fetchOptions,
method,
headers,
);
} catch (error) {
if (isAbortError(error)) throw error;
debug.warn("[GM_fetch] callback-style GM_xmlhttpRequest failed", {
url: urlStr,
method,
error: getGmXhrErrorMessage(error),
});
}
}
const promiseGmXhr = getPromiseGmXhr();
if (promiseGmXhr) {
debug.log("[GM_fetch] attempting promise-style GM.xmlHttpRequest");
try {
return await executePromiseGmXhr(
promiseGmXhr,
urlStr,
timeout,
fetchOptions,
method,
headers,
);
} catch (error) {
if (isAbortError(error)) throw error;
debug.warn("[GM_fetch] promise-style GM.xmlHttpRequest failed", {
url: urlStr,
method,
error: getGmXhrErrorMessage(error),
});
}
}
debug.warn("[GM_fetch] none of the GM approaches worked");
throw new Error("All GM approaches failed");
}
export async function GM_fetch(
url: RequestUrlLike,
opts: FetchOpts = {},
@ -320,7 +456,33 @@ export async function GM_fetch(
reason: forceGmXhr ? "forced" : "host-policy",
url: urlStr,
});
return await gmXhrFetch(urlStr, timeout, fetchOptions);
try {
return await gmXhrFetch(urlStr, timeout, fetchOptions);
} catch (err) {
if (isAbortError(err)) throw err;
if (forceGmXhr || shouldUseGmXhr(host, urlStr)) throw err;
debug.warn(
"[GM_fetch] all GM approaches failed, falling back to native fetch",
{
url: urlStr,
method,
host: host ?? "unknown",
error: getErrorMessage(err) || "Unknown error",
},
);
const { signal, cleanup } = createTimeoutSignal(
timeout,
fetchOptions.signal,
);
try {
return await fetch(url, {
...fetchOptions,
signal,
});
} finally {
cleanup();
}
}
}
const { signal, cleanup } = createTimeoutSignal(
@ -336,11 +498,20 @@ export async function GM_fetch(
if (signal.aborted || isAbortError(err)) {
throw err;
}
<<<<<<< Updated upstream
// If fetch fails, retry via GM_xmlhttpRequest.
debug.log(
"GM_fetch preventing CORS by GM_xmlhttpRequest",
getErrorMessage(err) || "Unknown error",
);
=======
debug.warn("[GM_fetch] fetch failed, retrying via GM_xmlhttpRequest", {
url: urlStr,
method,
host: host ?? "unknown",
error: getErrorMessage(err) || "Unknown error",
});
>>>>>>> Stashed changes
return await gmXhrFetch(urlStr, timeout, fetchOptions);
} finally {
cleanup();

View file

@ -179,9 +179,10 @@ export async function downloadBlob(
if (options.preferShare) {
const shareResult = await shareBlob(blob, filename);
return shareResult === "shared";
if (shareResult === "shared") {
return true;
}
}
return triggerBlobDownload(blob, filename);
}

View file

@ -11,7 +11,12 @@ import {
import { resetAndHideLifecycle } from "../../core/lifecycleShared";
import type { VideoHandler } from "../../index";
import debug from "../../utils/debug";
<<<<<<< Updated upstream
=======
import { containsCrossShadow, getDeepActiveElement } from "../../utils/dom";
>>>>>>> Stashed changes
import { GM_fetch } from "../../utils/gm";
import { isIframe } from "../../utils/iframeConnector";
import { getPlatformEventConfig } from "../../utils/platformEvents";
import { clampPercentInt } from "../../utils/volume";
import { handlePlaybackResumedTranslationRefresh } from "./translation";
@ -253,6 +258,14 @@ function bindYouTubeVolumeSync(ctx: ExtraEventsContext): void {
videoPercent = toPercentInt(fallbackVolume * 100);
}
self.syncVideoVolumeSlider();
<<<<<<< Updated upstream
=======
const activeOverlayView = self.uiManager.votOverlayView;
if (!activeOverlayView?.isInitialized()) return;
const videoPercent = toPercentInt(
activeOverlayView.videoVolumeSlider.value,
);
>>>>>>> Stashed changes
syncAudioTranslationVolumeFromVideo(self, videoPercent);
});
const ytpVolumePanel = document.querySelector(".ytp-volume-panel");
@ -378,7 +391,7 @@ function bindGlobalDismissAndHotkeys(ctx: ExtraEventsContext): void {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.repeat) return;
userPressedKeys.add(keyboardEvent.code);
const activeElement = document.activeElement as HTMLElement | null;
const activeElement = getDeepActiveElement(document) as HTMLElement | null;
const activeTag = activeElement?.tagName?.toLowerCase?.() ?? "";
const isInputElement =
["input", "textarea"].includes(activeTag) ||
@ -421,6 +434,7 @@ function bindGlobalDismissAndHotkeys(ctx: ExtraEventsContext): void {
add(globalThis, "blur", clearUserPressedKeys);
const eventContainer = self.getEventContainer();
if (eventContainer) {
<<<<<<< Updated upstream
addMany(eventContainer, ["pointerenter", "pointerdown"], (event) =>
self.overlayVisibility.handleHostInteraction(event),
);
@ -433,6 +447,38 @@ function bindGlobalDismissAndHotkeys(ctx: ExtraEventsContext): void {
add(eventContainer, "pointerleave", (event) =>
self.overlayVisibility.scheduleHide(event),
);
=======
const useWindowEvents =
isIframe() && typeof globalThis.window !== "undefined";
const interactionTarget = useWindowEvents
? globalThis.window
: eventContainer;
if (useWindowEvents) {
addMany(
interactionTarget,
["pointermove", "pointerdown"],
(event) => self.overlayVisibility.handleHostInteraction(event),
{ passive: true },
);
add(interactionTarget, "blur", () =>
self.overlayVisibility.scheduleHide(),
);
} else {
addMany(interactionTarget, ["pointerenter", "pointerdown"], (event) =>
self.overlayVisibility.handleHostInteraction(event),
);
add(
interactionTarget,
"pointermove",
(event) => self.overlayVisibility.handleHostInteraction(event),
{ passive: true },
);
add(interactionTarget, "pointerleave", (event) =>
self.overlayVisibility.scheduleHide(event),
);
}
>>>>>>> Stashed changes
}
self.rebindOverlayVisibilityTargets();
if (platformConfig.allowTouchMoveHandler) {
@ -462,7 +508,7 @@ export function bindPlaybackRefreshOnResume(ctx: ExtraEventsContext): void {
add(self.video, "playing", () => {
if (!wasPausedSinceLastPlay) return;
wasPausedSinceLastPlay = false;
void handlePlaybackResumedTranslationRefresh.call(self).catch((error) => {
handlePlaybackResumedTranslationRefresh.call(self).catch((error) => {
debug.log(
"[VOT] Failed to refresh translation after playback resumed",
error,
@ -581,8 +627,9 @@ export function isOverlayInteractiveNode(
const buttonContainer = overlayView?.votButton?.container;
const menuContainer = overlayView?.votMenu?.container;
return (
(buttonContainer instanceof Node && buttonContainer.contains(node)) ||
(menuContainer instanceof Node && menuContainer.contains(node))
(buttonContainer instanceof Node &&
containsCrossShadow(buttonContainer, node)) ||
(menuContainer instanceof Node && containsCrossShadow(menuContainer, node))
);
}
export function getAutoHideDelay(this: VideoHandler): number {

View file

@ -26,9 +26,19 @@ function getPreferredSubtitlesLanguage(
handler: VideoHandler,
): string | undefined {
const videoData = handler.videoData;
<<<<<<< Updated upstream
return handler.getPreferredSubtitlesLanguage(
videoData?.detectedLanguage,
videoData?.responseLanguage,
=======
return (
handler.getPreferredSubtitlesLanguage(
videoData?.detectedLanguage,
videoData?.responseLanguage,
) ??
videoData?.responseLanguage ??
handler.translateToLang
>>>>>>> Stashed changes
);
}