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:
NullVerdict 2026-02-17 22:12:48 +04:00
parent 3a59dfcb34
commit e110bcd54b
50 changed files with 2890 additions and 4081 deletions

2
.gitignore vendored
View file

@ -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

View file

@ -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.

View file

@ -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, что может приводить к проблемам с переводом новых видео.

View file

@ -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.

Binary file not shown.

View 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

File diff suppressed because one or more lines are too long

3596
dist/vot.user.js vendored

File diff suppressed because one or more lines are too long

344
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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++;
}

View file

@ -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(

View file

@ -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;
};

View file

@ -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();
}

View file

@ -1,6 +1,6 @@
export type LifecycleTranslationResetHost = {
videoData?: unknown;
stopTranslation(): void;
stopTranslation(): void | Promise<void>;
resetSubtitlesWidget(): void;
};

View file

@ -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.

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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",

View file

@ -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",
};
}

View file

@ -1,7 +1,7 @@
{
"name": "[VOT] - Voice Over Translation",
"description": "A small extension that adds a Yandex Browser video translation to other browsers",
"version": "1.11.0",
"version": "1.11.1",
"author": "Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng",
"namespace": "vot",
"icon": "https://translate.yandex.ru/icons/favicon.ico",

View file

@ -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);
}

View file

@ -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() {

View file

@ -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;

View file

@ -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: "";

View file

@ -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;
}

View file

@ -7,7 +7,7 @@
system-ui,
sans-serif
);
font-weight: bold;
font-weight: bold !important;
line-height: 1.5;
text-align: start;

View file

@ -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;
}

View file

@ -95,7 +95,7 @@
margin-left: var(--vot-space-2) !important;
white-space: nowrap;
color: inherit;
font-weight: 400;
font-weight: 400 !important;
}
}

View file

@ -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);

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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,
};
},

View file

@ -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;

View file

@ -13,4 +13,4 @@
*/
export type EventHandler<Args extends unknown[] = unknown[]> = (
...args: Args
) => void;
) => void | Promise<void>;

View file

@ -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];

View file

@ -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();
})();
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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() {

View file

@ -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();
}
}

View file

@ -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
View 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 };
}

View file

@ -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();
});
}

View file

@ -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;
}

View file

@ -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}`);
}
}