mirror of
https://github.com/ilyhalight/voice-over-translation.git
synced 2026-07-09 16:00:11 +00:00
Release v1.11.1: builds, docs, fixes
Bump release to v1.11.1, add packaged extension builds and update metadata. Includes new Chrome/Firefox extension artifacts and a Firefox updates manifest for auto-updates, and updates the distributed userscript version. Changelog: add detailed 1.11.1 notes (volume link/sync improvements, adaptive volume tweaks, stability fixes, AudioContext recovery and proxy fallbacks, subtitle cleaning and mobile positioning, YouTube Shorts lifecycle fixes, mobile download fallbacks, various site fixes). Docs: update README (EN/RU) — remove OrangeMonkey references, add Opera guidance link and adjust badges/instructions; add .gitignore entries for dist-ext directories. Source: add extension.zip and new volumeLink util; multiple code updates across audioDownloader, core (lifecycle, translation, event impl), extension background/prelude, UI/views, subtitles, styles and video handling to implement fixes and improvements described in the changelog.
This commit is contained in:
parent
3a59dfcb34
commit
e110bcd54b
50 changed files with 2890 additions and 4081 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,6 +3,8 @@ arch*.txt
|
|||
dist/test-ui.user.js
|
||||
dist/*.txt
|
||||
dist/main.user.js
|
||||
dist-ext/chrome/
|
||||
dist-ext/firefox/
|
||||
|
||||
src/localization/locales/generate_phrase.py
|
||||
src/localization/locales/regenerate_locales.py
|
||||
|
|
|
|||
13
README-EN.md
13
README-EN.md
|
|
@ -9,7 +9,6 @@
|
|||
[adguard-userscripts]: https://kb.adguard.com/en/general/userscripts#supported-apps
|
||||
[firemonkey-link]: https://erosman.github.io/firemonkey/
|
||||
[greasemonkey-link]: https://github.com/greasemonkey/greasemonkey
|
||||
[orangemonkey-link]: https://chromewebstore.google.com/detail/OrangeMonkey/ekmeppjgajofkpiofbebgcbohbmfldaf
|
||||
[user-js-and-css-link]: https://tenrabbits.github.io/user-js-css-docs/
|
||||
|
||||
<!-- FAQs / Wiki -->
|
||||
|
|
@ -17,6 +16,7 @@
|
|||
[firemonkey-how-to]: https://github.com/ilyhalight/voice-over-translation/wiki/%5BEN%5D-FAQ#%D0%BA%D0%B0%D0%BA-%D0%B8%D1%81%D0%BF%D0%BE%D0%BB%D1%8C%D0%B7%D0%BE%D0%B2%D0%B0%D1%82%D1%8C-%D1%80%D0%B0%D1%81%D1%88%D0%B8%D1%80%D0%B5%D0%BD%D0%B8%D0%B5-%D1%81-firemonkey
|
||||
[user-js-and-css-how-to]: https://github.com/ilyhalight/voice-over-translation/wiki/%5BEN%5D-FAQ#%D0%BA%D0%B0%D0%BA-%D0%B8%D1%81%D0%BF%D0%BE%D0%BB%D1%8C%D0%B7%D0%BE%D0%B2%D0%B0%D1%82%D1%8C-%D1%80%D0%B0%D1%81%D1%88%D0%B8%D1%80%D0%B5%D0%BD%D0%B8%D0%B5-%D1%81-user-js-and-css
|
||||
[devmode-enable]: https://www.tampermonkey.net/faq.php#Q209
|
||||
[opera-search-results-access]: https://help.opera.com/en/extensions/content-scripts/
|
||||
[vot-faq]: https://github.com/ilyhalight/voice-over-translation/wiki/%5BEN%5D-FAQ
|
||||
[vot-supported-sites]: https://github.com/ilyhalight/voice-over-translation/wiki/%5BEN%5D-Supported-sites
|
||||
[vot-wiki]: https://github.com/ilyhalight/voice-over-translation/wiki
|
||||
|
|
@ -56,8 +56,8 @@
|
|||
|
||||
<!-- Content -->
|
||||
|
||||
[![ru][badge-ru]][vot-readme-en]
|
||||
[![en][badge-en]][vot-readme-ru]
|
||||
[![ru][badge-ru]][vot-readme-ru]
|
||||
[![en][badge-en]][vot-readme-en]
|
||||
|
||||
> [!CAUTION]
|
||||
> Before creating Issues, we strongly recommend that you read the [FAQ][vot-faq] section and with existing [Issues][vot-issues].
|
||||
|
|
@ -71,12 +71,12 @@ Voice-over translation is now available beyond [Yandex Browser][yabrowser-link].
|
|||
> [!WARNING]
|
||||
> **Important for Tampermonkey 5.2+ (MV3) users:**
|
||||
> In **Chromium**-based browsers (Chrome, Edge, Brave, Vivaldi, etc.) you must:
|
||||
> 1. Open the extensions page (`chrome://extensions`) and enable **"Developer mode"**.
|
||||
> 1. Open the extensions page (`chrome://extensions`) and enable **"Developer mode"** (details in [Tampermonkey documentation][devmode-enable]).
|
||||
> 2. If you use **Chromium 138+**, open extension details and enable **"Allow User Scripts"**.
|
||||
>
|
||||
> **For Opera users:**
|
||||
> 1. Use **[Violentmonkey][violentmonkey-opera]** instead of Tampermonkey.
|
||||
> 2. In the extension settings, enable **"Allow access to search page results"**; otherwise the script will not work.
|
||||
> 2. In the extension settings, enable **"Allow access to search page results"** (Opera guide: [where to find this setting][opera-search-results-access]); otherwise the script will not work.
|
||||
|
||||
1. Install a userscript manager: **[Tampermonkey][tampermonkey-link]** (or [Violentmonkey][violentmonkey-opera] for Opera)
|
||||
2. **[Install the script][vot-dist]**
|
||||
|
|
@ -264,7 +264,7 @@ The extension has been tested in the following browsers:
|
|||
| ✅ | MS Edge | v106.0.1370.34 | Windows, Linux | Tampermonkey (MV2) |
|
||||
| ✅ | Cent Browser | v4.3.9.248 | Windows | Tampermonkey (MV2) |
|
||||
| ✅ | Cent Browser Beta | v5.0.1002.182 | Windows | Tampermonkey (MV2) |
|
||||
| ✅ | Google Chrome | v106 | Windows, MacOS, Linux | Tampermonkey (MV2), Tampermonkey (MV3), Violentmonkey, OrangeMonkey, User Javascript and CSS |
|
||||
| ✅ | Google Chrome | v106 | Windows, MacOS, Linux | Tampermonkey (MV2), Tampermonkey (MV3), Violentmonkey, User Javascript and CSS |
|
||||
| ✅ | Opera GX (LVL4) | core91 | Windows | Violentmonkey |
|
||||
| ✅ | Opera GX (LVL5) | core109 | Windows | Violentmonkey |
|
||||
| ✅ | Opera | v92.0.4561.43 | Windows | Violentmonkey |
|
||||
|
|
@ -291,7 +291,6 @@ Tested in the following userscript manager extensions:
|
|||
| ❔ | Any | [AdGuard Userscripts][adguard-userscripts] |
|
||||
| [Install guide][firemonkey-how-to] | Firefox | [Firemonkey][firemonkey-link] |
|
||||
| ✅ | Firefox | [Greasemonkey][greasemonkey-link] |
|
||||
| ⚠️² | Any | [OrangeMonkey][orangemonkey-link] |
|
||||
| [Install guide][user-js-and-css-how-to]¹ | Any | [User Javascript and CSS][user-js-and-css-link] |
|
||||
|
||||
¹ - Works in proxy mode, but the important "Use audio download" feature is unavailable due to missing `unsafeWindow` API, which can cause issues with newly requested translations.
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -9,7 +9,6 @@
|
|||
[adguard-userscripts]: https://kb.adguard.com/en/general/userscripts#supported-apps
|
||||
[firemonkey-link]: https://erosman.github.io/firemonkey/
|
||||
[greasemonkey-link]: https://github.com/greasemonkey/greasemonkey
|
||||
[orangemonkey-link]: https://chromewebstore.google.com/detail/OrangeMonkey/ekmeppjgajofkpiofbebgcbohbmfldaf
|
||||
[user-js-and-css-link]: https://tenrabbits.github.io/user-js-css-docs/ru/
|
||||
|
||||
<!-- FAQs / Wiki -->
|
||||
|
|
@ -17,6 +16,7 @@
|
|||
[firemonkey-how-to]: https://github.com/ilyhalight/voice-over-translation/wiki/%5BRU%5D-FAQ#%D0%BA%D0%B0%D0%BA-%D0%B8%D1%81%D0%BF%D0%BE%D0%BB%D1%8C%D0%B7%D0%BE%D0%B2%D0%B0%D1%82%D1%8C-%D1%80%D0%B0%D1%81%D1%88%D0%B8%D1%80%D0%B5%D0%BD%D0%B8%D0%B5-%D1%81-firemonkey
|
||||
[user-js-and-css-how-to]: https://github.com/ilyhalight/voice-over-translation/wiki/%5BRU%5D-FAQ#%D0%BA%D0%B0%D0%BA-%D0%B8%D1%81%D0%BF%D0%BE%D0%BB%D1%8C%D0%B7%D0%BE%D0%B2%D0%B0%D1%82%D1%8C-%D1%80%D0%B0%D1%81%D1%88%D0%B8%D1%80%D0%B5%D0%BD%D0%B8%D0%B5-%D1%81-user-js-and-css
|
||||
[devmode-enable]: https://www.tampermonkey.net/faq.php#Q209
|
||||
[opera-search-results-access]: https://help.opera.com/en/extensions/content-scripts/
|
||||
[vot-faq]: https://github.com/ilyhalight/voice-over-translation/wiki/%5BRU%5D-FAQ
|
||||
[vot-supported-sites]: https://github.com/ilyhalight/voice-over-translation/wiki/%5BRU%5D-Supported-sites
|
||||
[vot-wiki]: https://github.com/ilyhalight/voice-over-translation/wiki
|
||||
|
|
@ -56,8 +56,8 @@
|
|||
|
||||
<!-- Content -->
|
||||
|
||||
[![en][badge-en]][vot-readme-ru]
|
||||
[![ru][badge-ru]][vot-readme-en]
|
||||
[![en][badge-en]][vot-readme-en]
|
||||
[![ru][badge-ru]][vot-readme-ru]
|
||||
|
||||
> [!CAUTION]
|
||||
> Перед созданием Issues настоятельно рекомендуем ознакомиться с разделом [FAQ][vot-faq] и уже существующими [Issues][vot-issues].
|
||||
|
|
@ -71,12 +71,12 @@
|
|||
> [!WARNING]
|
||||
> **Важно для пользователей Tampermonkey 5.2+ (MV3):**
|
||||
> В браузерах на движке **Chromium** (Chrome, Edge, Brave, Vivaldi и др.) необходимо:
|
||||
> 1. Открыть страницу расширений (`chrome://extensions`) и включить **«Режим разработчика»**.
|
||||
> 1. Открыть страницу расширений (`chrome://extensions`) и включить **«Режим разработчика»** (подробности в [документации Tampermonkey][devmode-enable]).
|
||||
> 2. Если движок **Chromium версии 138+**, в «Сведениях» расширения включить **«Разрешить пользовательские скрипты»**.
|
||||
>
|
||||
> **Пользователям Opera:**
|
||||
> 1. Используйте **[Violentmonkey][violentmonkey-opera]** вместо Tampermonkey.
|
||||
> 2. В настройках расширения обязательно включите **«Разрешить доступ к результатам на странице поиска»**, иначе скрипт не будет работать.
|
||||
> 2. В настройках расширения обязательно включите **«Разрешить доступ к результатам на странице поиска»** (гайд от Opera: [как найти эту настройку][opera-search-results-access]), иначе скрипт не будет работать.
|
||||
|
||||
1. Установите загрузчик юзерскриптов: **[Tampermonkey][tampermonkey-link]** (или [Violentmonkey][violentmonkey-opera] для Opera)
|
||||
2. **[«Установить скрипт»][vot-dist]**
|
||||
|
|
@ -264,7 +264,7 @@ bun install
|
|||
| ✅ | MS Edge | v106.0.1370.34 | Windows, Linux | Tampermonkey (MV2) |
|
||||
| ✅ | Cent Browser | v4.3.9.248 | Windows | Tampermonkey (MV2) |
|
||||
| ✅ | Cent Browser Beta | v5.0.1002.182 | Windows | Tampermonkey (MV2) |
|
||||
| ✅ | Google Chrome | v106 | Windows, MacOS, Linux | Tampermonkey (MV2), Tampermonkey (MV3), Violentmonkey, OrangeMonkey, User Javascript and CSS |
|
||||
| ✅ | Google Chrome | v106 | Windows, MacOS, Linux | Tampermonkey (MV2), Tampermonkey (MV3), Violentmonkey, User Javascript and CSS |
|
||||
| ✅ | Opera GX (LVL4) | core91 | Windows | Violentmonkey |
|
||||
| ✅ | Opera GX (LVL5) | core109 | Windows | Violentmonkey |
|
||||
| ✅ | Opera | v92.0.4561.43 | Windows | Violentmonkey |
|
||||
|
|
@ -291,7 +291,6 @@ bun install
|
|||
| ❔ | Любой | [AdGuard Userscripts][adguard-userscripts] |
|
||||
| [Гайд по установке][firemonkey-how-to] | Firefox | [Firemonkey][firemonkey-link] |
|
||||
| ✅ | Firefox | [Greasemonkey][greasemonkey-link] |
|
||||
| ⚠️² | Любой | [OrangeMonkey][orangemonkey-link] |
|
||||
| [Гайд по установке][user-js-and-css-how-to]¹ | Любой | [User Javascript and CSS][user-js-and-css-link] |
|
||||
|
||||
¹ - Работает в режиме проксирования, важная функция «Использовать загрузку аудио» недоступна из-за отсутствия `unsafeWindow` API, что может приводить к проблемам с переводом новых видео.
|
||||
|
|
|
|||
18
changelog.md
18
changelog.md
|
|
@ -1,4 +1,20 @@
|
|||
# 1.11.0
|
||||
# 1.11.1
|
||||
|
||||
- Улучшена работа настройки "Связать громкость перевода и видео": переработана дельта-синхронизация слайдеров, устранены дрейф громкости и зацикливание при внешних изменениях громкости плеера
|
||||
- Доработана "Адаптивная громкость": улучшен анализ речи в переведенной дорожке и устранен конфликт с режимом синхронизации громкости
|
||||
- Повышена стабильность запуска/остановки перевода: устранены гонки при повторном старте, смене видео и переключении прокси
|
||||
- Улучшено восстановление воспроизведения перевода: добавлено корректное возобновление `AudioContext` и fallback на прямой URL при ошибке проксированной аудиоссылки
|
||||
- Исправлена обработка субтитров: удаляются служебные HTML/таймкоды и объединяются дублирующиеся короткие реплики (особенно заметно на VK)
|
||||
- Улучшено позиционирование субтитров на мобильных устройствах с учетом `safe-area`
|
||||
- Исправлена обработка смены видео на YouTube Shorts и дедупликация lifecycle-событий, что снижает вероятность повторной инициализации
|
||||
- Улучшена загрузка переведенной дорожки на мобильных устройствах (share/download fallback) и резервные сценарии скачивания
|
||||
- Добавлен Firefox updates manifest для автообновлений
|
||||
- Исправлена нестабильная работа в iOS/macOS
|
||||
- Исправлена ошибка на Udemy: кнопка перевода быстро исчезала и показывалась ошибка `No video ID found` (#1510)
|
||||
- Исправлена работа на VK.com (#1512)
|
||||
- Исправлена инициализация на YouTube (#1521)
|
||||
|
||||
# 1.11.0
|
||||
|
||||
- Добавлена поддержка ZDF (#1443), Niconico (#1188), Weibo, Xhamster (#1260), Spankbang (#1049), Rule34video (#984), Picarto (#960), Olympics Replay (#714) и Bilibili TV (#1151)
|
||||
- Добавлена поддержка `disk.360.yandex.ru` для Yandex Disk
|
||||
|
|
|
|||
Binary file not shown.
BIN
dist-ext/vot-extension-chrome-1.11.1.crx
Normal file
BIN
dist-ext/vot-extension-chrome-1.11.1.crx
Normal file
Binary file not shown.
Binary file not shown.
BIN
dist-ext/vot-extension-firefox-1.11.1.xpi
Normal file
BIN
dist-ext/vot-extension-firefox-1.11.1.xpi
Normal file
Binary file not shown.
12
dist-ext/vot-extension-firefox-updates.json
Normal file
12
dist-ext/vot-extension-firefox-updates.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"addons": {
|
||||
"vot-extension@firefox": {
|
||||
"updates": [
|
||||
{
|
||||
"version": "1.11.1",
|
||||
"update_link": "https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext/vot-extension-firefox-1.11.1.xpi"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
69
dist/vot-min.user.js
vendored
69
dist/vot-min.user.js
vendored
File diff suppressed because one or more lines are too long
3596
dist/vot.user.js
vendored
3596
dist/vot.user.js
vendored
File diff suppressed because one or more lines are too long
344
package-lock.json
generated
344
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -38,7 +38,7 @@ async function handleCommonAudioDownloadRequest({
|
|||
throw new Error("Audio downloader. Empty audio");
|
||||
}
|
||||
|
||||
audioDownloader.onDownloadedAudio.dispatch(translationId, {
|
||||
await audioDownloader.onDownloadedAudio.dispatchAsync(translationId, {
|
||||
videoId,
|
||||
fileId,
|
||||
audioData: value,
|
||||
|
|
@ -52,14 +52,17 @@ async function handleCommonAudioDownloadRequest({
|
|||
throw new Error("Audio downloader. Empty audio");
|
||||
}
|
||||
|
||||
audioDownloader.onDownloadedPartialAudio.dispatch(translationId, {
|
||||
videoId,
|
||||
fileId,
|
||||
audioData: audioChunk,
|
||||
version: 1,
|
||||
index,
|
||||
amount: mediaPartsLength,
|
||||
});
|
||||
await audioDownloader.onDownloadedPartialAudio.dispatchAsync(
|
||||
translationId,
|
||||
{
|
||||
videoId,
|
||||
fileId,
|
||||
audioData: audioChunk,
|
||||
version: 1,
|
||||
index,
|
||||
amount: mediaPartsLength,
|
||||
},
|
||||
);
|
||||
|
||||
index++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,16 +230,18 @@ export async function getAudioFromWebApiWithReplacedFetch({
|
|||
const serializedInit = serializeRequestInit(requestInfo);
|
||||
const fallbackInit = deserializeRequestInit(serializedInit);
|
||||
const finalRequestInit = normalizeRequestInit(requestInit, fallbackInit);
|
||||
const chunkParts = returnByParts
|
||||
? getChunkRangesPartsFromAdaptiveFormat(adaptiveFormat)
|
||||
: null;
|
||||
|
||||
return {
|
||||
fileId: makeFileId(STRATEGY_TYPE, itag, adaptiveFormat.contentLength),
|
||||
mediaPartsLength: returnByParts
|
||||
? getChunkRangesPartsFromAdaptiveFormat(adaptiveFormat).length
|
||||
: 1,
|
||||
mediaPartsLength: chunkParts?.length ?? 1,
|
||||
async *getMediaBuffers(): AsyncGenerator<Uint8Array> {
|
||||
if (returnByParts) {
|
||||
const chunkParts =
|
||||
getChunkRangesPartsFromAdaptiveFormat(adaptiveFormat);
|
||||
if (!chunkParts?.length) {
|
||||
throw new Error("Audio downloader. WEB API. Empty chunk parts");
|
||||
}
|
||||
for (const part of chunkParts) {
|
||||
const { media, url, isAcceptableLast } =
|
||||
await fetchMediaWithMetaByChunkRanges(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type { VideoObserver } from "../utils/VideoObserver";
|
|||
type VideoHandlerLike = {
|
||||
init(): Promise<void>;
|
||||
setCanPlay(): Promise<void>;
|
||||
getVideoData(): Promise<unknown>;
|
||||
videoData?: unknown;
|
||||
release(): Promise<void> | void;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,32 @@ export class EventImpl<Args extends unknown[] = unknown[]> {
|
|||
}
|
||||
}
|
||||
|
||||
async dispatchAsync(...args: Args): Promise<void> {
|
||||
const pending: Promise<void>[] = [];
|
||||
|
||||
for (const handler of this.listeners) {
|
||||
try {
|
||||
const result = handler(...args);
|
||||
if (result && typeof (result as Promise<void>).then === "function") {
|
||||
pending.push(Promise.resolve(result));
|
||||
}
|
||||
} catch (exception) {
|
||||
console.error("[VOT]", exception);
|
||||
}
|
||||
}
|
||||
|
||||
if (!pending.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(pending);
|
||||
for (const item of settled) {
|
||||
if (item.status === "rejected") {
|
||||
console.error("[VOT]", item.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export type LifecycleTranslationResetHost = {
|
||||
videoData?: unknown;
|
||||
stopTranslation(): void;
|
||||
stopTranslation(): void | Promise<void>;
|
||||
resetSubtitlesWidget(): void;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -282,13 +282,18 @@ export class VOTTranslationHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = setTimeout(async () => {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
void fn().then(resolve, reject);
|
||||
try {
|
||||
const result = await fn();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}, delayMs);
|
||||
|
||||
// Keep old behavior: allow caller to clear retries via the host.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ interface VideoLifecycleHost {
|
|||
site: ServiceConf<VideoService>;
|
||||
container: HTMLElement;
|
||||
firstPlay: boolean;
|
||||
stopTranslation(): void;
|
||||
stopTranslation(): void | Promise<void>;
|
||||
resetSubtitlesWidget(): void;
|
||||
uiManager: LifecycleUIManager;
|
||||
getVideoData(): Promise<VideoData | undefined>;
|
||||
|
|
@ -54,12 +54,9 @@ export class VideoLifecycleController {
|
|||
private readonly host: VideoLifecycleHost;
|
||||
private lifecycleGeneration = 0;
|
||||
private lastSetCanPlaySourceKey = "";
|
||||
private activeSetCanPlaySourceKey = "";
|
||||
private setCanPlayRequested = false;
|
||||
private setCanPlayLoopPromise?: Promise<void>;
|
||||
private inFlightVideoDataRequest?: {
|
||||
sourceKey: string;
|
||||
promise: Promise<VideoData | undefined>;
|
||||
};
|
||||
|
||||
constructor(host: VideoLifecycleHost) {
|
||||
this.host = host;
|
||||
|
|
@ -109,12 +106,20 @@ export class VideoLifecycleController {
|
|||
teardown() {
|
||||
this.setCanPlayRequested = false;
|
||||
this.invalidateActiveSession("teardown");
|
||||
this.inFlightVideoDataRequest = undefined;
|
||||
}
|
||||
|
||||
private getCurrentSourceKey(): string {
|
||||
const src = this.host.video.currentSrc || this.host.video.src || "";
|
||||
const hasSrcObject = this.host.video.srcObject ? "1" : "0";
|
||||
if (this.host.site.host === "youtube") {
|
||||
const path = globalThis.location.pathname;
|
||||
if (path.startsWith("/shorts/")) {
|
||||
// Shorts frequently rotate blob src values for the same logical video.
|
||||
// Use pathname to keep lifecycle keys stable and avoid duplicate runs.
|
||||
return `${globalThis.location.origin}${path}||${hasSrcObject}`;
|
||||
}
|
||||
}
|
||||
|
||||
const src = this.host.video.currentSrc || this.host.video.src || "";
|
||||
return `${globalThis.location.href}||${src}||${hasSrcObject}`;
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +146,19 @@ export class VideoLifecycleController {
|
|||
async setCanPlay() {
|
||||
this.setCanPlayRequested = true;
|
||||
if (this.setCanPlayLoopPromise !== undefined) {
|
||||
this.invalidateActiveSession("setCanPlay superseded by a newer trigger");
|
||||
const incomingSourceKey = this.getCurrentSourceKey();
|
||||
if (
|
||||
this.activeSetCanPlaySourceKey &&
|
||||
incomingSourceKey !== this.activeSetCanPlaySourceKey
|
||||
) {
|
||||
this.invalidateActiveSession(
|
||||
"setCanPlay source changed while previous trigger is running",
|
||||
);
|
||||
} else {
|
||||
debug.log("[VideoLifecycle] setCanPlay deduplicated for same source", {
|
||||
sourceKey: incomingSourceKey,
|
||||
});
|
||||
}
|
||||
return await this.setCanPlayLoopPromise;
|
||||
}
|
||||
|
||||
|
|
@ -168,42 +185,65 @@ export class VideoLifecycleController {
|
|||
this.host.videoData?.videoId &&
|
||||
sourceKey === this.lastSetCanPlaySourceKey
|
||||
) {
|
||||
debug.log("[VideoLifecycle] setCanPlay deduplicated for same source", {
|
||||
sourceKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.host.videoData = await this.host.getVideoData();
|
||||
} catch (err) {
|
||||
debug.log(
|
||||
`[VideoLifecycle] getVideoData failed for source ${sourceKey}`,
|
||||
err,
|
||||
);
|
||||
this.host.videoData = undefined;
|
||||
hideLifecycleOverlay(this.host.uiManager.votOverlayView, {
|
||||
hideMenu: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeSetCanPlaySourceKey = sourceKey;
|
||||
const currentId = this.startSession(`setCanPlay (source: ${sourceKey})`);
|
||||
debug.log(`[VideoLifecycle][session:${currentId}] setCanPlay started`, {
|
||||
sourceKey,
|
||||
});
|
||||
this.lastSetCanPlaySourceKey = sourceKey;
|
||||
|
||||
await this.handleSrcChanged(currentId, sourceKey);
|
||||
try {
|
||||
await this.handleSrcChanged(currentId, sourceKey);
|
||||
|
||||
if (this.isStale(currentId)) {
|
||||
debug.log(
|
||||
`[VideoLifecycle][session:${currentId}] setCanPlay aborted after src change (active: ${this.lifecycleGeneration})`,
|
||||
);
|
||||
return;
|
||||
if (this.isStale(currentId)) {
|
||||
debug.log(
|
||||
`[VideoLifecycle][session:${currentId}] setCanPlay aborted after src change (active: ${this.lifecycleGeneration})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const autoSubtitlesPromise = this.runAutoSubtitlesIfEnabled(currentId);
|
||||
|
||||
await this.host.translationOrchestrator.runAutoTranslationIfEligible();
|
||||
if (this.isStale(currentId)) {
|
||||
debug.log(
|
||||
`[VideoLifecycle][session:${currentId}] auto-translation result ignored (stale session)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await autoSubtitlesPromise;
|
||||
if (this.isStale(currentId)) {
|
||||
debug.log(
|
||||
`[VideoLifecycle][session:${currentId}] auto-subtitles result ignored (stale session)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
debug.log(`[VideoLifecycle][session:${currentId}] setCanPlay finished`);
|
||||
} finally {
|
||||
if (this.activeSetCanPlaySourceKey === sourceKey) {
|
||||
this.activeSetCanPlaySourceKey = "";
|
||||
}
|
||||
}
|
||||
|
||||
const autoSubtitlesPromise = this.runAutoSubtitlesIfEnabled(currentId);
|
||||
|
||||
await this.host.translationOrchestrator.runAutoTranslationIfEligible();
|
||||
if (this.isStale(currentId)) {
|
||||
debug.log(
|
||||
`[VideoLifecycle][session:${currentId}] auto-translation result ignored (stale session)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await autoSubtitlesPromise;
|
||||
if (this.isStale(currentId)) {
|
||||
debug.log(
|
||||
`[VideoLifecycle][session:${currentId}] auto-subtitles result ignored (stale session)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
debug.log(`[VideoLifecycle][session:${currentId}] setCanPlay finished`);
|
||||
}
|
||||
|
||||
private async runAutoSubtitlesIfEnabled(sessionId: number): Promise<void> {
|
||||
|
|
@ -221,28 +261,6 @@ export class VideoLifecycleController {
|
|||
}
|
||||
}
|
||||
|
||||
private async getVideoDataSingleFlight(
|
||||
sourceKey: string,
|
||||
): Promise<VideoData | undefined> {
|
||||
const inFlight = this.inFlightVideoDataRequest;
|
||||
if (inFlight?.sourceKey === sourceKey) {
|
||||
debug.log(
|
||||
`[VideoLifecycle] reusing in-flight getVideoData for source ${sourceKey}`,
|
||||
);
|
||||
return await inFlight.promise;
|
||||
}
|
||||
|
||||
const promise = this.host.getVideoData();
|
||||
this.inFlightVideoDataRequest = { sourceKey, promise };
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (this.inFlightVideoDataRequest?.promise === promise) {
|
||||
this.inFlightVideoDataRequest = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleSrcChanged(callId?: number, expectedSourceKey?: string) {
|
||||
const sessionId =
|
||||
typeof callId === "number"
|
||||
|
|
@ -252,9 +270,6 @@ export class VideoLifecycleController {
|
|||
typeof expectedSourceKey === "string" && expectedSourceKey.length > 0
|
||||
? expectedSourceKey
|
||||
: this.getCurrentSourceKey();
|
||||
if (typeof callId !== "number") {
|
||||
this.lastSetCanPlaySourceKey = sourceKey;
|
||||
}
|
||||
|
||||
if (this.shouldAbortHandleSrcChanged(sessionId, "before start")) {
|
||||
return;
|
||||
|
|
@ -281,11 +296,6 @@ export class VideoLifecycleController {
|
|||
this.host.container = nextContainer;
|
||||
}
|
||||
|
||||
if (this.shouldSkipYouTubeSource()) {
|
||||
hideLifecycleOverlay(overlayView, { hideMenu: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldAbortHandleSrcChanged(sessionId, "before getVideoData")) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -295,33 +305,10 @@ export class VideoLifecycleController {
|
|||
overlayView.votButton.opacity = 1;
|
||||
this.host.queueOverlayAutoHide?.();
|
||||
|
||||
let nextVideoData: VideoData | undefined;
|
||||
try {
|
||||
nextVideoData =
|
||||
typeof sourceKey === "string" && sourceKey
|
||||
? await this.getVideoDataSingleFlight(sourceKey)
|
||||
: await this.host.getVideoData();
|
||||
} catch (err) {
|
||||
debug.log(
|
||||
`[VideoLifecycle][session:${sessionId}] getVideoData failed, hiding overlay`,
|
||||
err,
|
||||
);
|
||||
if (
|
||||
this.shouldAbortHandleSrcChanged(sessionId, "after getVideoData error")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.host.videoData = undefined;
|
||||
hideLifecycleOverlay(overlayView, { hideMenu: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldAbortHandleSrcChanged(sessionId, "after getVideoData")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.host.videoData = nextVideoData;
|
||||
|
||||
if (!this.host.videoData?.videoId) {
|
||||
debug.log(
|
||||
`[VideoLifecycle][session:${sessionId}] No videoId resolved, hiding overlay`,
|
||||
|
|
@ -352,23 +339,7 @@ export class VideoLifecycleController {
|
|||
overlayView.votButton.container.hidden = false;
|
||||
overlayView.votButton.opacity = 1;
|
||||
this.host.queueOverlayAutoHide?.();
|
||||
this.lastSetCanPlaySourceKey = sourceKey;
|
||||
debug.log(`[VideoLifecycle][session:${sessionId}] src handling finished`);
|
||||
}
|
||||
|
||||
private shouldSkipYouTubeSource() {
|
||||
if (this.host.site.host !== "youtube") return false;
|
||||
|
||||
const isYouTubeDomain =
|
||||
/(^|\.)youtube(?:-nocookie|kids)?\.com$/.test(
|
||||
globalThis.location.hostname,
|
||||
) || globalThis.location.hostname === "youtube.googleapis.com";
|
||||
|
||||
const path = globalThis.location.pathname;
|
||||
const pathOkForYouTube =
|
||||
path === "/watch" ||
|
||||
path.startsWith("/embed/") ||
|
||||
path.startsWith("/shorts/");
|
||||
|
||||
return isYouTubeDomain && !pathOkForYouTube;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
import type {
|
||||
VideoData as CoreVideoData,
|
||||
VideoDataSubtitle,
|
||||
} from "@vot.js/core/types/client";
|
||||
import YoutubeHelper from "@vot.js/ext/helpers/youtube";
|
||||
import type { VideoService } from "@vot.js/ext/types/service";
|
||||
import { getVideoData } from "@vot.js/ext/utils/videoData";
|
||||
import votConfig from "@vot.js/shared/config";
|
||||
import { availableLangs } from "@vot.js/shared/consts";
|
||||
|
|
@ -39,25 +34,8 @@ const FORCED_DETECTED_LANGUAGE_BY_HOST: Record<string, RequestLang> = {
|
|||
|
||||
const YT_VOLUME_NOW_SELECTOR = ".ytp-volume-panel [aria-valuenow]";
|
||||
|
||||
type RawVideoData = CoreVideoData<VideoService>;
|
||||
|
||||
type SubtitleLike = Pick<
|
||||
VideoDataSubtitle,
|
||||
"language" | "translatedFromLanguage"
|
||||
>;
|
||||
|
||||
type ResolvedRequestLang = Exclude<RequestLang, "auto">;
|
||||
|
||||
type DetectSourceVideoData = Pick<
|
||||
RuntimeVideoData,
|
||||
| "videoId"
|
||||
| "detectedLanguage"
|
||||
| "responseLanguage"
|
||||
| "title"
|
||||
| "localizedTitle"
|
||||
| "description"
|
||||
>;
|
||||
|
||||
function pickFirstNonEmptyString(...values: unknown[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value !== "string") continue;
|
||||
|
|
@ -78,61 +56,10 @@ function normalizeToRequestLang(value: unknown): RequestLang | undefined {
|
|||
|
||||
function isResolvedLanguage(
|
||||
value: RequestLang | undefined,
|
||||
): value is Exclude<RequestLang, "auto"> {
|
||||
): value is ResolvedRequestLang {
|
||||
return Boolean(value && value !== "auto");
|
||||
}
|
||||
|
||||
function inferLanguageFromSubtitles(
|
||||
subtitles: unknown,
|
||||
): RequestLang | undefined {
|
||||
if (!Array.isArray(subtitles) || subtitles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tracks = subtitles as SubtitleLike[];
|
||||
|
||||
// Prefer explicit source-language hints from translated tracks.
|
||||
for (const track of tracks) {
|
||||
const translatedFrom = normalizeToRequestLang(
|
||||
track?.translatedFromLanguage,
|
||||
);
|
||||
if (isResolvedLanguage(translatedFrom)) {
|
||||
return translatedFrom;
|
||||
}
|
||||
}
|
||||
|
||||
// Then prefer non-translated original subtitle tracks.
|
||||
for (const track of tracks) {
|
||||
if (track?.translatedFromLanguage) continue;
|
||||
const language = normalizeToRequestLang(track?.language);
|
||||
if (isResolvedLanguage(language)) {
|
||||
return language;
|
||||
}
|
||||
}
|
||||
|
||||
// Last subtitles fallback: any concrete language track.
|
||||
for (const track of tracks) {
|
||||
const language = normalizeToRequestLang(track?.language);
|
||||
if (isResolvedLanguage(language)) {
|
||||
return language;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function pickResolvedLanguage(
|
||||
...values: Array<RequestLang | undefined>
|
||||
): Exclude<RequestLang, "auto"> | undefined {
|
||||
for (const value of values) {
|
||||
if (isResolvedLanguage(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildDetectText(
|
||||
title: unknown,
|
||||
localizedTitle: unknown,
|
||||
|
|
@ -156,10 +83,7 @@ function resolveHostDetectedLanguage(host: string): RequestLang | undefined {
|
|||
|
||||
if (host === "vk") {
|
||||
const trackLang = document.getElementsByTagName("track")?.[0]?.srclang;
|
||||
const normalizedTrackLang = normalizeToRequestLang(trackLang);
|
||||
if (isResolvedLanguage(normalizedTrackLang)) {
|
||||
return normalizedTrackLang;
|
||||
}
|
||||
return normalizeToRequestLang(trackLang);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
|
@ -180,57 +104,6 @@ function getAriaValueNowPercent(selector: string): number | null {
|
|||
return clampPercentInt(now);
|
||||
}
|
||||
|
||||
function extractYouTubeVideoId(url: URL): string | undefined {
|
||||
if (url.pathname === "/attribution_link") {
|
||||
const encoded = url.searchParams.get("u");
|
||||
if (encoded) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(encoded);
|
||||
const nestedUrl = decoded.startsWith("http")
|
||||
? new URL(decoded)
|
||||
: new URL(decoded, url.origin);
|
||||
return extractYouTubeVideoId(nestedUrl);
|
||||
} catch {
|
||||
// ignore malformed nested links
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (url.hostname === "youtu.be") {
|
||||
const id = url.pathname.replace(/^\/+/, "").split("/")[0];
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
const vParam = url.searchParams.get("v");
|
||||
if (vParam) {
|
||||
return vParam;
|
||||
}
|
||||
|
||||
return (
|
||||
/\/(?:shorts|embed|live|v|e)\/([^/?#]+)/.exec(url.pathname)?.[1] ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function selectDetectTextSource(
|
||||
current: RuntimeVideoData | undefined,
|
||||
fallback: DetectSourceVideoData,
|
||||
) {
|
||||
if (current && current.videoId === fallback.videoId) {
|
||||
return {
|
||||
title: current.title,
|
||||
localizedTitle: current.localizedTitle,
|
||||
description: current.description,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: fallback.title,
|
||||
localizedTitle: fallback.localizedTitle,
|
||||
description: fallback.description,
|
||||
};
|
||||
}
|
||||
|
||||
export class VOTVideoManager {
|
||||
videoHandler: VideoHandler;
|
||||
private readonly detectInFlightByVideoId = new Map<
|
||||
|
|
@ -254,7 +127,7 @@ export class VOTVideoManager {
|
|||
const task: Promise<ResolvedRequestLang | undefined> = (async () => {
|
||||
debug.log(`Detecting language text: ${text}`);
|
||||
const language = normalizeToRequestLang(await detect(text));
|
||||
return pickResolvedLanguage(language);
|
||||
return isResolvedLanguage(language) ? language : undefined;
|
||||
})();
|
||||
|
||||
this.detectInFlightByVideoId.set(videoId, task);
|
||||
|
|
@ -267,139 +140,54 @@ export class VOTVideoManager {
|
|||
}
|
||||
}
|
||||
|
||||
private scheduleLanguageDetection(videoData: DetectSourceVideoData): void {
|
||||
if (videoData.detectedLanguage !== "auto") {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = selectDetectTextSource(
|
||||
this.videoHandler.videoData,
|
||||
videoData,
|
||||
);
|
||||
const text = buildDetectText(
|
||||
source.title,
|
||||
source.localizedTitle,
|
||||
source.description,
|
||||
);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.detectLanguageSingleFlight(videoData.videoId, text)
|
||||
.then((detectedLanguage) => {
|
||||
if (!detectedLanguage) {
|
||||
return;
|
||||
}
|
||||
const latestVideoData = this.videoHandler.videoData;
|
||||
if (!latestVideoData || latestVideoData.videoId !== videoData.videoId) {
|
||||
return;
|
||||
}
|
||||
if (latestVideoData.detectedLanguage !== "auto") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setSelectMenuValues(
|
||||
detectedLanguage,
|
||||
latestVideoData.responseLanguage,
|
||||
);
|
||||
debug.log(
|
||||
`[VOT] Async detected language resolved: ${detectedLanguage} for video ${videoData.videoId}`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
debug.log("[VOT] Async detect failed", error);
|
||||
});
|
||||
}
|
||||
|
||||
private getYouTubeVideoDataFast(): RawVideoData {
|
||||
const playerData = YoutubeHelper.getPlayerData();
|
||||
const playerResponse = YoutubeHelper.getPlayerResponse();
|
||||
const videoId =
|
||||
pickFirstNonEmptyString(playerData?.video_id) ??
|
||||
extractYouTubeVideoId(new URL(globalThis.location.href));
|
||||
|
||||
if (!videoId) {
|
||||
throw new Error("[VOT] Failed to resolve YouTube videoId");
|
||||
}
|
||||
|
||||
const { title: localizedTitle } = playerData ?? {};
|
||||
const {
|
||||
shortDescription: description,
|
||||
isLive: isStream,
|
||||
title,
|
||||
} = playerResponse?.videoDetails ?? {};
|
||||
|
||||
const subtitles = YoutubeHelper.getSubtitles(localizationProvider.lang);
|
||||
const duration = YoutubeHelper.getPlayer()?.getDuration?.() ?? undefined;
|
||||
const detectedLanguage = normalizeToRequestLang(
|
||||
YoutubeHelper.getLanguage(),
|
||||
);
|
||||
|
||||
return {
|
||||
duration,
|
||||
url: `${this.videoHandler.site.url}${videoId}`,
|
||||
videoId,
|
||||
host: this.videoHandler.site.host,
|
||||
title,
|
||||
localizedTitle,
|
||||
description,
|
||||
detectedLanguage,
|
||||
subtitles,
|
||||
isStream: Boolean(isStream),
|
||||
};
|
||||
}
|
||||
|
||||
async getVideoData() {
|
||||
let rawVideoData: RawVideoData;
|
||||
if (this.videoHandler.site.host === "youtube") {
|
||||
rawVideoData = this.getYouTubeVideoDataFast();
|
||||
} else {
|
||||
rawVideoData = await getVideoData(this.videoHandler.site, {
|
||||
fetchFn: GM_fetch,
|
||||
video: this.videoHandler.video,
|
||||
language: localizationProvider.lang,
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
duration,
|
||||
url,
|
||||
videoId,
|
||||
host,
|
||||
title,
|
||||
translationHelp,
|
||||
translationHelp = null,
|
||||
localizedTitle,
|
||||
description,
|
||||
detectedLanguage: possibleLanguage,
|
||||
subtitles,
|
||||
isStream = false,
|
||||
} = rawVideoData;
|
||||
} = await getVideoData(this.videoHandler.site, {
|
||||
fetchFn: GM_fetch,
|
||||
video: this.videoHandler.video,
|
||||
language: localizationProvider.lang,
|
||||
});
|
||||
|
||||
const possibleRequestLanguage = normalizeToRequestLang(possibleLanguage);
|
||||
const selectedRequestLanguage = normalizeToRequestLang(
|
||||
this.videoHandler.translateFromLang,
|
||||
const normalizedPossibleLanguage = normalizeToRequestLang(possibleLanguage);
|
||||
// Do not reuse previous video's language when current metadata has no lang.
|
||||
// This keeps UI/auto behavior consistent on hosts where language is unknown.
|
||||
let detectedLanguage: RequestLang = normalizedPossibleLanguage ?? "auto";
|
||||
|
||||
if (!normalizedPossibleLanguage) {
|
||||
const text = buildDetectText(title, localizedTitle, description);
|
||||
if (text) {
|
||||
try {
|
||||
const language = await this.detectLanguageSingleFlight(videoId, text);
|
||||
if (language) {
|
||||
detectedLanguage = language;
|
||||
}
|
||||
} catch (error) {
|
||||
// Detection is best-effort; metadata loading must keep working.
|
||||
debug.log("[VOT] detectLanguageSingleFlight failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hostDetectedLanguage = resolveHostDetectedLanguage(
|
||||
this.videoHandler.site.host,
|
||||
);
|
||||
const shouldUseLanguageFallbacks =
|
||||
this.videoHandler.site.host !== "youtube" ||
|
||||
isResolvedLanguage(possibleRequestLanguage);
|
||||
|
||||
let detectedLanguage = pickResolvedLanguage(
|
||||
possibleRequestLanguage,
|
||||
shouldUseLanguageFallbacks ? selectedRequestLanguage : undefined,
|
||||
shouldUseLanguageFallbacks
|
||||
? inferLanguageFromSubtitles(subtitles)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
detectedLanguage = pickResolvedLanguage(
|
||||
resolveHostDetectedLanguage(this.videoHandler.site.host),
|
||||
detectedLanguage,
|
||||
);
|
||||
|
||||
const normalizedDetectedLanguage: RequestLang = detectedLanguage ?? "auto";
|
||||
if (hostDetectedLanguage) {
|
||||
detectedLanguage = hostDetectedLanguage;
|
||||
}
|
||||
|
||||
const videoData = {
|
||||
translationHelp: translationHelp ?? null,
|
||||
translationHelp,
|
||||
isStream,
|
||||
duration:
|
||||
duration ||
|
||||
|
|
@ -408,7 +196,7 @@ export class VOTVideoManager {
|
|||
videoId,
|
||||
url,
|
||||
host,
|
||||
detectedLanguage: normalizedDetectedLanguage,
|
||||
detectedLanguage,
|
||||
responseLanguage: this.videoHandler.translateToLang,
|
||||
subtitles,
|
||||
title,
|
||||
|
|
@ -416,18 +204,19 @@ export class VOTVideoManager {
|
|||
description,
|
||||
downloadTitle: localizedTitle ?? title ?? videoId,
|
||||
} satisfies RuntimeVideoData;
|
||||
console.log("[VOT] Detected language:", normalizedDetectedLanguage);
|
||||
|
||||
this.scheduleLanguageDetection(videoData);
|
||||
console.log("[VOT] Detected language:", detectedLanguage);
|
||||
return videoData;
|
||||
}
|
||||
|
||||
videoValidator() {
|
||||
if (!this.videoHandler.videoData || !this.videoHandler.data) {
|
||||
async videoValidator() {
|
||||
const videoData = this.videoHandler.videoData;
|
||||
const data = this.videoHandler.data;
|
||||
if (!videoData || !data) {
|
||||
throw new VOTLocalizedError("VOTNoVideoIDFound");
|
||||
}
|
||||
|
||||
console.log("[VOT] Video Data: ", this.videoHandler.videoData);
|
||||
debug.log("VideoValidator videoData: ", this.videoHandler.videoData);
|
||||
if (
|
||||
this.videoHandler.data.enabledDontTranslateLanguages &&
|
||||
this.videoHandler.data.dontTranslateLanguages?.includes(
|
||||
|
|
@ -436,16 +225,13 @@ export class VOTVideoManager {
|
|||
) {
|
||||
throw new VOTLocalizedError("VOTDisableFromYourLang");
|
||||
}
|
||||
|
||||
if (this.videoHandler.videoData.isStream) {
|
||||
// Stream translation (HLS) is currently disabled.
|
||||
// to translate streams on twitch, need to somehow go back 30(?) seconds to the player
|
||||
// Stream translation is disabled for all hosts.
|
||||
throw new VOTLocalizedError("VOTStreamNotAvailable");
|
||||
}
|
||||
|
||||
if (
|
||||
// !this.videoHandler.videoData.isStream &&
|
||||
this.videoHandler.videoData.duration > 14400
|
||||
) {
|
||||
if (this.videoHandler.videoData.duration > 14400) {
|
||||
throw new VOTLocalizedError("VOTVideoIsTooLong");
|
||||
}
|
||||
return true;
|
||||
|
|
@ -535,26 +321,28 @@ export class VOTVideoManager {
|
|||
}
|
||||
|
||||
setSelectMenuValues(from: RequestLang, to: ResponseLang) {
|
||||
if (
|
||||
!this.videoHandler.uiManager.votOverlayView?.isInitialized() ||
|
||||
!this.videoHandler.videoData
|
||||
) {
|
||||
const videoData = this.videoHandler.videoData;
|
||||
if (!videoData) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const normalizedFrom = normalizeToRequestLang(from) ?? "auto";
|
||||
console.log(`[VOT] Set translation from ${normalizedFrom} to ${to}`);
|
||||
this.videoHandler.uiManager.votOverlayView.languagePairSelect.fromSelect.selectTitle =
|
||||
videoData.detectedLanguage = normalizedFrom;
|
||||
videoData.responseLanguage = to;
|
||||
this.videoHandler.translateFromLang = normalizedFrom;
|
||||
this.videoHandler.translateToLang = to;
|
||||
|
||||
const overlayView = this.videoHandler.uiManager.votOverlayView;
|
||||
if (!overlayView?.isInitialized()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
overlayView.languagePairSelect.fromSelect.selectTitle =
|
||||
localizationProvider.getLangLabel(normalizedFrom);
|
||||
this.videoHandler.uiManager.votOverlayView.languagePairSelect.toSelect.selectTitle =
|
||||
overlayView.languagePairSelect.toSelect.selectTitle =
|
||||
localizationProvider.getLangLabel(to);
|
||||
this.videoHandler.uiManager.votOverlayView.languagePairSelect.fromSelect.setSelectedValue(
|
||||
normalizedFrom,
|
||||
);
|
||||
this.videoHandler.uiManager.votOverlayView.languagePairSelect.toSelect.setSelectedValue(
|
||||
to,
|
||||
);
|
||||
this.videoHandler.videoData.detectedLanguage = normalizedFrom;
|
||||
this.videoHandler.videoData.responseLanguage = to;
|
||||
overlayView.languagePairSelect.fromSelect.setSelectedValue(normalizedFrom);
|
||||
overlayView.languagePairSelect.toSelect.setSelectedValue(to);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ type XhrStartMessage = {
|
|||
headers?: Record<string, unknown>;
|
||||
data?: unknown;
|
||||
responseType?: string;
|
||||
wantProgressEvents?: boolean;
|
||||
timeout?: number;
|
||||
anonymous?: boolean;
|
||||
withCredentials?: boolean;
|
||||
|
|
@ -56,6 +55,7 @@ type XhrStartMessage = {
|
|||
type XhrAbortMessage = { type: "abort" };
|
||||
|
||||
type XhrPortMessage = XhrStartMessage | XhrAbortMessage;
|
||||
const MAX_INLINE_BINARY_RESPONSE_BYTES = 512 * 1024;
|
||||
|
||||
// -----------------------------
|
||||
// declarativeNetRequest helper
|
||||
|
|
@ -161,15 +161,9 @@ async function ensureDnrHeaderRuleForYandex(
|
|||
// ---------------------------------------------------------------------
|
||||
// Header injection / normalization
|
||||
//
|
||||
// The user-provided "Правильный запрос" capture for
|
||||
// `https://api.browser.yandex.ru/video-translation/translate` does NOT include
|
||||
// an `Origin` header.
|
||||
//
|
||||
// In a previous build we set Origin to the current page origin (e.g.
|
||||
// https://www.youtube.com) to avoid leaking `chrome-extension://<id>`. That
|
||||
// still diverges from the native Yandex request, so we now remove `Origin`
|
||||
// entirely.
|
||||
//
|
||||
// In MV3, fetch()/XHR cannot control forbidden headers like `Origin` or
|
||||
// `Sec-*`. We use declarativeNetRequest.modifyHeaders (session rules) to:
|
||||
// - remove Origin completely,
|
||||
|
|
@ -292,32 +286,13 @@ type XhrResponse = {
|
|||
// without having to ship non-JSON types through extension messaging.
|
||||
responseType?: string;
|
||||
contentType?: string;
|
||||
// Optional fallback for small binary payloads when chunked progress events
|
||||
// are unavailable or dropped.
|
||||
// Optional fallback for binary payloads in terminal messages.
|
||||
responseB64?: string;
|
||||
response?: unknown;
|
||||
responseText?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type XhrProgress = {
|
||||
finalUrl: string;
|
||||
readyState: number;
|
||||
status: number;
|
||||
statusText: string;
|
||||
responseHeaders: string;
|
||||
loaded: number;
|
||||
total: number;
|
||||
lengthComputable: boolean;
|
||||
// JSON-serializable binary chunk.
|
||||
// The bridge (content script) decodes this to ArrayBuffer before forwarding
|
||||
// it to the MAIN-world GM_xmlhttpRequest callbacks.
|
||||
chunkB64?: string;
|
||||
};
|
||||
|
||||
// Base64 helpers live in `base64.ts`.
|
||||
const MAX_INLINE_BINARY_RESPONSE_BYTES = 512 * 1024;
|
||||
|
||||
function getHeader(
|
||||
headers: Record<string, string>,
|
||||
name: string,
|
||||
|
|
@ -444,14 +419,6 @@ ext?.runtime?.onConnect?.addListener?.((port: unknown) => {
|
|||
}
|
||||
const timeout = Number(details.timeout || 0);
|
||||
const responseType = String(details.responseType || "text").toLowerCase();
|
||||
const wantProgressEvents = !!details.wantProgressEvents;
|
||||
let hostname = "";
|
||||
try {
|
||||
hostname = new URL(url).hostname;
|
||||
} catch {
|
||||
hostname = "";
|
||||
}
|
||||
const isYandexApiRequest = isYandexApiHostname(hostname);
|
||||
debug.log("[VOT EXT][background][xhr] start", {
|
||||
xhrSessionId,
|
||||
state: "in_flight",
|
||||
|
|
@ -459,7 +426,6 @@ ext?.runtime?.onConnect?.addListener?.((port: unknown) => {
|
|||
method,
|
||||
responseType,
|
||||
timeoutMs: timeout,
|
||||
wantProgressEvents,
|
||||
headerCount: Object.keys(allHeaders).length,
|
||||
headerNames: Object.keys(allHeaders),
|
||||
forbiddenHeaderNames: Object.keys(forbiddenHeaders),
|
||||
|
|
@ -662,6 +628,7 @@ ext?.runtime?.onConnect?.addListener?.((port: unknown) => {
|
|||
|
||||
const responseHeaders = formatHeaders(res.headers);
|
||||
const finalUrl = res.url || url;
|
||||
const responseContentType = res.headers.get("content-type") || "";
|
||||
|
||||
const makeBase = (
|
||||
readyState: number,
|
||||
|
|
@ -685,90 +652,46 @@ ext?.runtime?.onConnect?.addListener?.((port: unknown) => {
|
|||
let responseText: string | undefined;
|
||||
let responseB64: string | undefined;
|
||||
|
||||
// For Yandex protobuf calls we don't need progress events in GM_fetch.
|
||||
// Sending only a terminal payload reduces extension message overhead.
|
||||
const shouldStreamBinary =
|
||||
wantBinary && !!res.body && (wantProgressEvents || !isYandexApiRequest);
|
||||
if (wantBinary) {
|
||||
const contentLength = Number(res.headers.get("content-length") || 0);
|
||||
const shouldStreamBinary =
|
||||
!!res.body &&
|
||||
(!Number.isFinite(contentLength) ||
|
||||
contentLength > MAX_INLINE_BINARY_RESPONSE_BYTES);
|
||||
|
||||
if (shouldStreamBinary) {
|
||||
const total = Number(res.headers.get("content-length") || 0) || 0;
|
||||
let loaded = 0;
|
||||
const reader = res.body.getReader();
|
||||
const inlineChunks: Uint8Array[] = [];
|
||||
let inlineBytes = 0;
|
||||
let canInline = true;
|
||||
if (shouldStreamBinary) {
|
||||
// Large binaries are streamed as progress chunks to avoid one huge
|
||||
// base64 payload in extension messaging.
|
||||
let loaded = 0;
|
||||
const total = Number.isFinite(contentLength) ? contentLength : 0;
|
||||
const reader = res.body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
loaded += value.byteLength;
|
||||
|
||||
if (canInline) {
|
||||
const nextInlineBytes = inlineBytes + value.byteLength;
|
||||
if (nextInlineBytes <= MAX_INLINE_BINARY_RESPONSE_BYTES) {
|
||||
inlineChunks.push(value.slice());
|
||||
inlineBytes = nextInlineBytes;
|
||||
} else {
|
||||
canInline = false;
|
||||
inlineChunks.length = 0;
|
||||
inlineBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const progress: XhrProgress = {
|
||||
...makeBase(3),
|
||||
loaded,
|
||||
total,
|
||||
lengthComputable: total > 0,
|
||||
chunkB64: bytesToBase64(value),
|
||||
};
|
||||
|
||||
try {
|
||||
loaded += value.byteLength;
|
||||
safePostMessage({
|
||||
type: "progress",
|
||||
state: "in_flight",
|
||||
progress,
|
||||
progress: {
|
||||
...makeBase(3),
|
||||
loaded,
|
||||
total,
|
||||
lengthComputable: total > 0,
|
||||
chunkB64: bytesToBase64(value),
|
||||
},
|
||||
});
|
||||
debug.log("[VOT EXT][background][xhr] progress", {
|
||||
xhrSessionId,
|
||||
url: finalUrl,
|
||||
loaded,
|
||||
total,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (inlineChunks.length && inlineBytes > 0) {
|
||||
const merged = new Uint8Array(inlineBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of inlineChunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
responseB64 = bytesToBase64(merged);
|
||||
}
|
||||
|
||||
// Binary bodies are streamed to the bridge via progress events.
|
||||
// We intentionally avoid sending the full binary payload in the final
|
||||
// "load" message because extension messaging only supports
|
||||
// JSON-serializable objects.
|
||||
response = undefined;
|
||||
} else if (wantBinary) {
|
||||
// e.g. Yandex protobuf requests without onprogress handlers (or HEAD).
|
||||
// We return a single terminal payload instead of progress chunks.
|
||||
try {
|
||||
response = undefined;
|
||||
} else {
|
||||
// Small binaries can be delivered in one terminal payload.
|
||||
const ab = await res.arrayBuffer();
|
||||
if (ab.byteLength > 0) {
|
||||
responseB64 = arrayBufferToBase64(ab);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
response = undefined;
|
||||
}
|
||||
response = undefined;
|
||||
} else if (responseType === "json") {
|
||||
responseText = await res.text();
|
||||
try {
|
||||
|
|
@ -793,7 +716,6 @@ ext?.runtime?.onConnect?.addListener?.((port: unknown) => {
|
|||
responseTextLength: responseText?.length ?? 0,
|
||||
responseB64Length: responseB64?.length ?? 0,
|
||||
});
|
||||
const responseContentType = res.headers.get("content-type") || "";
|
||||
safePostMessage({
|
||||
type: "load",
|
||||
state: "terminal",
|
||||
|
|
|
|||
|
|
@ -179,7 +179,6 @@ function toSerializableXhrDetails(details: AnyObject): AnyObject {
|
|||
responseType: details.responseType,
|
||||
anonymous: details.anonymous,
|
||||
withCredentials: details.withCredentials,
|
||||
wantProgressEvents: typeof details.onprogress === "function",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -215,9 +214,6 @@ async function toBridgeXhrDetails(details: AnyObject): Promise<AnyObject> {
|
|||
responseType: details.responseType,
|
||||
anonymous: details.anonymous,
|
||||
withCredentials: details.withCredentials,
|
||||
// Progress is expensive in extension messaging for tiny protobuf responses.
|
||||
// Keep it only when caller actually subscribed to onprogress.
|
||||
wantProgressEvents: typeof details.onprogress === "function",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.0",
|
||||
"version": "1.11.1",
|
||||
"author": "Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng",
|
||||
"namespace": "vot",
|
||||
"icon": "https://translate.yandex.ru/icons/favicon.ico",
|
||||
|
|
|
|||
285
src/index.ts
285
src/index.ts
|
|
@ -48,11 +48,15 @@ import {
|
|||
import { VideoObserver } from "./utils/VideoObserver";
|
||||
import VOTLocalizedError from "./utils/VOTLocalizedError";
|
||||
import {
|
||||
clampInt,
|
||||
clampPercentInt,
|
||||
snapVolume01,
|
||||
volume01ToPercent,
|
||||
} from "./utils/volume";
|
||||
import {
|
||||
applyVolumeLinkDelta,
|
||||
syncTranslationLinkSnapshot,
|
||||
syncVideoLinkSnapshot,
|
||||
} from "./utils/volumeLink";
|
||||
import {
|
||||
getAutoHideDelay as getAutoHideDelayImpl,
|
||||
initExtraEvents as initExtraEventsImpl,
|
||||
|
|
@ -163,6 +167,12 @@ export class VideoHandler {
|
|||
internalVideoVolumeSetAt = 0;
|
||||
internalVideoVolumeSetPercent: number | null = null;
|
||||
internalVideoVolumeSuppressionMs = 250;
|
||||
internalVideoVolumeSetHistory: Array<{
|
||||
at: number;
|
||||
percent: number;
|
||||
suppressMs: number;
|
||||
}> = [];
|
||||
internalVideoVolumeSetHistoryLimit = 48;
|
||||
|
||||
// Smart auto-volume ducking state. Used to lower the original video volume
|
||||
// only while the translated track has audible sound (not during silence).
|
||||
|
|
@ -196,6 +206,11 @@ export class VideoHandler {
|
|||
subtitlesWidget?: SubtitlesWidget;
|
||||
|
||||
activeTranslation: { key: string; promise: Promise<unknown> } | null = null;
|
||||
/**
|
||||
* In-flight async teardown for translation/audio player cleanup.
|
||||
* New translation starts should wait for this to avoid clear/init races.
|
||||
*/
|
||||
stopTranslatePromise: Promise<void> | null = null;
|
||||
|
||||
// Managers
|
||||
interactionChecker!: IntervalIdleChecker;
|
||||
|
|
@ -614,13 +629,8 @@ export class VideoHandler {
|
|||
* Run auto translate using orchestrator dependencies.
|
||||
*/
|
||||
async runAutoTranslate() {
|
||||
try {
|
||||
this.videoManager.videoValidator();
|
||||
await this.uiManager.handleTranslationBtnClick();
|
||||
} catch (err) {
|
||||
console.error("[VOT]", err);
|
||||
throw err;
|
||||
}
|
||||
await this.videoManager.videoValidator();
|
||||
await this.uiManager.handleTranslationBtnClick();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -686,12 +696,27 @@ export class VideoHandler {
|
|||
* our own recent programmatic setVideoVolume call.
|
||||
*/
|
||||
isLikelyInternalVideoVolumeChange(observedPercent: number) {
|
||||
const now = Date.now();
|
||||
if (this.internalVideoVolumeSetHistory.length > 0) {
|
||||
this.internalVideoVolumeSetHistory =
|
||||
this.internalVideoVolumeSetHistory.filter(
|
||||
(entry) => now - entry.at <= entry.suppressMs,
|
||||
);
|
||||
|
||||
for (const entry of this.internalVideoVolumeSetHistory) {
|
||||
// Allow a 1% tolerance to account for hosts that quantize volume.
|
||||
if (Math.abs(observedPercent - entry.percent) <= 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.internalVideoVolumeSetPercent === null) return false;
|
||||
|
||||
const ageMs = Date.now() - this.internalVideoVolumeSetAt;
|
||||
const ageMs = now - this.internalVideoVolumeSetAt;
|
||||
if (ageMs > this.internalVideoVolumeSuppressionMs) return false;
|
||||
|
||||
// Allow a 1% tolerance to account for hosts that quantize volume.
|
||||
return Math.abs(observedPercent - this.internalVideoVolumeSetPercent) <= 1;
|
||||
}
|
||||
|
||||
|
|
@ -882,14 +907,39 @@ export class VideoHandler {
|
|||
* @param {number} volume A number between 0 and 1.
|
||||
* @returns {VideoHandler} This instance.
|
||||
*/
|
||||
setVideoVolume(volume: number): this {
|
||||
setVideoVolume(
|
||||
volume: number,
|
||||
options: { suppressSyncMs?: number } = {},
|
||||
): this {
|
||||
const snapped = snapVolume01(volume);
|
||||
const suppressSyncMs =
|
||||
typeof options.suppressSyncMs === "number" &&
|
||||
Number.isFinite(options.suppressSyncMs)
|
||||
? Math.max(0, options.suppressSyncMs)
|
||||
: this.internalVideoVolumeSuppressionMs;
|
||||
const now = Date.now();
|
||||
const percent = volume01ToPercent(snapped);
|
||||
|
||||
// Remember this programmatic update so external observers (e.g. YouTube aria
|
||||
// mutations) won't treat it as a user-driven volume change and resync volumes
|
||||
// again (which would cause drift/loops).
|
||||
this.internalVideoVolumeSetAt = Date.now();
|
||||
this.internalVideoVolumeSetPercent = volume01ToPercent(snapped);
|
||||
this.internalVideoVolumeSetAt = now;
|
||||
this.internalVideoVolumeSetPercent = percent;
|
||||
this.internalVideoVolumeSetHistory.push({
|
||||
at: now,
|
||||
percent,
|
||||
suppressMs: suppressSyncMs,
|
||||
});
|
||||
if (
|
||||
this.internalVideoVolumeSetHistory.length >
|
||||
this.internalVideoVolumeSetHistoryLimit
|
||||
) {
|
||||
this.internalVideoVolumeSetHistory.splice(
|
||||
0,
|
||||
this.internalVideoVolumeSetHistory.length -
|
||||
this.internalVideoVolumeSetHistoryLimit,
|
||||
);
|
||||
}
|
||||
|
||||
this.videoManager.setVideoVolume(snapped);
|
||||
return this;
|
||||
|
|
@ -904,7 +954,7 @@ export class VideoHandler {
|
|||
// If syncVolume isn't initialized yet, keep the state aligned so the first
|
||||
// delta computation won't jump.
|
||||
if (!this.volumeLinkState.initialized) {
|
||||
this.volumeLinkState.lastVideoPercent = normalized;
|
||||
syncVideoLinkSnapshot(this.volumeLinkState, normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -919,7 +969,30 @@ export class VideoHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
this.volumeLinkState.lastVideoPercent = normalized;
|
||||
syncVideoLinkSnapshot(this.volumeLinkState, normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps internal translation-volume snapshot aligned when syncVolume is
|
||||
* temporarily disabled, so re-enabling link mode does not apply stale deltas.
|
||||
*/
|
||||
onTranslationVolumeSliderSynced(volumePercent: number) {
|
||||
if (!this.volumeLinkState.initialized) {
|
||||
syncTranslationLinkSnapshot(this.volumeLinkState, volumePercent);
|
||||
return;
|
||||
}
|
||||
|
||||
syncTranslationLinkSnapshot(this.volumeLinkState, volumePercent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seeds syncVolume baseline from current UI slider values.
|
||||
* Useful when toggling syncVolume on/off to avoid stale delta state.
|
||||
*/
|
||||
resetVolumeLinkState(videoPercent: number, translationPercent: number): void {
|
||||
syncVideoLinkSnapshot(this.volumeLinkState, videoPercent);
|
||||
syncTranslationLinkSnapshot(this.volumeLinkState, translationPercent);
|
||||
this.volumeLinkState.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -972,61 +1045,28 @@ export class VideoHandler {
|
|||
if (!videoSlider || !translationSlider) {
|
||||
return;
|
||||
}
|
||||
const { nextVideo, nextTranslation } = applyVolumeLinkDelta({
|
||||
state: this.volumeLinkState,
|
||||
fromType,
|
||||
newVolume,
|
||||
currentVideo: Number(videoSlider.value),
|
||||
currentTranslation: Number(translationSlider.value),
|
||||
translationMin: translationSlider.min,
|
||||
translationMax: translationSlider.max,
|
||||
});
|
||||
|
||||
// Initialize state once from current slider values.
|
||||
if (!this.volumeLinkState.initialized) {
|
||||
this.volumeLinkState.lastVideoPercent = Number(videoSlider.value);
|
||||
this.volumeLinkState.lastTranslationPercent = Number(
|
||||
translationSlider.value,
|
||||
);
|
||||
this.volumeLinkState.initialized = true;
|
||||
}
|
||||
|
||||
if (fromType === "video") {
|
||||
const prevVideo = this.volumeLinkState.lastVideoPercent;
|
||||
const delta = newVolume - prevVideo;
|
||||
|
||||
// Always update the initiator snapshot, even if delta is 0 / invalid.
|
||||
this.volumeLinkState.lastVideoPercent = newVolume;
|
||||
|
||||
if (!Number.isFinite(delta) || delta === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTranslation = Number(translationSlider.value);
|
||||
const nextTranslation = clampInt(
|
||||
currentTranslation + delta,
|
||||
translationSlider.min,
|
||||
translationSlider.max,
|
||||
);
|
||||
|
||||
if (typeof nextTranslation === "number") {
|
||||
translationSlider.value = nextTranslation;
|
||||
this.volumeLinkState.lastTranslationPercent = nextTranslation;
|
||||
|
||||
if (this.audioPlayer?.player) {
|
||||
this.audioPlayer.player.volume = nextTranslation / 100;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// fromType === "translation"
|
||||
const prevTranslation = this.volumeLinkState.lastTranslationPercent;
|
||||
const delta = newVolume - prevTranslation;
|
||||
|
||||
this.volumeLinkState.lastTranslationPercent = newVolume;
|
||||
|
||||
if (!Number.isFinite(delta) || delta === 0) {
|
||||
return;
|
||||
if (typeof nextVideo === "number") {
|
||||
videoSlider.value = nextVideo;
|
||||
this.setVideoVolume(nextVideo / 100);
|
||||
}
|
||||
|
||||
const currentVideo = Number(videoSlider.value);
|
||||
const nextVideo = clampPercentInt(currentVideo + delta);
|
||||
|
||||
videoSlider.value = nextVideo;
|
||||
this.volumeLinkState.lastVideoPercent = nextVideo;
|
||||
|
||||
this.setVideoVolume(nextVideo / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1039,64 +1079,83 @@ export class VideoHandler {
|
|||
|
||||
/**
|
||||
* Validates the video.
|
||||
* @returns {boolean} True if valid.
|
||||
* @returns {Promise<boolean>} True if valid.
|
||||
*/
|
||||
videoValidator() {
|
||||
return this.videoManager.videoValidator();
|
||||
async videoValidator() {
|
||||
return await this.videoManager.videoValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops translation and resets UI elements.
|
||||
*/
|
||||
stopTranslate() {
|
||||
if (this.audioPlayer?.player) {
|
||||
try {
|
||||
this.audioPlayer.player.removeVideoEvents();
|
||||
this.audioPlayer.player.clear();
|
||||
this.audioPlayer.player.src = "";
|
||||
} catch (err) {
|
||||
debug.log("[stopTranslate] audioPlayer cleanup error", err);
|
||||
}
|
||||
debug.log("audioPlayer after stopTranslate", this.audioPlayer);
|
||||
stopTranslate(): Promise<void> {
|
||||
if (this.stopTranslatePromise) {
|
||||
return this.stopTranslatePromise;
|
||||
}
|
||||
this.activeTranslation = null;
|
||||
const overlayView = this.uiManager.votOverlayView;
|
||||
if (overlayView) {
|
||||
if (overlayView.videoVolumeSlider) {
|
||||
overlayView.videoVolumeSlider.hidden = true;
|
||||
}
|
||||
if (overlayView.translationVolumeSlider) {
|
||||
overlayView.translationVolumeSlider.hidden = true;
|
||||
}
|
||||
if (overlayView.downloadTranslationButton) {
|
||||
overlayView.downloadTranslationButton.hidden = true;
|
||||
}
|
||||
}
|
||||
this.downloadTranslationUrl = null;
|
||||
this.longWaitingResCount = 0;
|
||||
this.hadAsyncWait = false;
|
||||
this.transformBtn("none", localizationProvider.get("translateVideo"));
|
||||
debug.log(`Volume on start: ${this.volumeOnStart}`);
|
||||
|
||||
// Restore the original video volume. If the user adjusted volume while
|
||||
// ducking was enabled, prefer the latest baseline volume.
|
||||
const restoreVolume =
|
||||
typeof this.smartVolumeDuckingBaseline === "number"
|
||||
? this.smartVolumeDuckingBaseline
|
||||
: this.volumeOnStart;
|
||||
const cleanup = async () => {
|
||||
if (this.audioPlayer?.player) {
|
||||
try {
|
||||
this.audioPlayer.player.removeVideoEvents();
|
||||
this.audioPlayer.player.src = "";
|
||||
await this.audioPlayer.player.clear();
|
||||
} catch (err) {
|
||||
debug.log("[stopTranslate] audioPlayer cleanup error", err);
|
||||
}
|
||||
debug.log("audioPlayer after stopTranslate", this.audioPlayer);
|
||||
}
|
||||
this.activeTranslation = null;
|
||||
const overlayView = this.uiManager.votOverlayView;
|
||||
if (overlayView) {
|
||||
if (overlayView.videoVolumeSlider) {
|
||||
overlayView.videoVolumeSlider.hidden = true;
|
||||
}
|
||||
if (overlayView.translationVolumeSlider) {
|
||||
overlayView.translationVolumeSlider.hidden = true;
|
||||
}
|
||||
if (overlayView.downloadTranslationButton) {
|
||||
overlayView.downloadTranslationButton.hidden = true;
|
||||
}
|
||||
}
|
||||
this.downloadTranslationUrl = null;
|
||||
this.longWaitingResCount = 0;
|
||||
this.hadAsyncWait = false;
|
||||
this.transformBtn("none", localizationProvider.get("translateVideo"));
|
||||
debug.log(`Volume on start: ${this.volumeOnStart}`);
|
||||
|
||||
stopSmartVolumeDuckingImpl(this, { restoreVolume });
|
||||
this.volumeOnStart = undefined;
|
||||
if (this.autoRetry !== undefined) {
|
||||
clearTimeout(this.autoRetry);
|
||||
this.autoRetry = undefined;
|
||||
}
|
||||
if (this.translationRefreshTimeout !== undefined) {
|
||||
clearTimeout(this.translationRefreshTimeout);
|
||||
this.translationRefreshTimeout = undefined;
|
||||
}
|
||||
// Cancel in-flight translation work.
|
||||
this.resetActionsAbortController("stopTranslate");
|
||||
// Restore the original video volume. If the user adjusted volume while
|
||||
// ducking was enabled, prefer the latest baseline volume.
|
||||
const restoreVolume =
|
||||
typeof this.smartVolumeDuckingBaseline === "number"
|
||||
? this.smartVolumeDuckingBaseline
|
||||
: this.volumeOnStart;
|
||||
|
||||
stopSmartVolumeDuckingImpl(this, { restoreVolume });
|
||||
this.volumeOnStart = undefined;
|
||||
if (this.autoRetry !== undefined) {
|
||||
clearTimeout(this.autoRetry);
|
||||
this.autoRetry = undefined;
|
||||
}
|
||||
if (this.translationRefreshTimeout !== undefined) {
|
||||
clearTimeout(this.translationRefreshTimeout);
|
||||
this.translationRefreshTimeout = undefined;
|
||||
}
|
||||
// Cancel in-flight translation work.
|
||||
this.resetActionsAbortController("stopTranslate");
|
||||
};
|
||||
|
||||
const inFlight = cleanup().finally(() => {
|
||||
if (this.stopTranslatePromise === inFlight) {
|
||||
this.stopTranslatePromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.stopTranslatePromise = inFlight;
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
waitForPendingStopTranslate(): Promise<void> {
|
||||
return this.stopTranslatePromise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1336,10 +1395,10 @@ export class VideoHandler {
|
|||
/**
|
||||
* Stops translation and synchronizes volume.
|
||||
*/
|
||||
stopTranslation = () => {
|
||||
stopTranslation = async () => {
|
||||
this.translationOrchestrator?.reset();
|
||||
this.overlayVisibility?.cancel();
|
||||
this.stopTranslate();
|
||||
await this.stopTranslate();
|
||||
this.syncVideoVolumeSlider();
|
||||
};
|
||||
|
||||
|
|
@ -1357,7 +1416,7 @@ export class VideoHandler {
|
|||
debug.log("[VideoHandler] release");
|
||||
this.initialized = false;
|
||||
try {
|
||||
this.stopTranslation();
|
||||
await this.stopTranslation();
|
||||
} catch (err) {
|
||||
debug.log("[VideoHandler] stopTranslation failed during release", err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ import rawDefaultLocale from "./locales/en.json";
|
|||
|
||||
export type LangOverride = "auto" | Locale;
|
||||
|
||||
const repoBranch =
|
||||
typeof REPO_BRANCH !== "undefined" && REPO_BRANCH ? REPO_BRANCH : "master";
|
||||
const availableLocales =
|
||||
typeof AVAILABLE_LOCALES !== "undefined" && Array.isArray(AVAILABLE_LOCALES)
|
||||
? AVAILABLE_LOCALES
|
||||
: ["en"];
|
||||
|
||||
class LocalizationProvider {
|
||||
storageKeys: LocaleStorageKey[] = [
|
||||
"localePhrases",
|
||||
|
|
@ -29,8 +36,8 @@ class LocalizationProvider {
|
|||
defaultLocale: FlatPhrases = toFlatObj(rawDefaultLocale);
|
||||
|
||||
cacheTTL = 7200;
|
||||
localesUrl = `${contentUrl}/${REPO_BRANCH}/src/localization/locales`;
|
||||
hashesUrl = `${contentUrl}/${REPO_BRANCH}/src/localization/hashes.json`;
|
||||
localesUrl = `${contentUrl}/${repoBranch}/src/localization/locales`;
|
||||
hashesUrl = `${contentUrl}/${repoBranch}/src/localization/hashes.json`;
|
||||
|
||||
_langOverride: LangOverride = "auto";
|
||||
|
||||
|
|
@ -62,9 +69,9 @@ class LocalizationProvider {
|
|||
// Older extension builds injected AVAILABLE_LOCALES without the `auto`
|
||||
// option. Ensure it is always present so Settings → Language can restore
|
||||
// automatic detection.
|
||||
return AVAILABLE_LOCALES.includes("auto")
|
||||
? AVAILABLE_LOCALES
|
||||
: (["auto", ...AVAILABLE_LOCALES] as LangOverride[]);
|
||||
return availableLocales.includes("auto")
|
||||
? (availableLocales as LangOverride[])
|
||||
: (["auto", ...availableLocales] as LangOverride[]);
|
||||
}
|
||||
|
||||
async reset() {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
text-overflow: ellipsis;
|
||||
@include shared.vot-font-family;
|
||||
font-size: $font-size;
|
||||
font-weight: $font-weight;
|
||||
font-weight: $font-weight !important;
|
||||
line-height: $line-height;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
padding-left: var(--vot-checkbox-label-offset) !important;
|
||||
width: inherit;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
font-weight: normal !important;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@
|
|||
display: flex;
|
||||
flex: 1;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
font-weight: inherit !important;
|
||||
margin: 0 !important;
|
||||
outline: 0;
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@
|
|||
&-title {
|
||||
flex: 1;
|
||||
font-size: calc(15 / 13 * 100%);
|
||||
font-weight: bold;
|
||||
font-weight: bold !important;
|
||||
line-height: 1;
|
||||
padding: var(--vot-space-5) var(--vot-space-5) var(--vot-space-4) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
system-ui,
|
||||
sans-serif
|
||||
);
|
||||
font-weight: bold;
|
||||
font-weight: bold !important;
|
||||
line-height: 1.5;
|
||||
text-align: start;
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@
|
|||
display: flex;
|
||||
flex: 1;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
font-weight: inherit !important;
|
||||
margin: 0 !important;
|
||||
outline: 0;
|
||||
text-align: start;
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
&-title {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
font-weight: 500 !important;
|
||||
line-height: 1;
|
||||
padding: var(--vot-space-4) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@
|
|||
margin-left: var(--vot-space-2) !important;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
font-weight: 400;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
sans-serif
|
||||
);
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
font-weight: normal !important;
|
||||
line-height: 1.5;
|
||||
text-align: start;
|
||||
color: var(--vot-helper-theme);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
margin-left: 0 !important;
|
||||
font-variant-numeric: tabular-nums;
|
||||
// Reduce visual noise: values should be legible but not steal attention.
|
||||
font-weight: 500;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.vot-slider-label-text {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
@use "../mixins" as shared;
|
||||
|
||||
.vot-tooltip {
|
||||
--vot-helper-theme-rgb: var(--vot-onsurface-rgb, 0, 0, 0);
|
||||
--vot-helper-theme: rgba(var(--vot-helper-theme-rgb), 0.87);
|
||||
|
|
@ -11,13 +13,7 @@
|
|||
color: var(--vot-helper-theme);
|
||||
fill: var(--vot-helper-theme);
|
||||
border-radius: 4px !important;
|
||||
font-family: var(
|
||||
--vot-font-family,
|
||||
"Roboto",
|
||||
"Segoe UI",
|
||||
system-ui,
|
||||
sans-serif
|
||||
);
|
||||
@include shared.vot-font-family;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
cursor: default;
|
||||
|
|
@ -42,5 +38,6 @@
|
|||
|
||||
& * {
|
||||
box-sizing: border-box !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ vot-block {
|
|||
system-ui,
|
||||
sans-serif
|
||||
) !important;
|
||||
font-weight: 400 !important;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
|
|
@ -110,6 +111,11 @@ vot-block {
|
|||
visibility: visible !important;
|
||||
}
|
||||
|
||||
// Keep host-page typography rules from overriding VOT font weights.
|
||||
vot-block * {
|
||||
font-weight: inherit !important;
|
||||
}
|
||||
|
||||
// Create an explicit stacking / blending boundary for overlays.
|
||||
.vot-portal-local,
|
||||
.vot-subtitles-widget {
|
||||
|
|
|
|||
|
|
@ -190,6 +190,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) and (pointer: coarse) {
|
||||
.vot-subtitles-widget {
|
||||
--vot-subtitles-fallback-bottom-inset: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
:fullscreen .vot-subtitles,
|
||||
:-webkit-full-screen .vot-subtitles {
|
||||
// Fullscreen should utilize more horizontal real estate to reduce
|
||||
|
|
|
|||
|
|
@ -300,13 +300,19 @@ const ensureProcessedSubtitles = (input: unknown): ProcessedSubtitles => {
|
|||
return { subtitles };
|
||||
};
|
||||
|
||||
const VK_INLINE_TIMESTAMP_RE = /<(?:\d{1,2}:)?\d{2}:\d{2}(?:[.,]\d{1,3})?>/gu;
|
||||
const VK_DUPLICATE_MAX_GAP_MS = 120;
|
||||
|
||||
const stripHtmlToText = (value: string): string => {
|
||||
if (!value.includes("<")) return value;
|
||||
if (typeof document === "undefined") return value;
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = value;
|
||||
return template.content.textContent ?? "";
|
||||
if (typeof document !== "undefined") {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = value;
|
||||
return template.content.textContent ?? "";
|
||||
}
|
||||
|
||||
return value.replace(/<\/?[^>]+>/gu, "");
|
||||
};
|
||||
|
||||
const getYoutubeEventDurationMs = (
|
||||
|
|
@ -616,15 +622,78 @@ export const SubtitlesProcessor = {
|
|||
},
|
||||
|
||||
cleanJsonSubtitles(subtitles: ProcessedSubtitles): ProcessedSubtitles {
|
||||
return {
|
||||
subtitles: subtitles.subtitles.map((line) => ({
|
||||
const normalizeText = (value: string): string =>
|
||||
stripHtmlToText(value.replace(VK_INLINE_TIMESTAMP_RE, ""))
|
||||
.replace(/\s+([,.:;!?])/gu, "$1")
|
||||
.replace(/[ \t]*\n[ \t]*/gu, "\n")
|
||||
.replace(/[ \t]{2,}/gu, " ")
|
||||
.replace(/\n{3,}/gu, "\n\n")
|
||||
.trim();
|
||||
|
||||
const normalizeComparable = (value: string): string =>
|
||||
value.toLowerCase().replace(/\s+/gu, " ").trim();
|
||||
const lineEndMs = (line: SubtitleLine): number =>
|
||||
line.startMs + Math.max(0, line.durationMs);
|
||||
const tokensTextLength = (tokens: SubtitleToken[]): number =>
|
||||
tokens.reduce((sum, token) => sum + token.text.length, 0);
|
||||
|
||||
const cleanedLines = subtitles.subtitles
|
||||
.map((line) => ({
|
||||
...line,
|
||||
text: stripHtmlToText(line.text),
|
||||
tokens: line.tokens.map((token) => ({
|
||||
...token,
|
||||
text: stripHtmlToText(token.text),
|
||||
})),
|
||||
})),
|
||||
text: normalizeText(line.text),
|
||||
tokens: line.tokens
|
||||
.map((token) => ({
|
||||
...token,
|
||||
text: normalizeText(token.text),
|
||||
}))
|
||||
.filter((token) => token.text.length > 0),
|
||||
}))
|
||||
.filter((line) => line.text.length > 0);
|
||||
|
||||
const mergedLines: SubtitleLine[] = [];
|
||||
for (const line of cleanedLines) {
|
||||
const currentComparable = normalizeComparable(line.text);
|
||||
if (!currentComparable) continue;
|
||||
|
||||
const previous = mergedLines.at(-1);
|
||||
if (!previous) {
|
||||
mergedLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousComparable = normalizeComparable(previous.text);
|
||||
const previousEnd = lineEndMs(previous);
|
||||
const currentEnd = lineEndMs(line);
|
||||
const isDuplicateText = previousComparable === currentComparable;
|
||||
const isNearPrevious =
|
||||
line.startMs <= previousEnd + VK_DUPLICATE_MAX_GAP_MS;
|
||||
|
||||
if (!isDuplicateText || !isNearPrevious) {
|
||||
mergedLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// VK can emit duplicate cue bursts with tiny timing deltas. Merge them
|
||||
// instead of dropping short cues by duration heuristics.
|
||||
const mergedStart = Math.min(previous.startMs, line.startMs);
|
||||
const mergedEnd = Math.max(previousEnd, currentEnd);
|
||||
const previousTokensLength = tokensTextLength(previous.tokens);
|
||||
const currentTokensLength = tokensTextLength(line.tokens);
|
||||
const mergedTokens =
|
||||
currentTokensLength >= previousTokensLength
|
||||
? line.tokens
|
||||
: previous.tokens;
|
||||
|
||||
mergedLines[mergedLines.length - 1] = {
|
||||
...previous,
|
||||
startMs: mergedStart,
|
||||
durationMs: Math.max(0, mergedEnd - mergedStart),
|
||||
tokens: mergedTokens,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subtitles: mergedLines,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -535,6 +535,11 @@ export class SubtitlesWidget {
|
|||
const h = this.safeAreaProbeEl.offsetHeight || 0;
|
||||
return h;
|
||||
}
|
||||
private isMobileViewport(): boolean {
|
||||
if (typeof globalThis.matchMedia !== "function") return false;
|
||||
return globalThis.matchMedia("(max-width: 900px) and (pointer: coarse)")
|
||||
.matches;
|
||||
}
|
||||
private getBottomInsetPreset() {
|
||||
const doc = document as Document & {
|
||||
webkitFullscreenElement?: Element | null;
|
||||
|
|
@ -595,6 +600,9 @@ export class SubtitlesWidget {
|
|||
Number.parseFloat(
|
||||
getComputedStyle(this.container).paddingBottom || "0",
|
||||
) || 0;
|
||||
if (this.isMobileViewport()) {
|
||||
return Math.max(paddingBottom, safeAreaBottom);
|
||||
}
|
||||
const anchorH =
|
||||
anchorBox?.h ??
|
||||
this.computeAnchorBoxLayout(layout ?? this.getLayoutSize()).h;
|
||||
|
|
|
|||
|
|
@ -13,4 +13,4 @@
|
|||
*/
|
||||
export type EventHandler<Args extends unknown[] = unknown[]> = (
|
||||
...args: Args
|
||||
) => void;
|
||||
) => void | Promise<void>;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export type SettingsViewEventMap = {
|
|||
"change:autoSubtitles": [checked: boolean];
|
||||
"change:showVideoVolume": [checked: boolean];
|
||||
"change:audioBooster": [checked: boolean];
|
||||
"change:syncVolume": [checked: boolean];
|
||||
"change:useLivelyVoice": [checked: boolean];
|
||||
"change:subtitlesHighlightWords": [checked: boolean];
|
||||
"change:subtitlesSmartLayout": [checked: boolean];
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { OverlayMount, UIManagerProps } from "../types/uiManager";
|
|||
import ui from "../ui";
|
||||
import debug from "../utils/debug";
|
||||
import { buildTranslationBlob, downloadTranslation } from "../utils/download";
|
||||
import { GM_fetch, isSupportGMXhr } from "../utils/gm";
|
||||
import { GM_fetch } from "../utils/gm";
|
||||
import type { IntervalIdleChecker } from "../utils/intervalIdleChecker";
|
||||
import { votStorage } from "../utils/storage";
|
||||
import {
|
||||
|
|
@ -22,7 +22,6 @@ import {
|
|||
clearFileName,
|
||||
downloadBlob,
|
||||
exitFullscreen,
|
||||
revokeObjectUrlLater,
|
||||
} from "../utils/utils";
|
||||
import VOTLocalizedError from "../utils/VOTLocalizedError";
|
||||
import VOTButton from "./components/votButton";
|
||||
|
|
@ -178,9 +177,9 @@ export class UIManager {
|
|||
|
||||
const downloadButton = this.votOverlayView.downloadTranslationButton;
|
||||
const downloadUrl = this.videoHandler.downloadTranslationUrl;
|
||||
const filename = clearFileName(
|
||||
this.videoHandler.videoData.downloadTitle,
|
||||
);
|
||||
const filename = this.data.downloadWithName
|
||||
? clearFileName(this.videoHandler.videoData.downloadTitle)
|
||||
: `translation_${this.videoHandler.videoData.videoId}`;
|
||||
const isMobile = this.videoHandler.site.additionalData === "mobile";
|
||||
|
||||
if (downloadButton) {
|
||||
|
|
@ -204,35 +203,10 @@ export class UIManager {
|
|||
}
|
||||
},
|
||||
);
|
||||
const file = new File([blob], `${filename}.mp3`, {
|
||||
type: blob.type || "audio/mpeg",
|
||||
});
|
||||
const canShareFiles =
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof navigator.canShare === "function" &&
|
||||
navigator.canShare({ files: [file] });
|
||||
if (navigator?.share && canShareFiles) {
|
||||
await navigator.share({ files: [file], title: filename });
|
||||
return;
|
||||
}
|
||||
const fallbackUrl = URL.createObjectURL(blob);
|
||||
globalThis.open(fallbackUrl, "_blank")?.focus();
|
||||
revokeObjectUrlLater(fallbackUrl);
|
||||
await this.saveBlobWithMobileShare(blob, `${filename}.mp3`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.data.downloadWithName || !isSupportGMXhr) {
|
||||
globalThis.open(downloadUrl, "_blank")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!downloadButton) {
|
||||
throw new Error(
|
||||
"[VOT] Download translation button is not initialized",
|
||||
);
|
||||
}
|
||||
|
||||
downloadButton.progress = 0;
|
||||
// Download the full audio. A range request (bytes=0-0) only returns a
|
||||
// tiny fragment (~1 byte + headers) which results in a silent ~5KB file.
|
||||
const res = await GM_fetch(downloadUrl, { timeout: 0 });
|
||||
|
|
@ -240,11 +214,15 @@ export class UIManager {
|
|||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
await downloadTranslation(res, filename, (progress) => {
|
||||
downloadButton.progress = progress;
|
||||
if (downloadButton) {
|
||||
downloadButton.progress = progress;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[VOT] Download translation failed:", err);
|
||||
globalThis.open(downloadUrl, "_blank")?.focus();
|
||||
if (!this.triggerUrlDownload(downloadUrl, `${filename}.mp3`)) {
|
||||
globalThis.open(downloadUrl, "_blank")?.focus();
|
||||
}
|
||||
} finally {
|
||||
if (downloadButton) {
|
||||
downloadButton.progress = 0;
|
||||
|
|
@ -284,6 +262,7 @@ export class UIManager {
|
|||
|
||||
this.videoHandler.setVideoVolume(volume / 100);
|
||||
if (!this.data.syncVolume) {
|
||||
this.videoHandler.onVideoVolumeSliderSynced(volume);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -299,6 +278,7 @@ export class UIManager {
|
|||
const nextVolume = volume ?? this.data.defaultVolume ?? 100;
|
||||
this.videoHandler.audioPlayer.player.volume = nextVolume / 100;
|
||||
if (!this.data.syncVolume) {
|
||||
this.videoHandler.onTranslationVolumeSliderSynced(nextVolume);
|
||||
return;
|
||||
}
|
||||
this.videoHandler.syncVolumeWrapper("translation", nextVolume);
|
||||
|
|
@ -354,15 +334,35 @@ export class UIManager {
|
|||
const currentVolume = overlayView.translationVolumeSlider.value;
|
||||
const maxVolume = this.data.audioBooster ? maxAudioVolume : 100;
|
||||
overlayView.translationVolumeSlider.max = maxVolume;
|
||||
overlayView.translationVolumeSlider.value = clamp(
|
||||
currentVolume,
|
||||
0,
|
||||
maxVolume,
|
||||
const nextVolume = clamp(currentVolume, 0, maxVolume);
|
||||
overlayView.translationVolumeSlider.value = nextVolume;
|
||||
this.videoHandler?.onTranslationVolumeSliderSynced(nextVolume);
|
||||
});
|
||||
})
|
||||
.addEventListener("change:syncVolume", (checked) => {
|
||||
if (!this.videoHandler) {
|
||||
return;
|
||||
}
|
||||
this.videoHandler.setupAudioSettings();
|
||||
if (!checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.withInitializedOverlayView((overlayView) => {
|
||||
const videoSlider = overlayView.videoVolumeSlider;
|
||||
const translationSlider = overlayView.translationVolumeSlider;
|
||||
if (!videoSlider || !translationSlider) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoHandler.resetVolumeLinkState(
|
||||
Number(videoSlider.value),
|
||||
Number(translationSlider.value),
|
||||
);
|
||||
});
|
||||
})
|
||||
.addEventListener("change:useLivelyVoice", () => {
|
||||
this.videoHandler?.stopTranslate();
|
||||
void this.videoHandler?.stopTranslate();
|
||||
})
|
||||
.addEventListener("change:subtitlesHighlightWords", (checked) => {
|
||||
this.withSubtitlesWidget((widget) => {
|
||||
|
|
@ -394,14 +394,13 @@ export class UIManager {
|
|||
return;
|
||||
}
|
||||
|
||||
// Proxy host changes can invalidate cached requests/URLs.
|
||||
// Best-effort refresh the active audio source and clear caches.
|
||||
// Proxy host changes invalidate cached requests/URLs and should stop
|
||||
// the current translation session.
|
||||
void this.videoHandler.handleProxySettingsChanged("proxyWorkerHost");
|
||||
})
|
||||
.addEventListener("select:proxyTranslationStatus", () => {
|
||||
// Switching proxy mode changes the request path and can turn transient
|
||||
// failures into successes (and vice versa). Clear caches and restart
|
||||
// request plumbing.
|
||||
// Switching proxy mode changes request routing. Drop stale cache and
|
||||
// stop translation so the next run starts with fresh settings.
|
||||
void this.videoHandler?.handleProxySettingsChanged(
|
||||
"proxyTranslationStatus",
|
||||
);
|
||||
|
|
@ -479,7 +478,7 @@ export class UIManager {
|
|||
const settingsWasOpen =
|
||||
this.votSettingsView?.dialog?.container?.hidden === false;
|
||||
|
||||
this.videoHandler?.stopTranslation();
|
||||
await this.videoHandler?.stopTranslation();
|
||||
this.release();
|
||||
this.initUI();
|
||||
this.initUIEvents();
|
||||
|
|
@ -537,7 +536,7 @@ export class UIManager {
|
|||
debug.log("[handleTranslationBtnClick] click translationBtn");
|
||||
if (this.videoHandler.hasActiveSource()) {
|
||||
debug.log("[handleTranslationBtnClick] video has active source");
|
||||
this.videoHandler.stopTranslation();
|
||||
await this.videoHandler.stopTranslation();
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
@ -556,7 +555,7 @@ export class UIManager {
|
|||
"[handleTranslationBtnClick] translationBtn isn't in none state",
|
||||
);
|
||||
this.videoHandler.actionsAbortController.abort();
|
||||
this.videoHandler.stopTranslation();
|
||||
await this.videoHandler.stopTranslation();
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
@ -691,12 +690,81 @@ export class UIManager {
|
|||
callback(widget);
|
||||
}
|
||||
|
||||
private triggerUrlDownload(url: string, filename: string): boolean {
|
||||
try {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
// Cross-origin downloads can ignore `download`; keep navigation off the
|
||||
// current tab in that case.
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async tryShareBlob(blob: Blob, filename: string): Promise<boolean> {
|
||||
const nav = typeof navigator === "undefined" ? undefined : navigator;
|
||||
if (!nav?.share || typeof File === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let file: File;
|
||||
try {
|
||||
file = new File([blob], filename, {
|
||||
type: blob.type || "application/octet-stream",
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nav.canShare === "function" &&
|
||||
!nav.canShare({ files: [file] })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await nav.share({ files: [file], title: filename });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
debug.warn("[VOT] navigator.share failed, fallback to download", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveBlobWithMobileShare(
|
||||
blob: Blob,
|
||||
filename: string,
|
||||
preferShare: boolean,
|
||||
): Promise<void> {
|
||||
if (preferShare) {
|
||||
const shared = await this.tryShareBlob(blob, filename);
|
||||
if (shared) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
|
||||
private restartAudioPlayer() {
|
||||
if (!this.videoHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoHandler.stopTranslate();
|
||||
this.videoHandler.createPlayer();
|
||||
void (async () => {
|
||||
await this.videoHandler?.stopTranslate();
|
||||
this.videoHandler?.createPlayer();
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,6 +274,9 @@ export class OverlayView {
|
|||
target: this.votButton.translateButton,
|
||||
content: localizationProvider.get("translateVideo"),
|
||||
position: this.votButton.tooltipPos,
|
||||
// Keep side-tooltip direction stable for the moved button (left/right)
|
||||
// so status/error text does not mirror to the opposite side.
|
||||
autoLayout: false,
|
||||
hidden: direction === "row",
|
||||
bordered: false,
|
||||
parentElement: this.votOverlayPortal,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const SETTINGS_EVENT_KEYS: Array<keyof SettingsViewEventMap> = [
|
|||
"change:autoSubtitles",
|
||||
"change:showVideoVolume",
|
||||
"change:audioBooster",
|
||||
"change:syncVolume",
|
||||
"change:useLivelyVoice",
|
||||
"change:subtitlesHighlightWords",
|
||||
"change:subtitlesSmartLayout",
|
||||
|
|
@ -310,6 +311,7 @@ export class SettingsView {
|
|||
dispatch?.(value);
|
||||
});
|
||||
}
|
||||
|
||||
initUI() {
|
||||
if (this.isInitialized()) {
|
||||
throw new Error("[VOT] SettingsView is already initialized");
|
||||
|
|
@ -425,13 +427,16 @@ export class SettingsView {
|
|||
this.autoSetVolumeSlider = new Slider({
|
||||
labelHtml: this.autoSetVolumeCheckbox.container,
|
||||
value: autoVolume,
|
||||
min: 0,
|
||||
});
|
||||
const syncVolumeEnabled = Boolean(this.data.syncVolume);
|
||||
this.autoSetVolumeSlider.disabled = !this.autoSetVolumeCheckbox.checked;
|
||||
this.smartDuckingCheckbox = new Checkbox({
|
||||
labelHtml: localizationProvider.get("smartDucking"),
|
||||
checked: this.data.enabledSmartDucking ?? true,
|
||||
});
|
||||
this.smartDuckingCheckbox.disabled = !this.autoSetVolumeCheckbox.checked;
|
||||
this.smartDuckingCheckbox.disabled =
|
||||
syncVolumeEnabled || !this.autoSetVolumeCheckbox.checked;
|
||||
this.showVideoVolumeSliderCheckbox = new Checkbox({
|
||||
labelHtml: localizationProvider.get("showVideoVolumeSlider"),
|
||||
checked: this.data.showVideoSlider,
|
||||
|
|
@ -959,7 +964,8 @@ export class SettingsView {
|
|||
apply: (checked) => {
|
||||
this.data.enabledAutoVolume = checked;
|
||||
this.autoSetVolumeSlider.disabled = !checked;
|
||||
this.smartDuckingCheckbox.disabled = !checked;
|
||||
this.smartDuckingCheckbox.disabled =
|
||||
!checked || Boolean(this.syncVolumeCheckbox?.checked);
|
||||
},
|
||||
storageKey: "enabledAutoVolume",
|
||||
readPersistedValue: () => this.data.enabledAutoVolume,
|
||||
|
|
@ -975,6 +981,7 @@ export class SettingsView {
|
|||
storageKey: "enabledSmartDucking",
|
||||
readPersistedValue: () => this.data.enabledSmartDucking,
|
||||
logLabel: "enabledSmartDucking",
|
||||
afterPersist: async () => this.videoHandler?.setupAudioSettings?.(),
|
||||
});
|
||||
this.bindPersistedSetting({
|
||||
control: this.autoSetVolumeSlider,
|
||||
|
|
@ -1015,10 +1022,18 @@ export class SettingsView {
|
|||
event: "change",
|
||||
apply: (checked) => {
|
||||
this.data.syncVolume = checked;
|
||||
this.autoSetVolumeSlider.disabled =
|
||||
!this.autoSetVolumeCheckbox?.checked;
|
||||
this.smartDuckingCheckbox.disabled =
|
||||
checked || !this.autoSetVolumeCheckbox?.checked;
|
||||
if (checked && this.smartDuckingCheckbox?.checked) {
|
||||
this.smartDuckingCheckbox.checked = false;
|
||||
}
|
||||
},
|
||||
storageKey: "syncVolume",
|
||||
readPersistedValue: () => this.data.syncVolume,
|
||||
logLabel: "syncVolume",
|
||||
dispatch: (checked) => this.events["change:syncVolume"].dispatch(checked),
|
||||
});
|
||||
this.bindPersistedSetting({
|
||||
control: this.downloadWithNameCheckbox,
|
||||
|
|
|
|||
|
|
@ -564,7 +564,12 @@ export class VideoObserver {
|
|||
this.onDocumentReady = onReady;
|
||||
document.addEventListener("readystatechange", onReady);
|
||||
if (typeof queueMicrotask === "function") queueMicrotask(onReady);
|
||||
else Promise.resolve().then(onReady);
|
||||
else {
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
onReady();
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
disable() {
|
||||
|
|
|
|||
799
src/utils/gm.ts
799
src/utils/gm.ts
|
|
@ -18,564 +18,114 @@ import debug from "./debug";
|
|||
import { isAbortError, makeAbortError } from "./errors";
|
||||
import { getHeaders } from "./utils";
|
||||
|
||||
const YANDEX_API_HOST = "api.browser.yandex.ru";
|
||||
|
||||
const scriptHandler =
|
||||
typeof GM_info !== "undefined" ? GM_info?.scriptHandler : undefined;
|
||||
|
||||
export const isProxyOnlyExtension =
|
||||
// The extension build provides a full GM_xmlhttpRequest implementation, so
|
||||
// we should not fall into the "proxy-only" compatibility mode.
|
||||
!(typeof IS_EXTENSION !== "undefined" && IS_EXTENSION) &&
|
||||
!!GM_info?.scriptHandler &&
|
||||
!nonProxyExtensions.includes(GM_info.scriptHandler);
|
||||
!!scriptHandler &&
|
||||
!nonProxyExtensions.includes(scriptHandler);
|
||||
export const isSupportGM4 = typeof GM !== "undefined";
|
||||
export const isUnsafeWindowAllowed = typeof unsafeWindow !== "undefined";
|
||||
export const isSupportGMXhr = typeof GM_xmlhttpRequest !== "undefined";
|
||||
|
||||
const DEFAULT_TIMEOUT = 15_000;
|
||||
const YANDEX_TRANSLATE_HOST = "api.browser.yandex.ru";
|
||||
const YANDEX_MIN_TIMEOUT = 30_000;
|
||||
const YANDEX_MAX_ATTEMPTS = 2;
|
||||
const RETRYABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
||||
let gmFetchRequestSeq = 0;
|
||||
const HEADER_DEBUG_LIMIT = 32;
|
||||
const BODY_OBJECT_KEYS_DEBUG_LIMIT = 12;
|
||||
|
||||
type RequestAttemptTrace = {
|
||||
requestId: string;
|
||||
attempt: number;
|
||||
effectiveTimeoutMs: number;
|
||||
};
|
||||
|
||||
const toUrlString = (u: string | URL | Request) => {
|
||||
if (typeof u === "string") {
|
||||
return u;
|
||||
}
|
||||
|
||||
if (u instanceof URL) {
|
||||
return u.href;
|
||||
}
|
||||
|
||||
return u.url;
|
||||
};
|
||||
|
||||
type NoFallbackError = {
|
||||
__gmFetchNoFallback?: boolean;
|
||||
};
|
||||
|
||||
function isNoFallbackError(err: unknown): err is NoFallbackError {
|
||||
return (
|
||||
!!err &&
|
||||
typeof err === "object" &&
|
||||
"__gmFetchNoFallback" in (err as Record<string, unknown>)
|
||||
);
|
||||
function shouldUseGmXhr(url: string): boolean {
|
||||
// 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 url.includes(YANDEX_API_HOST);
|
||||
}
|
||||
|
||||
function markNoFallbackError(err: unknown): void {
|
||||
if (!err || typeof err !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
(err as NoFallbackError).__gmFetchNoFallback = true;
|
||||
}
|
||||
|
||||
function isYandexTranslateRequest(urlStr: string): boolean {
|
||||
try {
|
||||
return new URL(urlStr).hostname === YANDEX_TRANSLATE_HOST;
|
||||
} catch {
|
||||
return urlStr.includes(YANDEX_TRANSLATE_HOST);
|
||||
}
|
||||
}
|
||||
|
||||
const forceGmXhr = (urlStr: string) => isYandexTranslateRequest(urlStr);
|
||||
|
||||
function getEffectiveTimeoutMs(urlStr: string, timeoutMs: number): number {
|
||||
if (!isYandexTranslateRequest(urlStr)) return timeoutMs;
|
||||
return Math.max(timeoutMs, YANDEX_MIN_TIMEOUT);
|
||||
}
|
||||
|
||||
function makeRequestId(): string {
|
||||
gmFetchRequestSeq += 1;
|
||||
return `gm_fetch_${Date.now()}_${gmFetchRequestSeq}`;
|
||||
}
|
||||
|
||||
function isRetryableError(
|
||||
err: unknown,
|
||||
signal?: AbortSignal,
|
||||
): err is Error | DOMException {
|
||||
if ((err as any)?.__gmFetchNoFallback) return false;
|
||||
if (signal?.aborted) return false;
|
||||
if (isAbortError(err)) return true;
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("timeout") ||
|
||||
msg.includes("network") ||
|
||||
msg.includes("failed to fetch") ||
|
||||
msg.includes("load failed") ||
|
||||
msg.includes("bridge port disconnected")
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRetryResponseStatus(
|
||||
async function gmXhrFetch(
|
||||
urlStr: string,
|
||||
status: number,
|
||||
attempt: number,
|
||||
maxAttempts: number,
|
||||
): boolean {
|
||||
return (
|
||||
isYandexTranslateRequest(urlStr) &&
|
||||
attempt < maxAttempts &&
|
||||
RETRYABLE_HTTP_STATUSES.has(status)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRetryRequestError(
|
||||
urlStr: string,
|
||||
err: unknown,
|
||||
attempt: number,
|
||||
maxAttempts: number,
|
||||
signal?: AbortSignal,
|
||||
): boolean {
|
||||
return (
|
||||
isYandexTranslateRequest(urlStr) &&
|
||||
attempt < maxAttempts &&
|
||||
isRetryableError(err, signal)
|
||||
);
|
||||
}
|
||||
|
||||
function getBodyDebugInfo(body: RequestInit["body"]) {
|
||||
if (body == null) return { type: "none", size: 0 };
|
||||
if (typeof body === "string") {
|
||||
return {
|
||||
type: "string",
|
||||
size: body.length,
|
||||
looksLikeObjectString: /^\[object [^\]]+\]$/.test(body.trim()),
|
||||
};
|
||||
}
|
||||
if (body instanceof URLSearchParams)
|
||||
return { type: "URLSearchParams", size: body.toString().length };
|
||||
if (body instanceof FormData) return { type: "FormData", size: -1 };
|
||||
if (body instanceof Blob) return { type: "Blob", size: body.size };
|
||||
if (body instanceof ArrayBuffer)
|
||||
return { type: "ArrayBuffer", size: body.byteLength };
|
||||
if (ArrayBuffer.isView(body))
|
||||
return {
|
||||
type: body.constructor?.name ?? "TypedArray",
|
||||
size: body.byteLength,
|
||||
};
|
||||
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
|
||||
return { type: "ReadableStream", size: -1 };
|
||||
}
|
||||
const objectTag = (() => {
|
||||
try {
|
||||
return Object.prototype.toString.call(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const ctor = (() => {
|
||||
try {
|
||||
const ctorName = (body as any)?.constructor?.name;
|
||||
return typeof ctorName === "string" ? ctorName : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const keys =
|
||||
body && typeof body === "object"
|
||||
? Object.keys(body as unknown as Record<string, unknown>).slice(
|
||||
0,
|
||||
BODY_OBJECT_KEYS_DEBUG_LIMIT,
|
||||
)
|
||||
: undefined;
|
||||
return { type: typeof body, size: -1, objectTag, ctor, keys };
|
||||
}
|
||||
|
||||
function getHeaderDebugInfo(headers: HeadersInit | undefined) {
|
||||
if (!headers) {
|
||||
return {
|
||||
headerCount: 0,
|
||||
headerNames: [] as string[],
|
||||
contentType: null as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
const names: string[] = [];
|
||||
let contentType: string | null = null;
|
||||
|
||||
const addHeader = (name: string, value: string) => {
|
||||
const normalized = String(name || "").trim();
|
||||
if (!normalized) return;
|
||||
names.push(normalized);
|
||||
if (normalized.toLowerCase() === "content-type" && contentType === null) {
|
||||
contentType = String(value);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (headers instanceof Headers) {
|
||||
for (const [name, value] of headers.entries()) addHeader(name, value);
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const pair of headers) {
|
||||
if (!Array.isArray(pair) || pair.length < 2) continue;
|
||||
addHeader(String(pair[0]), String(pair[1]));
|
||||
}
|
||||
} else {
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
addHeader(name, String(value));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
headerCount: -1,
|
||||
headerNames: [] as string[],
|
||||
contentType: null as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
headerCount: names.length,
|
||||
headerNames: names.slice(0, HEADER_DEBUG_LIMIT),
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRawHeaders(raw = "") {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const m = /^([\w!#$%&'*+.^`|~-]+)\s*:\s*(.*)$/.exec(line);
|
||||
if (m) out[m[1].toLowerCase()] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function nativeFetchWithTimeout(
|
||||
url: string | URL | Request,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
trace?: RequestAttemptTrace,
|
||||
timeout: number,
|
||||
fetchOptions: Omit<FetchOpts, "timeout">,
|
||||
): Promise<Response> {
|
||||
const { signal, didTimeout, cleanup } = createTimeoutSignal(
|
||||
timeoutMs,
|
||||
init.signal,
|
||||
);
|
||||
|
||||
try {
|
||||
debug.log("[VOT][GM_fetch][native] start", {
|
||||
url: toUrlString(url),
|
||||
method: init.method ?? "GET",
|
||||
timeoutMs,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
effectiveTimeoutMs: trace?.effectiveTimeoutMs,
|
||||
credentials: init.credentials ?? "same-origin",
|
||||
mode: init.mode ?? "cors",
|
||||
...getHeaderDebugInfo(init.headers),
|
||||
...getBodyDebugInfo(init.body),
|
||||
});
|
||||
return await fetch(url, { ...init, signal });
|
||||
} catch (e) {
|
||||
debug.error("[VOT][GM_fetch][native] error", {
|
||||
url: toUrlString(url),
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
effectiveTimeoutMs: trace?.effectiveTimeoutMs,
|
||||
isAbortError: isAbortError(e),
|
||||
signalAborted: !!init.signal?.aborted,
|
||||
didTimeout: didTimeout(),
|
||||
});
|
||||
if (isAbortError(e) && !didTimeout() && init.signal?.aborted) {
|
||||
markNoFallbackError(e);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function gmXhrFetch(
|
||||
urlStr: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
fetchErrForAbortReuse?: unknown,
|
||||
trace?: RequestAttemptTrace,
|
||||
): Promise<Response> {
|
||||
const headers = getHeaders(init.headers);
|
||||
const method = (init.method ?? "GET") as HttpMethod;
|
||||
const supportsReadableStream = typeof ReadableStream !== "undefined";
|
||||
// Yandex API requests are small protobuf payloads and don't benefit from
|
||||
// progressive streaming. Using a single terminal payload avoids races where
|
||||
// late abort/timeout events can poison the response body read.
|
||||
const useProgressStream = supportsReadableStream && !forceGmXhr(urlStr);
|
||||
|
||||
// Align cookie behavior with fetch():
|
||||
// - credentials: 'include' => send cookies/auth headers cross-site
|
||||
// - credentials: 'omit' => do not send cookies
|
||||
// Many userscript managers support `withCredentials` / `anonymous`.
|
||||
// Unsupported flags are ignored by engines that don't implement them.
|
||||
const credentials = init.credentials;
|
||||
const withCredentials = credentials === "include" ? true : undefined;
|
||||
const anonymous = credentials === "omit" ? true : undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let responseResolved = false;
|
||||
let req: { abort?(): void } | undefined;
|
||||
|
||||
let streamCtrl: ReadableStreamDefaultController<Uint8Array> | undefined;
|
||||
let seenBytes = 0;
|
||||
|
||||
const cleanup = () => init.signal?.removeEventListener("abort", onAbort);
|
||||
|
||||
const resolveResponse = (
|
||||
body: BodyInit | null,
|
||||
status: number,
|
||||
statusText: string,
|
||||
rawHeaders?: string,
|
||||
) => {
|
||||
if (responseResolved) return;
|
||||
responseResolved = true;
|
||||
debug.log("[VOT][GM_fetch][xhr] resolve", {
|
||||
url: urlStr,
|
||||
method,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
effectiveTimeoutMs: trace?.effectiveTimeoutMs,
|
||||
status,
|
||||
statusText,
|
||||
hasBody: body != null,
|
||||
});
|
||||
resolve(
|
||||
new Response(body, {
|
||||
status,
|
||||
statusText,
|
||||
headers: parseRawHeaders(rawHeaders),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
if (responseResolved && !useProgressStream) {
|
||||
cleanup();
|
||||
debug.warn("[VOT][GM_fetch][xhr] late terminal event after resolve", {
|
||||
url: urlStr,
|
||||
method,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
effectiveTimeoutMs: trace?.effectiveTimeoutMs,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
debug.error("[VOT][GM_fetch][xhr] fail", {
|
||||
url: urlStr,
|
||||
method,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
effectiveTimeoutMs: trace?.effectiveTimeoutMs,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (useProgressStream) streamCtrl?.error(err);
|
||||
if (!responseResolved) reject(err);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
req?.abort?.();
|
||||
const abortErr =
|
||||
fetchErrForAbortReuse && isAbortError(fetchErrForAbortReuse)
|
||||
? (fetchErrForAbortReuse as any)
|
||||
: makeAbortError();
|
||||
fail(abortErr);
|
||||
};
|
||||
|
||||
if (init.signal) {
|
||||
if (init.signal.aborted) return onAbort();
|
||||
init.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
debug.log("[VOT][GM_fetch][xhr] start", {
|
||||
url: urlStr,
|
||||
method,
|
||||
timeoutMs,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
effectiveTimeoutMs: trace?.effectiveTimeoutMs,
|
||||
responseType: "arraybuffer",
|
||||
withCredentials: withCredentials ?? false,
|
||||
anonymous: anonymous ?? false,
|
||||
useProgressStream,
|
||||
...getHeaderDebugInfo(headers),
|
||||
...getBodyDebugInfo(init.body),
|
||||
});
|
||||
|
||||
if (
|
||||
typeof init.body === "string" &&
|
||||
/^\[object [^\]]+\]$/.test(init.body.trim())
|
||||
) {
|
||||
const contentType = getHeaderDebugInfo(headers).contentType;
|
||||
debug.warn("[VOT][GM_fetch][xhr] suspicious body string before request", {
|
||||
url: urlStr,
|
||||
method,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
contentType,
|
||||
body: init.body,
|
||||
});
|
||||
}
|
||||
|
||||
const stream = useProgressStream
|
||||
? new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
streamCtrl = controller;
|
||||
},
|
||||
cancel() {
|
||||
req?.abort?.();
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Pushes new bytes into the ReadableStream.
|
||||
*
|
||||
* Userscript managers typically expose the *cumulative* `response`
|
||||
* ArrayBuffer on progress events. Our extension shim can emit either:
|
||||
* - `progress.response`: cumulative ArrayBuffer (TM-like)
|
||||
* - `progress.chunk`: incremental ArrayBuffer chunk (more efficient)
|
||||
*/
|
||||
const pushNewBytes = (evtOrResp: any) => {
|
||||
if (!useProgressStream || !streamCtrl) return;
|
||||
|
||||
// Extension-optimized mode: pass only the incremental chunk.
|
||||
const chunk = evtOrResp?.chunk;
|
||||
if (chunk && typeof chunk !== "string") {
|
||||
const u8 = new Uint8Array(chunk as ArrayBuffer);
|
||||
if (u8.byteLength) {
|
||||
streamCtrl.enqueue(u8);
|
||||
seenBytes += u8.byteLength;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Classic GM/TM mode: pass the cumulative response buffer.
|
||||
const resp = evtOrResp?.response ?? evtOrResp;
|
||||
if (!resp || typeof resp === "string") return;
|
||||
|
||||
const u8 = new Uint8Array(resp as ArrayBuffer);
|
||||
if (u8.byteLength <= seenBytes) return;
|
||||
|
||||
streamCtrl.enqueue(u8.slice(seenBytes));
|
||||
seenBytes = u8.byteLength;
|
||||
};
|
||||
const headers = getHeaders(fetchOptions.headers);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const gmXhr =
|
||||
typeof GM_xmlhttpRequest !== "undefined"
|
||||
? GM_xmlhttpRequest
|
||||
: (globalThis as any).GM_xmlhttpRequest;
|
||||
|
||||
if (typeof gmXhr !== "function") {
|
||||
throw new TypeError("GM_xmlhttpRequest is not available");
|
||||
reject(new TypeError("GM_xmlhttpRequest is not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
req = gmXhr({
|
||||
method,
|
||||
let settled = false;
|
||||
let onAbort: (() => void) | undefined;
|
||||
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: (fetchOptions.method || "GET") as HttpMethod,
|
||||
url: urlStr,
|
||||
responseType: "blob" as any,
|
||||
data: fetchOptions.body as any,
|
||||
timeout,
|
||||
headers,
|
||||
data: init.body as any,
|
||||
withCredentials,
|
||||
anonymous,
|
||||
timeout: timeoutMs,
|
||||
responseType: "arraybuffer" as any,
|
||||
|
||||
onprogress: useProgressStream
|
||||
? (p) => {
|
||||
const progress = p as {
|
||||
loaded?: number;
|
||||
total?: number;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
responseHeaders?: string;
|
||||
response?: ArrayBuffer | string | null;
|
||||
};
|
||||
debug.log("[VOT][GM_fetch][xhr] progress", {
|
||||
url: urlStr,
|
||||
method,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
loaded: progress.loaded ?? null,
|
||||
total: progress.total ?? null,
|
||||
status: progress.status ?? null,
|
||||
});
|
||||
if (!stream) return;
|
||||
if (!responseResolved) {
|
||||
resolveResponse(
|
||||
stream,
|
||||
progress.status,
|
||||
progress.statusText,
|
||||
progress.responseHeaders,
|
||||
);
|
||||
onload: (resp) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupAbort();
|
||||
const responseHeaders = String(resp.responseHeaders || "")
|
||||
.split(/\r?\n/)
|
||||
.reduce<Record<string, string>>((acc, line) => {
|
||||
const [, key, value] = line.match(/^([\w-]+):\s*(.+)$/) || [];
|
||||
if (key) {
|
||||
acc[key] = value;
|
||||
}
|
||||
pushNewBytes(progress);
|
||||
}
|
||||
: undefined,
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
onload: (r) => {
|
||||
cleanup();
|
||||
debug.log("[VOT][GM_fetch][xhr] onload", {
|
||||
url: urlStr,
|
||||
method,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
effectiveTimeoutMs: trace?.effectiveTimeoutMs,
|
||||
status: r.status,
|
||||
statusText: r.statusText,
|
||||
responseType: "arraybuffer",
|
||||
responseSize:
|
||||
r.response && typeof r.response !== "string"
|
||||
? ((r.response as ArrayBuffer).byteLength ?? null)
|
||||
: null,
|
||||
const response = new Response(resp.response as Blob, {
|
||||
status: resp.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
|
||||
if (useProgressStream) {
|
||||
if (!responseResolved) {
|
||||
if (!stream) return;
|
||||
resolveResponse(stream, r.status, r.statusText, r.responseHeaders);
|
||||
}
|
||||
pushNewBytes(r);
|
||||
streamCtrl?.close();
|
||||
return;
|
||||
}
|
||||
|
||||
resolveResponse(
|
||||
r.response && typeof r.response !== "string"
|
||||
? (r.response as ArrayBuffer)
|
||||
: null,
|
||||
r.status,
|
||||
r.statusText,
|
||||
r.responseHeaders,
|
||||
);
|
||||
},
|
||||
|
||||
onerror: fail,
|
||||
ontimeout: () => {
|
||||
debug.error("[VOT][GM_fetch][xhr] timeout", {
|
||||
url: urlStr,
|
||||
method,
|
||||
timeoutMs,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
effectiveTimeoutMs: trace?.effectiveTimeoutMs,
|
||||
// Response has empty url by default (readonly).
|
||||
// Keep parity with classic fetch by exposing final URL.
|
||||
Object.defineProperty(response, "url", {
|
||||
value: resp.finalUrl ?? "",
|
||||
});
|
||||
fail(new Error("GM_xmlhttpRequest timeout"));
|
||||
},
|
||||
onabort: () => {
|
||||
debug.warn("[VOT][GM_fetch][xhr] abort", {
|
||||
url: urlStr,
|
||||
method,
|
||||
requestId: trace?.requestId,
|
||||
attempt: trace?.attempt,
|
||||
effectiveTimeoutMs: trace?.effectiveTimeoutMs,
|
||||
});
|
||||
fail(makeAbortError());
|
||||
|
||||
resolve(response);
|
||||
},
|
||||
ontimeout: () => failOnce(new Error("Timeout")),
|
||||
onerror: (error: unknown) => failOnce(new Error(String(error))),
|
||||
onabort: () => failOnce(makeAbortError()),
|
||||
});
|
||||
|
||||
onAbort = () => {
|
||||
try {
|
||||
request?.abort?.();
|
||||
} catch {
|
||||
// ignore abort races
|
||||
}
|
||||
failOnce(makeAbortError());
|
||||
};
|
||||
|
||||
if (fetchOptions.signal) {
|
||||
if (fetchOptions.signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
fetchOptions.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -583,172 +133,35 @@ export async function GM_fetch(
|
|||
url: string | URL | Request,
|
||||
opts: FetchOpts = {},
|
||||
): Promise<Response> {
|
||||
const { timeout = DEFAULT_TIMEOUT, ...init } = opts;
|
||||
const urlStr = toUrlString(url);
|
||||
const { timeout = 15_000, ...fetchOptions } = opts;
|
||||
const urlStr =
|
||||
typeof url === "string" ? url : url instanceof URL ? url.href : url.url;
|
||||
|
||||
const shouldForce = isSupportGMXhr && forceGmXhr(urlStr);
|
||||
const effectiveTimeoutMs = getEffectiveTimeoutMs(urlStr, timeout);
|
||||
const requestId = makeRequestId();
|
||||
const maxAttempts = isYandexTranslateRequest(urlStr)
|
||||
? YANDEX_MAX_ATTEMPTS
|
||||
: 1;
|
||||
const requestMeta = {
|
||||
url: urlStr,
|
||||
method: init.method ?? "GET",
|
||||
timeoutMs: timeout,
|
||||
effectiveTimeoutMs,
|
||||
requestId,
|
||||
shouldForceGmXhr: shouldForce,
|
||||
canUseGmXhr: isSupportGMXhr,
|
||||
...getHeaderDebugInfo(init.headers),
|
||||
...getBodyDebugInfo(init.body),
|
||||
};
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const trace: RequestAttemptTrace = {
|
||||
requestId,
|
||||
attempt,
|
||||
effectiveTimeoutMs,
|
||||
};
|
||||
debug.log("[VOT][GM_fetch] start", { ...requestMeta, attempt });
|
||||
|
||||
try {
|
||||
if (!shouldForce) {
|
||||
const res = await nativeFetchWithTimeout(
|
||||
url,
|
||||
init,
|
||||
effectiveTimeoutMs,
|
||||
trace,
|
||||
);
|
||||
debug.log("[VOT][GM_fetch] native response", {
|
||||
...requestMeta,
|
||||
attempt,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
type: res.type,
|
||||
redirected: res.redirected,
|
||||
});
|
||||
|
||||
// If the caller requested `no-cors` (or the browser downgraded the
|
||||
// request), fetch() can return an opaque response (status 0) which
|
||||
// cannot be read. In the userscript environment GM_xmlhttpRequest is
|
||||
// commonly used as a CORS-bypass for media downloads; mirror that
|
||||
// behaviour here to avoid false "download failed" paths.
|
||||
if (
|
||||
isSupportGMXhr &&
|
||||
(res.type === "opaque" ||
|
||||
res.type === "opaqueredirect" ||
|
||||
res.status === 0)
|
||||
) {
|
||||
debug.log(
|
||||
"GM_fetch got an opaque/blocked response; retrying via GM_xmlhttpRequest",
|
||||
{ ...requestMeta, attempt },
|
||||
);
|
||||
const xhrRes = await gmXhrFetch(
|
||||
urlStr,
|
||||
init,
|
||||
effectiveTimeoutMs,
|
||||
undefined,
|
||||
trace,
|
||||
);
|
||||
if (
|
||||
shouldRetryResponseStatus(
|
||||
urlStr,
|
||||
xhrRes.status,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
)
|
||||
) {
|
||||
debug.warn("[VOT][GM_fetch] retrying after retryable XHR status", {
|
||||
...requestMeta,
|
||||
attempt,
|
||||
status: xhrRes.status,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return xhrRes;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldRetryResponseStatus(urlStr, res.status, attempt, maxAttempts)
|
||||
) {
|
||||
debug.warn("[VOT][GM_fetch] retrying after retryable native status", {
|
||||
...requestMeta,
|
||||
attempt,
|
||||
status: res.status,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
throw new Error("Force GM_xmlhttpRequest");
|
||||
} catch (e) {
|
||||
if (isNoFallbackError(e) && e.__gmFetchNoFallback) throw e;
|
||||
if (!isSupportGMXhr) {
|
||||
if (
|
||||
shouldRetryRequestError(urlStr, e, attempt, maxAttempts, init.signal)
|
||||
) {
|
||||
debug.warn("[VOT][GM_fetch] retrying after native error", {
|
||||
...requestMeta,
|
||||
attempt,
|
||||
reason: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
lastError = e;
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
debug.warn("[VOT][GM_fetch] fallback to GM_xmlhttpRequest", {
|
||||
...requestMeta,
|
||||
attempt,
|
||||
reason: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await gmXhrFetch(
|
||||
urlStr,
|
||||
init,
|
||||
effectiveTimeoutMs,
|
||||
e,
|
||||
trace,
|
||||
);
|
||||
if (
|
||||
shouldRetryResponseStatus(urlStr, res.status, attempt, maxAttempts)
|
||||
) {
|
||||
debug.warn("[VOT][GM_fetch] retrying after retryable XHR status", {
|
||||
...requestMeta,
|
||||
attempt,
|
||||
status: res.status,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
} catch (error_) {
|
||||
if (
|
||||
shouldRetryRequestError(
|
||||
urlStr,
|
||||
error_,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
init.signal,
|
||||
)
|
||||
) {
|
||||
debug.warn("[VOT][GM_fetch] retrying after XHR error", {
|
||||
...requestMeta,
|
||||
attempt,
|
||||
reason: error_ instanceof Error ? error_.message : String(error_),
|
||||
});
|
||||
lastError = error_;
|
||||
continue;
|
||||
}
|
||||
throw error_;
|
||||
}
|
||||
}
|
||||
if (shouldUseGmXhr(urlStr)) {
|
||||
debug.log("GM_fetch: routing request via GM_xmlhttpRequest", {
|
||||
host: YANDEX_API_HOST,
|
||||
url: urlStr,
|
||||
});
|
||||
return await gmXhrFetch(urlStr, timeout, fetchOptions);
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("GM_fetch failed");
|
||||
const { signal, cleanup } = createTimeoutSignal(timeout, fetchOptions.signal);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal,
|
||||
...fetchOptions,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || fetchOptions.signal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
// If fetch fails, retry via GM_xmlhttpRequest.
|
||||
debug.log(
|
||||
"GM_fetch preventing CORS by GM_xmlhttpRequest",
|
||||
(err as Error)?.message ?? String(err),
|
||||
);
|
||||
return await gmXhrFetch(urlStr, timeout, fetchOptions);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ function getDefaultRuntime(): IntervalIdleRuntime {
|
|||
queueMicrotask(fn);
|
||||
return;
|
||||
}
|
||||
Promise.resolve().then(fn);
|
||||
void (async () => {
|
||||
await Promise.resolve();
|
||||
fn();
|
||||
})();
|
||||
},
|
||||
isDocumentHidden: () =>
|
||||
typeof document !== "undefined" && typeof document.hidden === "boolean"
|
||||
|
|
|
|||
95
src/utils/volumeLink.ts
Normal file
95
src/utils/volumeLink.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { clampInt, clampPercentInt } from "./volume";
|
||||
|
||||
export type VolumeLinkState = {
|
||||
initialized: boolean;
|
||||
lastVideoPercent: number;
|
||||
lastTranslationPercent: number;
|
||||
};
|
||||
|
||||
export type VolumeLinkDirection = "translation" | "video";
|
||||
|
||||
type ApplyVolumeLinkDeltaInput = {
|
||||
state: VolumeLinkState;
|
||||
fromType: VolumeLinkDirection;
|
||||
newVolume: number;
|
||||
currentVideo: number;
|
||||
currentTranslation: number;
|
||||
translationMin: number;
|
||||
translationMax: number;
|
||||
};
|
||||
|
||||
export type ApplyVolumeLinkDeltaResult = {
|
||||
nextVideo?: number;
|
||||
nextTranslation?: number;
|
||||
};
|
||||
|
||||
export function syncVideoLinkSnapshot(
|
||||
state: VolumeLinkState,
|
||||
volumePercent: number,
|
||||
): void {
|
||||
const normalized = clampPercentInt(volumePercent);
|
||||
state.lastVideoPercent = normalized;
|
||||
}
|
||||
|
||||
export function syncTranslationLinkSnapshot(
|
||||
state: VolumeLinkState,
|
||||
volumePercent: number,
|
||||
): void {
|
||||
const numeric = Number(volumePercent);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return;
|
||||
}
|
||||
const normalized = Math.max(0, Math.round(numeric));
|
||||
state.lastTranslationPercent = normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies delta-based volume linking and mutates `state` in place.
|
||||
*
|
||||
* Rules:
|
||||
* - "video" initiator: translation changes by the same delta as video.
|
||||
* - "translation" initiator: video changes by the same delta as translation.
|
||||
*
|
||||
* This preserves relative offset between sliders (until clamped by bounds),
|
||||
* instead of forcing a 1:1 mirror.
|
||||
*/
|
||||
export function applyVolumeLinkDelta({
|
||||
state,
|
||||
fromType,
|
||||
newVolume,
|
||||
currentVideo,
|
||||
currentTranslation,
|
||||
translationMin,
|
||||
translationMax,
|
||||
}: ApplyVolumeLinkDeltaInput): ApplyVolumeLinkDeltaResult {
|
||||
if (!state.initialized) {
|
||||
state.lastVideoPercent = Number(currentVideo);
|
||||
state.lastTranslationPercent = Number(currentTranslation);
|
||||
state.initialized = true;
|
||||
}
|
||||
|
||||
if (fromType === "video") {
|
||||
const normalizedVideo = clampPercentInt(newVolume);
|
||||
const delta = normalizedVideo - clampPercentInt(state.lastVideoPercent);
|
||||
state.lastVideoPercent = normalizedVideo;
|
||||
const nextTranslation = clampInt(
|
||||
state.lastTranslationPercent + delta,
|
||||
translationMin,
|
||||
translationMax,
|
||||
);
|
||||
state.lastTranslationPercent = nextTranslation;
|
||||
return { nextTranslation };
|
||||
}
|
||||
|
||||
const normalizedTranslation = clampInt(
|
||||
Number.isFinite(newVolume) ? newVolume : currentTranslation,
|
||||
translationMin,
|
||||
translationMax,
|
||||
);
|
||||
const delta = normalizedTranslation - state.lastTranslationPercent;
|
||||
state.lastTranslationPercent = normalizedTranslation;
|
||||
const nextVideo = clampPercentInt(state.lastVideoPercent + delta);
|
||||
state.lastVideoPercent = nextVideo;
|
||||
|
||||
return { nextVideo };
|
||||
}
|
||||
|
|
@ -79,6 +79,9 @@ function syncAudioTranslationVolumeFromVideo(
|
|||
if (options.skipYouTubeLikeHosts && isYouTubeLikeHost(self.site.host)) {
|
||||
return;
|
||||
}
|
||||
// While smart ducking is active, the script drives video volume itself.
|
||||
// Ignore observer-driven sync to avoid feedback loops/jitter.
|
||||
if (typeof self.smartVolumeDuckingInterval === "number") return;
|
||||
if (!self.data?.syncVolume || !self.audioPlayer?.player?.src) return;
|
||||
if (self.isLikelyInternalVideoVolumeChange(videoPercent)) return;
|
||||
self.syncVolumeWrapper("video", videoPercent);
|
||||
|
|
@ -373,7 +376,7 @@ function bindVideoLifecycleEvents(ctx: ExtraEventsContext): void {
|
|||
if (self.site.host === "youtube" && !self.site.additionalData) {
|
||||
add(document, "yt-page-data-updated", () => {
|
||||
debug.log("yt-page-data-updated");
|
||||
if (!globalThis.location.pathname.includes("/shorts/")) return;
|
||||
if (!globalThis.location.pathname.startsWith("/shorts/")) return;
|
||||
queueSetCanPlay();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,15 +42,255 @@ type StopSmartVolumeDuckingOptions = {
|
|||
};
|
||||
|
||||
type AudioPlayerLike = {
|
||||
getAudioRms?: () => number | undefined;
|
||||
audio?: HTMLMediaElement;
|
||||
audioElement?: HTMLMediaElement;
|
||||
gainNode?: AudioNode;
|
||||
audioSource?: AudioNode;
|
||||
mediaElementSource?: AudioNode;
|
||||
};
|
||||
|
||||
type SmartDuckingAnalyserState = {
|
||||
analyser?: AnalyserNode;
|
||||
analyserFloatData?: Float32Array<ArrayBuffer>;
|
||||
analyserData?: Uint8Array<ArrayBuffer>;
|
||||
getMediaElement?: () => HTMLMediaElement | undefined;
|
||||
audio?: HTMLMediaElement;
|
||||
audioElement?: HTMLMediaElement;
|
||||
connectedInputNode?: AudioNode;
|
||||
mediaElement?: HTMLMediaElement;
|
||||
audioContext?: AudioContext;
|
||||
createdMediaSource?: MediaElementAudioSourceNode;
|
||||
mediaSourceCreationFailed?: boolean;
|
||||
};
|
||||
|
||||
const smartDuckingAnalyserState = new WeakMap<
|
||||
VideoHandler,
|
||||
SmartDuckingAnalyserState
|
||||
>();
|
||||
|
||||
function isAudioNode(node: unknown): node is AudioNode {
|
||||
if (!node || typeof node !== "object") return false;
|
||||
const candidate = node as { connect?: unknown; disconnect?: unknown };
|
||||
return (
|
||||
typeof candidate.connect === "function" &&
|
||||
typeof candidate.disconnect === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function getPlayerMediaElement(
|
||||
player?: AudioPlayerLike,
|
||||
): HTMLMediaElement | undefined {
|
||||
return player?.audio ?? player?.audioElement;
|
||||
}
|
||||
|
||||
async function resumePlayerAudioContextIfNeeded(
|
||||
handler: VideoHandler,
|
||||
): Promise<"not-needed" | "resumed" | "timeout" | "failed"> {
|
||||
const ctx = handler.audioPlayer?.audioContext;
|
||||
if (!ctx || ctx.state !== "suspended") return "not-needed";
|
||||
|
||||
const RESUME_TIMEOUT_MS = 1500;
|
||||
|
||||
const resumePromise = (async (): Promise<"resumed" | "failed"> => {
|
||||
try {
|
||||
await ctx.resume();
|
||||
return "resumed";
|
||||
} catch (err) {
|
||||
debug.log("[updateTranslation] Failed to resume AudioContext", err);
|
||||
return "failed";
|
||||
}
|
||||
})();
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<"timeout">((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve("timeout"), RESUME_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
const result = await Promise.race([resumePromise, timeoutPromise]);
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (result === "resumed") {
|
||||
debug.log("[updateTranslation] AudioContext resumed");
|
||||
} else if (result === "timeout") {
|
||||
debug.log("[updateTranslation] AudioContext resume timeout");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function rollbackStaleAppliedSourceIfStillCurrent(
|
||||
handler: VideoHandler,
|
||||
appliedSourceUrl: string | null,
|
||||
): Promise<void> {
|
||||
if (!appliedSourceUrl || !handler.audioPlayer) return;
|
||||
|
||||
const player = handler.audioPlayer.player;
|
||||
const currentSource = String(player.currentSrc || player.src || "");
|
||||
const normalizedCurrentUrl = handler.proxifyAudio(
|
||||
handler.unproxifyAudio(currentSource),
|
||||
);
|
||||
const normalizedAppliedUrl = handler.proxifyAudio(
|
||||
handler.unproxifyAudio(appliedSourceUrl),
|
||||
);
|
||||
if (normalizedCurrentUrl !== normalizedAppliedUrl) return;
|
||||
|
||||
try {
|
||||
await player.clear();
|
||||
player.src = "";
|
||||
debug.log("[updateTranslation] cleared stale partially-applied source");
|
||||
} catch (err) {
|
||||
debug.log("[updateTranslation] failed to clear stale source", err);
|
||||
}
|
||||
}
|
||||
|
||||
function getSmartDuckingAudioContext(
|
||||
handler: VideoHandler,
|
||||
): AudioContext | undefined {
|
||||
return handler.audioPlayer?.audioContext ?? handler.audioContext;
|
||||
}
|
||||
|
||||
function disconnectSmartDuckingAnalyser(
|
||||
state: SmartDuckingAnalyserState,
|
||||
): void {
|
||||
if (state.connectedInputNode && state.analyser) {
|
||||
try {
|
||||
state.connectedInputNode.disconnect(state.analyser);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
state.connectedInputNode = undefined;
|
||||
|
||||
if (state.createdMediaSource) {
|
||||
try {
|
||||
state.createdMediaSource.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
state.createdMediaSource = undefined;
|
||||
|
||||
if (state.analyser) {
|
||||
try {
|
||||
state.analyser.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
state.analyser = undefined;
|
||||
state.analyserFloatData = undefined;
|
||||
state.analyserData = undefined;
|
||||
state.mediaElement = undefined;
|
||||
state.audioContext = undefined;
|
||||
state.mediaSourceCreationFailed = false;
|
||||
}
|
||||
|
||||
function releaseSmartDuckingAnalyser(handler: VideoHandler): void {
|
||||
const state = smartDuckingAnalyserState.get(handler);
|
||||
if (!state) return;
|
||||
|
||||
disconnectSmartDuckingAnalyser(state);
|
||||
smartDuckingAnalyserState.delete(handler);
|
||||
}
|
||||
|
||||
function resolveSmartDuckingInputNode(
|
||||
player: AudioPlayerLike | undefined,
|
||||
media: HTMLMediaElement,
|
||||
audioContext: AudioContext,
|
||||
state: SmartDuckingAnalyserState,
|
||||
): AudioNode | undefined {
|
||||
if (isAudioNode(player?.gainNode)) return player.gainNode;
|
||||
if (isAudioNode(player?.audioSource)) return player.audioSource;
|
||||
if (isAudioNode(player?.mediaElementSource)) return player.mediaElementSource;
|
||||
|
||||
if (
|
||||
state.mediaSourceCreationFailed &&
|
||||
state.mediaElement === media &&
|
||||
state.audioContext === audioContext
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
state.createdMediaSource &&
|
||||
state.mediaElement === media &&
|
||||
state.audioContext === audioContext
|
||||
) {
|
||||
return state.createdMediaSource;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = audioContext.createMediaElementSource(media);
|
||||
state.createdMediaSource = source;
|
||||
state.mediaSourceCreationFailed = false;
|
||||
return source;
|
||||
} catch (err) {
|
||||
state.mediaSourceCreationFailed = true;
|
||||
debug.log("[SmartDucking] failed to create media source", err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSmartDuckingAnalyser(
|
||||
handler: VideoHandler,
|
||||
player: AudioPlayerLike | undefined,
|
||||
media: HTMLMediaElement,
|
||||
): { analyser: AnalyserNode; state: SmartDuckingAnalyserState } | undefined {
|
||||
const audioContext = getSmartDuckingAudioContext(handler);
|
||||
if (!audioContext) return undefined;
|
||||
|
||||
let state = smartDuckingAnalyserState.get(handler);
|
||||
if (!state) {
|
||||
state = {};
|
||||
smartDuckingAnalyserState.set(handler, state);
|
||||
}
|
||||
|
||||
if (
|
||||
(state.mediaElement && state.mediaElement !== media) ||
|
||||
(state.audioContext && state.audioContext !== audioContext)
|
||||
) {
|
||||
disconnectSmartDuckingAnalyser(state);
|
||||
}
|
||||
|
||||
state.mediaElement = media;
|
||||
state.audioContext = audioContext;
|
||||
|
||||
if (!state.analyser) {
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
state.analyser = analyser;
|
||||
}
|
||||
|
||||
const inputNode = resolveSmartDuckingInputNode(
|
||||
player,
|
||||
media,
|
||||
audioContext,
|
||||
state,
|
||||
);
|
||||
const analyser = state.analyser;
|
||||
if (!inputNode || !analyser) return undefined;
|
||||
|
||||
if (state.connectedInputNode !== inputNode) {
|
||||
if (state.connectedInputNode) {
|
||||
try {
|
||||
state.connectedInputNode.disconnect(analyser);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
inputNode.connect(analyser);
|
||||
state.connectedInputNode = inputNode;
|
||||
} catch (err) {
|
||||
debug.log("[SmartDucking] failed to connect analyser", err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return { analyser, state };
|
||||
}
|
||||
|
||||
function readSmartDuckingRuntime(handler: VideoHandler): SmartDuckingRuntime {
|
||||
return {
|
||||
isDucked: handler.smartVolumeIsDucked,
|
||||
|
|
@ -111,6 +351,8 @@ export function stopSmartVolumeDucking(
|
|||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
releaseSmartDuckingAnalyser(handler);
|
||||
writeSmartDuckingRuntime(handler, resetSmartDuckingRuntime());
|
||||
}
|
||||
|
||||
|
|
@ -118,38 +360,59 @@ function startSmartVolumeDucking(handler: VideoHandler): void {
|
|||
if (typeof globalThis === "undefined") return;
|
||||
if (typeof handler.smartVolumeDuckingInterval === "number") return;
|
||||
|
||||
writeSmartDuckingRuntime(
|
||||
handler,
|
||||
initSmartDuckingRuntime(handler.getVideoVolume()),
|
||||
);
|
||||
const currentVideoVolume = handler.getVideoVolume();
|
||||
const baseline =
|
||||
typeof handler.smartVolumeDuckingBaseline === "number"
|
||||
? handler.smartVolumeDuckingBaseline
|
||||
: currentVideoVolume;
|
||||
|
||||
const runtime = initSmartDuckingRuntime(baseline);
|
||||
if (
|
||||
Number.isFinite(currentVideoVolume) &&
|
||||
Number.isFinite(baseline) &&
|
||||
currentVideoVolume <
|
||||
baseline - SMART_DUCKING_DEFAULT_CONFIG.externalBaselineDelta01
|
||||
) {
|
||||
// Resuming Smart mode from constant ducking: keep baseline untouched and
|
||||
// continue from the already ducked state.
|
||||
const now =
|
||||
typeof performance !== "undefined" &&
|
||||
typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
runtime.isDucked = true;
|
||||
runtime.speechGateOpen = true;
|
||||
runtime.lastApplied = currentVideoVolume;
|
||||
runtime.lastSoundAt = now;
|
||||
}
|
||||
|
||||
writeSmartDuckingRuntime(handler, runtime);
|
||||
|
||||
handler.smartVolumeDuckingInterval = globalThis.setInterval(() => {
|
||||
smartDuckingTick(handler);
|
||||
}, SMART_DUCKING_DEFAULT_CONFIG.tickMs);
|
||||
}
|
||||
|
||||
function getTranslatedAudioRms(this: VideoHandler): number | undefined {
|
||||
const player = this.audioPlayer?.player as unknown as
|
||||
function getTranslatedAudioRms(
|
||||
handler: VideoHandler,
|
||||
media: HTMLMediaElement,
|
||||
): number | undefined {
|
||||
const player = handler.audioPlayer?.player as unknown as
|
||||
| AudioPlayerLike
|
||||
| undefined;
|
||||
const analyserBundle = ensureSmartDuckingAnalyser(handler, player, media);
|
||||
if (!analyserBundle) return undefined;
|
||||
|
||||
// Preferred: use the helper implemented in chaimu (if available).
|
||||
const rms = player?.getAudioRms?.();
|
||||
if (typeof rms === "number" && Number.isFinite(rms)) return rms;
|
||||
|
||||
// Fallback: if the analyser is present (older builds might still expose it),
|
||||
// compute RMS here.
|
||||
const analyser: AnalyserNode | undefined = player?.analyser;
|
||||
if (!analyser) return undefined;
|
||||
const { analyser, state } = analyserBundle;
|
||||
|
||||
try {
|
||||
// Use float time-domain data when available (avoids 8-bit quantization).
|
||||
if (typeof analyser.getFloatTimeDomainData === "function") {
|
||||
let floatData = player?.analyserFloatData;
|
||||
let floatData = state.analyserFloatData;
|
||||
|
||||
if (floatData?.length !== analyser.fftSize) {
|
||||
floatData = new Float32Array(analyser.fftSize);
|
||||
player.analyserFloatData = floatData;
|
||||
state.analyserFloatData = floatData;
|
||||
}
|
||||
|
||||
analyser.getFloatTimeDomainData(floatData);
|
||||
|
|
@ -158,18 +421,13 @@ function getTranslatedAudioRms(this: VideoHandler): number | undefined {
|
|||
for (const value of floatData) {
|
||||
sum += value * value;
|
||||
}
|
||||
return Math.sqrt(sum / floatData.length);
|
||||
return clamp(Math.sqrt(sum / floatData.length), 0, 1);
|
||||
}
|
||||
|
||||
// Byte fallback.
|
||||
// TS 5.9+ types `Uint8Array` with a generic ArrayBuffer type parameter. WebAudio's
|
||||
// `getByteTimeDomainData` expects a Uint8Array; the underlying buffer type does not
|
||||
// matter for our usage, so we cast for compatibility.
|
||||
let data = player?.analyserData;
|
||||
|
||||
let data = state.analyserData;
|
||||
if (data?.length !== analyser.fftSize) {
|
||||
data = new Uint8Array(analyser.fftSize);
|
||||
player.analyserData = data;
|
||||
state.analyserData = data;
|
||||
}
|
||||
|
||||
analyser.getByteTimeDomainData(data);
|
||||
|
|
@ -179,7 +437,7 @@ function getTranslatedAudioRms(this: VideoHandler): number | undefined {
|
|||
const normalizedValue = (rawValue - 128) / 128;
|
||||
sum += normalizedValue * normalizedValue;
|
||||
}
|
||||
return Math.sqrt(sum / data.length);
|
||||
return clamp(Math.sqrt(sum / data.length), 0, 1);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -189,11 +447,20 @@ function smartDuckingTick(handler: VideoHandler): void {
|
|||
const player = handler.audioPlayer?.player as unknown as
|
||||
| AudioPlayerLike
|
||||
| undefined;
|
||||
const media: HTMLMediaElement | undefined =
|
||||
player?.getMediaElement?.() ??
|
||||
// Legacy fallbacks
|
||||
player?.audio ??
|
||||
player?.audioElement;
|
||||
const media = getPlayerMediaElement(player);
|
||||
const syncVolumeEnabled = Boolean(handler.data?.syncVolume);
|
||||
const autoVolumeEnabled =
|
||||
Boolean(handler.data?.enabledAutoVolume) && !syncVolumeEnabled;
|
||||
const smartEnabled =
|
||||
(handler.data?.enabledSmartDucking ?? true) && !syncVolumeEnabled;
|
||||
|
||||
// Smart ducking may be disabled while interval is still active (e.g. runtime
|
||||
// settings toggle). Switch to one-shot classic ducking immediately and stop
|
||||
// periodic corrections so user volume changes are not overridden.
|
||||
if (autoVolumeEnabled && !smartEnabled) {
|
||||
setupAudioSettings.call(handler);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioIsPlaying =
|
||||
!!media &&
|
||||
|
|
@ -213,15 +480,17 @@ function smartDuckingTick(handler: VideoHandler): void {
|
|||
const dynamicDuckingTarget =
|
||||
clamp(handler.data?.autoVolume ?? defaultAutoVolume, 0, 100) / 100;
|
||||
handler.smartVolumeDuckingTarget = dynamicDuckingTarget;
|
||||
const rms =
|
||||
audioIsPlaying && media ? getTranslatedAudioRms(handler, media) : 0;
|
||||
|
||||
const decision = computeSmartDuckingStep(
|
||||
{
|
||||
nowMs: now,
|
||||
translationActive: handler.hasActiveSource(),
|
||||
enabledAutoVolume: Boolean(handler.data?.enabledAutoVolume),
|
||||
smartEnabled: handler.data?.enabledSmartDucking ?? true,
|
||||
enabledAutoVolume: autoVolumeEnabled,
|
||||
smartEnabled,
|
||||
audioIsPlaying,
|
||||
rms: audioIsPlaying ? getTranslatedAudioRms.call(handler) : 0,
|
||||
rms,
|
||||
currentVideoVolume,
|
||||
hostVideoActive,
|
||||
duckingTarget01: dynamicDuckingTarget,
|
||||
|
|
@ -436,39 +705,21 @@ export async function handleProxySettingsChanged(
|
|||
this: VideoHandler,
|
||||
reason = "proxySettingsChanged",
|
||||
) {
|
||||
debug.log(`[VOT] ${reason}: clearing translation/subtitles cache`);
|
||||
try {
|
||||
debug.log(`[VOT] ${reason}: clearing translation cache`);
|
||||
this.cacheManager.clear();
|
||||
this.activeTranslation = null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Cancel any in-flight requests so a new attempt can start cleanly.
|
||||
// Switching proxy settings should cancel any ongoing translation and leave
|
||||
// playback in a clean, disabled state.
|
||||
try {
|
||||
this.resetActionsAbortController(reason);
|
||||
await this.stopTranslation();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// If we're currently playing a translated audio track (non-stream),
|
||||
// re-apply the proxy mapping and re-validate the URL.
|
||||
try {
|
||||
if (this.videoData?.isStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current =
|
||||
this.downloadTranslationUrl ||
|
||||
this.audioPlayer?.player?.currentSrc ||
|
||||
this.audioPlayer?.player?.src;
|
||||
|
||||
if (!current) return;
|
||||
|
||||
await this.updateTranslation(this.unproxifyAudio(current));
|
||||
} catch (err) {
|
||||
debug.log(`[VOT] ${reason}: failed to refresh active audio`, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function isMultiMethodS3(this: VideoHandler, url: string): boolean {
|
||||
|
|
@ -482,10 +733,15 @@ export async function updateTranslation(
|
|||
audioUrl: string,
|
||||
actionContext?: { gen: number; videoId: string },
|
||||
): Promise<void> {
|
||||
await this.waitForPendingStopTranslate();
|
||||
if (this.isActionStale(actionContext)) return;
|
||||
if (!this.audioPlayer) {
|
||||
this.createPlayer();
|
||||
}
|
||||
if (this.audioPlayer.audioContext?.state === "closed") {
|
||||
debug.log("[updateTranslation] AudioContext is closed, recreating player");
|
||||
this.createPlayer();
|
||||
}
|
||||
const normalizedTargetUrl = this.proxifyAudio(this.unproxifyAudio(audioUrl));
|
||||
const currentSource =
|
||||
this.audioPlayer.player.currentSrc || this.audioPlayer.player.src || "";
|
||||
|
|
@ -502,18 +758,92 @@ export async function updateTranslation(
|
|||
}
|
||||
if (this.isActionStale(actionContext)) return;
|
||||
const shouldInitPlayer = this.audioPlayer.player.src !== nextAudioUrl;
|
||||
let appliedSourceUrl: string | null = null;
|
||||
if (shouldInitPlayer) {
|
||||
this.audioPlayer.player.src = nextAudioUrl;
|
||||
appliedSourceUrl = nextAudioUrl;
|
||||
}
|
||||
let initError: unknown;
|
||||
try {
|
||||
if (shouldInitPlayer) {
|
||||
this.audioPlayer.init();
|
||||
await this.audioPlayer.init();
|
||||
}
|
||||
if (this.isActionStale(actionContext)) {
|
||||
await rollbackStaleAppliedSourceIfStillCurrent(this, appliedSourceUrl);
|
||||
return;
|
||||
}
|
||||
const resumeResult = await resumePlayerAudioContextIfNeeded(this);
|
||||
if (resumeResult === "timeout") {
|
||||
debug.log(
|
||||
"[updateTranslation] continuing after AudioContext resume timeout",
|
||||
);
|
||||
} else if (resumeResult === "failed") {
|
||||
debug.log(
|
||||
"[updateTranslation] AudioContext resume failed, continue without deferred resume",
|
||||
);
|
||||
}
|
||||
if (this.isActionStale(actionContext)) {
|
||||
await rollbackStaleAppliedSourceIfStillCurrent(this, appliedSourceUrl);
|
||||
return;
|
||||
}
|
||||
if (!this.video.paused && this.audioPlayer.player.src) {
|
||||
this.audioPlayer.player.lipSync("play");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
debug.log("this.audioPlayer.init() error", err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.transformBtn("error", msg);
|
||||
initError = err;
|
||||
}
|
||||
|
||||
// Network/proxy hiccup fallback: if proxied URL failed to fetch audio data,
|
||||
// retry once with the original direct S3 URL.
|
||||
if (initError && shouldInitPlayer && !this.isActionStale(actionContext)) {
|
||||
const directUrl = this.unproxifyAudio(nextAudioUrl);
|
||||
if (directUrl !== nextAudioUrl) {
|
||||
try {
|
||||
debug.log(
|
||||
"[updateTranslation] proxied audio init failed, retrying direct URL",
|
||||
);
|
||||
const validatedDirectUrl = await this.validateAudioUrl(
|
||||
directUrl,
|
||||
actionContext,
|
||||
);
|
||||
if (this.isActionStale(actionContext)) {
|
||||
await rollbackStaleAppliedSourceIfStillCurrent(
|
||||
this,
|
||||
appliedSourceUrl,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.audioPlayer.player.src = validatedDirectUrl;
|
||||
appliedSourceUrl = validatedDirectUrl;
|
||||
nextAudioUrl = validatedDirectUrl;
|
||||
await this.audioPlayer.init();
|
||||
|
||||
const resumeResult = await resumePlayerAudioContextIfNeeded(this);
|
||||
if (resumeResult === "timeout" || resumeResult === "failed") {
|
||||
debug.log(
|
||||
"[updateTranslation] AudioContext not resumed after direct URL fallback",
|
||||
resumeResult,
|
||||
);
|
||||
}
|
||||
if (!this.video.paused && this.audioPlayer.player.src) {
|
||||
this.audioPlayer.player.lipSync("play");
|
||||
}
|
||||
initError = undefined;
|
||||
} catch (fallbackErr) {
|
||||
initError = fallbackErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (initError) {
|
||||
debug.log("this.audioPlayer.init() error", initError);
|
||||
await rollbackStaleAppliedSourceIfStillCurrent(this, appliedSourceUrl);
|
||||
const msg =
|
||||
initError instanceof Error ? initError.message : String(initError);
|
||||
this.transformBtn("error", msg);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupAudioSettings();
|
||||
this.transformBtn("success", localizationProvider.get("disableTranslate"));
|
||||
this.afterUpdateTranslation(nextAudioUrl);
|
||||
|
|
@ -527,8 +857,9 @@ export async function translateFunc(
|
|||
responseLang: string,
|
||||
translationHelp?: VideoData["translationHelp"],
|
||||
): Promise<void> {
|
||||
await this.waitForPendingStopTranslate();
|
||||
debug.log("Run videoValidator");
|
||||
this.videoValidator();
|
||||
await this.videoValidator();
|
||||
// Stream translation is currently disabled; keep this parameter for API compatibility.
|
||||
|
||||
// Ensure we don't start requests with a stale/aborted signal.
|
||||
|
|
@ -669,6 +1000,15 @@ export async function translateFunc(
|
|||
if (this.activeTranslation?.promise === translationPromise) {
|
||||
this.activeTranslation = null;
|
||||
}
|
||||
const overlayBtn = this.uiManager.votOverlayView?.votButton;
|
||||
if (
|
||||
!this.activeTranslation &&
|
||||
overlayBtn?.loading &&
|
||||
!this.hasActiveSource()
|
||||
) {
|
||||
debug.log("[translateFunc] clearing stale loading state");
|
||||
this.transformBtn("none", localizationProvider.get("translateVideo"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -681,18 +1021,49 @@ export function setupAudioSettings(this: VideoHandler) {
|
|||
this.audioPlayer.player.volume = this.data.defaultVolume / 100;
|
||||
}
|
||||
|
||||
// Smart Auto-Volume ducking: lower the original video volume only when
|
||||
// translated audio is actually playing sound, and restore it during silence.
|
||||
if (this.data?.enabledAutoVolume) {
|
||||
this.smartVolumeDuckingTarget =
|
||||
clamp(this.data.autoVolume ?? defaultAutoVolume, 0, 100) / 100;
|
||||
const autoVolumeEnabled =
|
||||
Boolean(this.data?.enabledAutoVolume) && !this.data?.syncVolume;
|
||||
const smartDuckingEnabled = this.data?.enabledSmartDucking ?? true;
|
||||
|
||||
// Start the ducking loop once per translation session.
|
||||
startSmartVolumeDucking(this);
|
||||
} else {
|
||||
// Auto-volume toggled off -> stop ducking and restore baseline.
|
||||
if (!autoVolumeEnabled) {
|
||||
// Auto-volume toggled off -> restore baseline and fully reset smart ducking.
|
||||
stopSmartVolumeDucking(this, {
|
||||
restoreVolume: this.smartVolumeDuckingBaseline ?? this.volumeOnStart,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const targetVolume =
|
||||
clamp(this.data.autoVolume ?? defaultAutoVolume, 0, 100) / 100;
|
||||
this.smartVolumeDuckingTarget = targetVolume;
|
||||
|
||||
if (!this.hasActiveSource()) {
|
||||
// No active translation source yet: keep target cached for next setup call.
|
||||
return;
|
||||
}
|
||||
|
||||
if (smartDuckingEnabled) {
|
||||
startSmartVolumeDucking(this);
|
||||
return;
|
||||
}
|
||||
|
||||
// Smart ducking disabled -> fall back to classic constant ducking.
|
||||
if (typeof this.smartVolumeDuckingInterval === "number") {
|
||||
clearInterval(this.smartVolumeDuckingInterval);
|
||||
this.smartVolumeDuckingInterval = undefined;
|
||||
}
|
||||
|
||||
if (typeof this.smartVolumeDuckingBaseline !== "number") {
|
||||
this.smartVolumeDuckingBaseline = this.getVideoVolume();
|
||||
}
|
||||
|
||||
const baseline = this.smartVolumeDuckingBaseline ?? this.getVideoVolume();
|
||||
this.setVideoVolume(Math.min(baseline, targetVolume));
|
||||
|
||||
// Keep runtime in a neutral state in constant mode.
|
||||
writeSmartDuckingRuntime(
|
||||
this,
|
||||
initSmartDuckingRuntime(this.smartVolumeDuckingBaseline),
|
||||
);
|
||||
this.smartVolumeIsDucked = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ export const srcDir = path.join(rootDir, "src");
|
|||
export const outBase = path.join(rootDir, "dist-ext");
|
||||
export const outTmp = path.join(outBase, "_tmp");
|
||||
|
||||
const GITHUB_DIST_EXT_RAW_BASE =
|
||||
"https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist-ext";
|
||||
const CHROME_UPDATES_MANIFEST_FILE = "vot-extension-chrome-updates.xml";
|
||||
const FIREFOX_UPDATES_MANIFEST_FILE = "vot-extension-firefox-updates.json";
|
||||
const FIREFOX_UPDATES_MANIFEST_URL = `${GITHUB_DIST_EXT_RAW_BASE}/${FIREFOX_UPDATES_MANIFEST_FILE}`;
|
||||
|
||||
export type ExtensionBuildTarget = "chrome" | "firefox" | "all";
|
||||
|
||||
export interface ExtensionHeaders {
|
||||
|
|
@ -409,7 +415,7 @@ function getFirefoxAddonId(): string {
|
|||
return (
|
||||
process.env.FIREFOX_ADDON_ID ||
|
||||
process.env.GECKO_ID ||
|
||||
"vot-extension@local"
|
||||
"vot-extension@firefox"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -443,6 +449,10 @@ function getFirefoxDataCollectionPermissions(): {
|
|||
};
|
||||
}
|
||||
|
||||
function getFirefoxXpiRawUrl(version: string): string {
|
||||
return `${GITHUB_DIST_EXT_RAW_BASE}/vot-extension-firefox-${version}.xpi`;
|
||||
}
|
||||
|
||||
function buildManifestFirefox({
|
||||
headers,
|
||||
includeWorld,
|
||||
|
|
@ -458,6 +468,7 @@ function buildManifestFirefox({
|
|||
defaultIcon[64] = "icons/icon-64.png";
|
||||
}
|
||||
|
||||
delete manifest.update_url;
|
||||
delete manifest.background;
|
||||
manifest.background = {
|
||||
scripts: ["background.js"],
|
||||
|
|
@ -465,6 +476,7 @@ function buildManifestFirefox({
|
|||
manifest.browser_specific_settings = {
|
||||
gecko: {
|
||||
id: getFirefoxAddonId(),
|
||||
update_url: FIREFOX_UPDATES_MANIFEST_URL,
|
||||
strict_min_version: getFirefoxStrictMinVersion(),
|
||||
data_collection_permissions: getFirefoxDataCollectionPermissions(),
|
||||
},
|
||||
|
|
@ -487,6 +499,36 @@ async function writeManifest(
|
|||
);
|
||||
}
|
||||
|
||||
async function writeFirefoxUpdatesManifest({
|
||||
version,
|
||||
addonId,
|
||||
}: {
|
||||
version: string;
|
||||
addonId: string;
|
||||
}): Promise<string> {
|
||||
const updatesManifestPath = path.join(outBase, FIREFOX_UPDATES_MANIFEST_FILE);
|
||||
const updatesManifest = {
|
||||
addons: {
|
||||
[addonId]: {
|
||||
updates: [
|
||||
{
|
||||
version,
|
||||
update_link: getFirefoxXpiRawUrl(version),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
updatesManifestPath,
|
||||
JSON.stringify(updatesManifest, null, 3),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
return updatesManifestPath;
|
||||
}
|
||||
|
||||
async function zipDir(sourceDirPath: string, outZipPath: string): Promise<void> {
|
||||
await fs.rm(outZipPath, { force: true });
|
||||
await fs.mkdir(path.dirname(outZipPath), { recursive: true });
|
||||
|
|
@ -528,10 +570,12 @@ async function maybeBuildCrx({
|
|||
}: {
|
||||
sourceDir: string;
|
||||
version: string;
|
||||
}): Promise<string> {
|
||||
}): Promise<{ crxPath: string }> {
|
||||
const customKeyPath = process.env.CHROME_CRX_KEY_PATH?.trim() || null;
|
||||
const useTemporaryKey = !customKeyPath;
|
||||
const keyPath = path.resolve(
|
||||
rootDir,
|
||||
process.env.CHROME_CRX_KEY_PATH || path.join(outBase, "vot-extension-chrome.pem"),
|
||||
customKeyPath || path.join(outTmp, "vot-extension-chrome.pem"),
|
||||
);
|
||||
await fs.mkdir(path.dirname(keyPath), { recursive: true });
|
||||
|
||||
|
|
@ -540,8 +584,29 @@ async function maybeBuildCrx({
|
|||
throw new Error(`CRX builder not found: ${crx3Bin}. Install dependencies first.`);
|
||||
}
|
||||
const outCrx = path.join(outBase, `vot-extension-chrome-${version}.crx`);
|
||||
await runCmd(crx3Bin, ["-p", keyPath, "-o", outCrx, sourceDir], rootDir);
|
||||
return outCrx;
|
||||
try {
|
||||
await runCmd(
|
||||
crx3Bin,
|
||||
[
|
||||
"-p",
|
||||
keyPath,
|
||||
"-o",
|
||||
outCrx,
|
||||
"--appVersion",
|
||||
version,
|
||||
sourceDir,
|
||||
],
|
||||
rootDir,
|
||||
);
|
||||
} finally {
|
||||
if (useTemporaryKey) {
|
||||
await fs.rm(keyPath, { force: true });
|
||||
await fs.rm(path.join(outBase, "vot-extension-chrome.pem"), { force: true });
|
||||
}
|
||||
}
|
||||
return {
|
||||
crxPath: outCrx,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidMatchPattern(pattern: string): boolean {
|
||||
|
|
@ -607,6 +672,9 @@ async function verifyOne(browserName: "chrome" | "firefox"): Promise<void> {
|
|||
if (browserName === "chrome" && !manifest.background?.service_worker) {
|
||||
throw new Error(`${browserName}: expected background.service_worker`);
|
||||
}
|
||||
if (browserName === "chrome" && manifest.update_url) {
|
||||
throw new Error(`${browserName}: update_url must not be set`);
|
||||
}
|
||||
if (
|
||||
browserName === "firefox" &&
|
||||
(!Array.isArray(manifest.background?.scripts) ||
|
||||
|
|
@ -614,6 +682,15 @@ async function verifyOne(browserName: "chrome" | "firefox"): Promise<void> {
|
|||
) {
|
||||
throw new Error(`${browserName}: expected background.scripts[]`);
|
||||
}
|
||||
if (
|
||||
browserName === "firefox" &&
|
||||
manifest.browser_specific_settings?.gecko?.update_url !==
|
||||
FIREFOX_UPDATES_MANIFEST_URL
|
||||
) {
|
||||
throw new Error(
|
||||
`${browserName}: expected browser_specific_settings.gecko.update_url to be ${FIREFOX_UPDATES_MANIFEST_URL}, got ${manifest.browser_specific_settings?.gecko?.update_url}`,
|
||||
);
|
||||
}
|
||||
|
||||
assertValidPatterns(
|
||||
"host_permissions",
|
||||
|
|
@ -726,6 +803,15 @@ export async function verifyExtensionOutputs(
|
|||
}
|
||||
if (target === "all" || target === "firefox") {
|
||||
await verifyOne("firefox");
|
||||
const firefoxUpdatesManifestPath = path.join(
|
||||
outBase,
|
||||
FIREFOX_UPDATES_MANIFEST_FILE,
|
||||
);
|
||||
if (!(await exists(firefoxUpdatesManifestPath))) {
|
||||
throw new Error(
|
||||
`firefox: missing updates manifest at ${firefoxUpdatesManifestPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log("\nExtension verification complete.");
|
||||
}
|
||||
|
|
@ -739,12 +825,16 @@ export async function finalizeExtensionBuildArtifacts(
|
|||
const shouldBuildFirefox = target === "all" || target === "firefox";
|
||||
|
||||
await renameContentCss();
|
||||
if (shouldBuildChrome) {
|
||||
await fs.rm(path.join(outBase, CHROME_UPDATES_MANIFEST_FILE), { force: true });
|
||||
}
|
||||
|
||||
const version = headers.version || "0.0.0";
|
||||
let outChrome: string | null = null;
|
||||
let chromeCrx: string | null = null;
|
||||
let outFirefox: string | null = null;
|
||||
let firefoxXpi: string | null = null;
|
||||
let firefoxUpdatesManifestPath: string | null = null;
|
||||
|
||||
if (shouldBuildChrome) {
|
||||
outChrome = path.join(outBase, "chrome");
|
||||
|
|
@ -760,7 +850,8 @@ export async function finalizeExtensionBuildArtifacts(
|
|||
|
||||
const chromeZip = path.join(outBase, `vot-extension-chrome-${version}.zip`);
|
||||
await fs.rm(chromeZip, { force: true });
|
||||
chromeCrx = await maybeBuildCrx({ sourceDir: outChrome, version });
|
||||
const chromeBuildResult = await maybeBuildCrx({ sourceDir: outChrome, version });
|
||||
chromeCrx = chromeBuildResult.crxPath;
|
||||
}
|
||||
|
||||
if (shouldBuildFirefox) {
|
||||
|
|
@ -777,6 +868,10 @@ export async function finalizeExtensionBuildArtifacts(
|
|||
|
||||
firefoxXpi = path.join(outBase, `vot-extension-firefox-${version}.xpi`);
|
||||
await zipDir(outFirefox, firefoxXpi);
|
||||
firefoxUpdatesManifestPath = await writeFirefoxUpdatesManifest({
|
||||
version,
|
||||
addonId: getFirefoxAddonId(),
|
||||
});
|
||||
}
|
||||
|
||||
await verifyExtensionOutputs(target);
|
||||
|
|
@ -789,6 +884,7 @@ export async function finalizeExtensionBuildArtifacts(
|
|||
if (outFirefox) {
|
||||
console.log(`- Firefox: ${outFirefox}`);
|
||||
console.log(` - Package: ${firefoxXpi}`);
|
||||
console.log(` - Updates: ${firefoxUpdatesManifestPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue