,
+ );
+ break;
+ }
+ }
+ } catch (error) {
+ console.error("[VOT] Main world bridge", {
+ error,
+ });
+ }
+}
+
+export class AudioDownloader {
+ onDownloadedAudio = new EventImpl();
+ onDownloadedPartialAudio = new EventImpl();
+ onDownloadAudioError = new EventImpl();
+
+ async runAudioDownload(
+ videoId: string,
+ translationId: string,
+ signal: AbortSignal,
+ ) {
+ window.addEventListener("message", mainWorldMessageHandler);
+ try {
+ await handleCommonAudioDownloadRequest({
+ audioDownloader: this,
+ translationId,
+ videoId,
+ signal,
+ });
+ console.log("Audio downloader. Audio download finished", {
+ videoId,
+ });
+ } catch (err) {
+ console.error("Audio downloader. Failed to download audio", err);
+ this.onDownloadAudioError.dispatch(videoId);
+ }
+
+ window.removeEventListener("message", mainWorldMessageHandler);
+ }
+
+ addEventListener(
+ type: "downloadedAudio",
+ listener: (translationId: string, data: DownloadedAudioData) => void,
+ ): this;
+ addEventListener(
+ type: "downloadedPartialAudio",
+ listener: (translationId: string, data: DownloadedPartialAudioData) => void,
+ ): this;
+ addEventListener(
+ type: "downloadAudioError",
+ listener: (videoId: string) => void,
+ ): this;
+ addEventListener(
+ type: "downloadedAudio" | "downloadedPartialAudio" | "downloadAudioError",
+ listener: (...data: any[]) => void,
+ ): this {
+ switch (type) {
+ case "downloadedAudio":
+ this.onDownloadedAudio.addListener(listener);
+ break;
+ case "downloadedPartialAudio":
+ this.onDownloadedPartialAudio.addListener(listener);
+ break;
+ case "downloadAudioError":
+ this.onDownloadAudioError.addListener(listener);
+ break;
+ }
+
+ return this;
+ }
+
+ removeEventListener(
+ type: "downloadedAudio",
+ listener: (translationId: string, data: DownloadedAudioData) => void,
+ ): this;
+ removeEventListener(
+ type: "downloadedPartialAudio",
+ listener: (translationId: string, data: DownloadedPartialAudioData) => void,
+ ): this;
+ removeEventListener(
+ type: "downloadAudioError",
+ listener: (videoId: string) => void,
+ ): this;
+ removeEventListener(
+ type: "downloadedAudio" | "downloadedPartialAudio" | "downloadAudioError",
+ listener: (...data: any[]) => void,
+ ): this {
+ switch (type) {
+ case "downloadedAudio":
+ this.onDownloadedAudio.removeListener(listener);
+ break;
+ case "downloadedPartialAudio":
+ this.onDownloadedPartialAudio.removeListener(listener);
+ break;
+ case "downloadAudioError":
+ this.onDownloadAudioError.removeListener(listener);
+ break;
+ }
+
+ return this;
+ }
+}
diff --git a/src/audioDownloader/shared.ts b/src/audioDownloader/shared.ts
new file mode 100644
index 00000000..e118d4d1
--- /dev/null
+++ b/src/audioDownloader/shared.ts
@@ -0,0 +1,77 @@
+import { config } from "@vot.js/shared";
+import { SerializedRequestInitData } from "../types/audioDownloader";
+
+export const IFRAME_HASH = "vot_iframe";
+export const IFRAME_ID = "vot_iframe_player";
+
+export const MIN_CHUNK_RANGES_PART_SIZE = config.minChunkSize; // 5295308
+export const MIN_CONTENT_LENGTH_MULTIPLIER = 0.9;
+export const CHUNK_STEPS = [60_000, 80_000, 150_000, 330_000, 460_000];
+export const MIN_ARRAY_BUFFER_LENGTH = 15_000;
+export const ACCEPTABLE_LENGTH_DIFF = 0.9;
+
+export const getRequestUrl = (request: RequestInfo) =>
+ typeof request === "string" ? request : request.url;
+
+export function serializeRequestInit(
+ request: RequestInfo,
+): SerializedRequestInitData {
+ const body = new Uint8Array([120, 0]);
+ if (typeof request === "string") {
+ return {
+ body,
+ cache: "no-store" as RequestCache,
+ credentials: "include" as RequestCredentials,
+ method: "POST",
+ };
+ }
+
+ const {
+ headers,
+ cache,
+ credentials,
+ integrity,
+ keepalive,
+ method,
+ mode,
+ redirect,
+ referrer,
+ referrerPolicy,
+ } = request;
+ const headersEntries = [...headers.entries()];
+
+ return {
+ body,
+ cache,
+ credentials,
+ headersEntries,
+ integrity,
+ keepalive,
+ method,
+ mode,
+ redirect,
+ referrer,
+ referrerPolicy,
+ };
+}
+
+export function deserializeRequestInit(request: SerializedRequestInitData) {
+ const { headersEntries, ...options } = request;
+ const headers = new Headers(headersEntries);
+ return {
+ ...options,
+ headers,
+ };
+}
+
+export function serializeResponse(response: Response) {
+ const { ok, redirected, status, statusText, type, url } = response;
+ return {
+ ok,
+ redirected,
+ status,
+ statusText,
+ type,
+ url,
+ };
+}
diff --git a/src/global.d.ts b/src/global.d.ts
index d98453ce..f079ff75 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -2,3 +2,8 @@
const DEBUG_MODE: boolean;
const AVAILABLE_LOCALES: import("./types/localization").Locale[];
const REPO_BRANCH: "master" | "dev";
+
+/**
+ * @link https://wiki.greasespot.net/unsafeWindow
+ */
+const unsafeWindow: Window;
diff --git a/src/headers.json b/src/headers.json
index b071499c..79e3cc71 100644
--- a/src/headers.json
+++ b/src/headers.json
@@ -115,7 +115,7 @@
"*://*/*.mp4*",
"*://*/*.webm*"
],
- "grant": ["window.focus"],
+ "grant": ["window.focus", "unsafeWindow"],
"exclude": [
"file://*/*.mp4*",
"file://*/*.webm*",
@@ -146,13 +146,13 @@
"yandex.net",
"timeweb.cloud",
"raw.githubusercontent.com",
- "9animetv.to",
"vimeo.com",
"toil.cc",
"deno.dev",
"onrender.com",
"workers.dev",
"speed.cloudflare.com",
- "porntn.com"
+ "porntn.com",
+ "googlevideo.com"
]
}
diff --git a/src/index.js b/src/index.js
index b9f45175..640787bb 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,4 +1,3 @@
-import Bowser from "bowser";
import Chaimu, { initAudioContext } from "chaimu";
import VOTClient, { VOTWorkerClient } from "@vot.js/ext";
@@ -36,6 +35,7 @@ import {
cleanText,
isProxyOnlyExtension,
browserInfo,
+ isIframe,
} from "./utils/utils.js";
import { syncVolume } from "./utils/volume.js";
import { VideoObserver } from "./utils/VideoObserver.js";
@@ -44,6 +44,10 @@ import { detect, translate } from "./utils/translateApis.ts";
import { UIManager } from "./ui/manager.ts";
import { StorageData } from "./types/storage.ts";
import { formatKeysCombo } from "./ui/components/hotkeyButton.ts";
+import { initIframeService } from "./audioDownloader/iframe.ts";
+import { IFRAME_HASH } from "./audioDownloader/shared.ts";
+import { AudioDownloader } from "./audioDownloader/index.ts";
+import { VideoTranslationStatus } from "@vot.js/core/types/yandex";
export let countryCode; // Used later for proxy settings
@@ -139,6 +143,53 @@ class VOTTranslationHandler {
*/
constructor(videoHandler) {
this.videoHandler = videoHandler;
+ this.audioDownloader = new AudioDownloader();
+ this.audioDownloader
+ .addEventListener("downloadedAudio", async (translationId, data) => {
+ console.log("downloadedAudio", data);
+ const { videoId, fileId, audioData } = data;
+ const videoUrl = this.getCanonicalUrl(videoId);
+ await this.videoHandler.votClient.requestVtransAudio(
+ videoUrl,
+ translationId,
+ {
+ audioFile: audioData,
+ fileId,
+ },
+ );
+ })
+ .addEventListener(
+ "downloadedPartialAudio",
+ async (translationId, data) => {
+ // TODO: sended last audio part throw 400
+ // console.log("downloadedPartialAudio", data);
+ // const { audioData, fileId, videoId, amount, version, index } = data;
+ // const videoUrl = this.getCanonicalUrl(videoId);
+ // await this.videoHandler.votClient.requestVtransAudio(
+ // videoUrl,
+ // translationId,
+ // {
+ // audioFile: audioData,
+ // fileId: String(index),
+ // },
+ // {
+ // audioPartsLength: amount,
+ // fileId,
+ // unknown0: version,
+ // },
+ // );
+ },
+ )
+ .addEventListener("downloadAudioError", async (videoId) => {
+ console.log(`Failed to download audio ${videoId}`);
+ const videoUrl = this.getCanonicalUrl(videoId);
+ await this.videoHandler.votClient.requestVtransFailAudio(videoUrl);
+ });
+ }
+
+ getCanonicalUrl(videoId) {
+ // i guess hardcoded > videoData.url (in this case)
+ return `https://youtu.be/${videoId}`;
}
/**
@@ -154,6 +205,7 @@ class VOTTranslationHandler {
requestLang,
responseLang,
translationHelp = null,
+ shouldSendFailedAudio = false,
) {
clearTimeout(this.videoHandler.autoRetry);
debug.log(
@@ -170,17 +222,40 @@ class VOTTranslationHandler {
useLivelyVoice: this.videoHandler.data?.useNewModel,
videoTitle: this.videoHandler.videoData.title,
},
+ shouldSendFailedAudio,
});
debug.log("Translate video result", res);
if (res.translated && res.remainingTime < 1) {
debug.log("Video translation finished with this data: ", res);
return res;
}
+
const message =
res.message ?? localizationProvider.get("translationTakeFewMinutes");
await this.videoHandler.updateTranslationErrorMsg(
res.remainingTime > 0 ? secsToStrTime(res.remainingTime) : message,
);
+
+ if (
+ res.status === VideoTranslationStatus.AUDIO_REQUESTED &&
+ videoData.host === "youtube"
+ ) {
+ console.log("Start audio download");
+ await this.audioDownloader.runAudioDownload(
+ videoData.videoId,
+ res.translationId,
+ new AbortController().signal,
+ );
+ // for get instant result on download end
+ console.log("Send translate video");
+ return await this.translateVideoImpl(
+ videoData,
+ requestLang,
+ responseLang,
+ translationHelp,
+ true,
+ );
+ }
} catch (err) {
await this.videoHandler.updateTranslationErrorMsg(
err.data?.message ?? err,
@@ -200,6 +275,7 @@ class VOTTranslationHandler {
requestLang,
responseLang,
translationHelp,
+ true,
),
);
}, 20000);
@@ -1748,13 +1824,6 @@ function findContainer(site, video) {
*/
function initIframeInteractor() {
const configs = {
- "https://9animetv.to": {
- targetOrigin: "https://rapid-cloud.co",
- dataFilter: (data) => data === "getVideoId",
- extractVideoId: (url) => url.pathname.split("/").pop(),
- iframeSelector: "#iframe-embed",
- responseFormatter: (videoId) => `getVideoId:${videoId}`,
- },
"https://dev.epicgames.com": {
targetOrigin: "https://dev.epicgames.com",
dataFilter: (data) =>
@@ -1802,6 +1871,11 @@ function initIframeInteractor() {
* Main function to start the extension.
*/
async function main() {
+ if (isIframe() && window.location.hash.includes(IFRAME_HASH)) {
+ initIframeService();
+ return;
+ }
+
debug.log("Loading extension...");
await localizationProvider.update();
debug.log(`Selected menu language: ${localizationProvider.lang}`);
diff --git a/src/subtitles.js b/src/subtitles.js
index 5ad4c5c2..3dcfec8a 100644
--- a/src/subtitles.js
+++ b/src/subtitles.js
@@ -2,7 +2,7 @@ import { html, render } from "lit-html";
import { convertSubs } from "@vot.js/shared/utils/subs";
import { defaultTranslationService } from "./config/config.js";
-import { lang, GM_fetch } from "./utils/utils.js";
+import { lang, GM_fetch, timeout } from "./utils/utils.js";
import { translate } from "./utils/translateApis.ts";
import { localizationProvider } from "./localization/localizationProvider.ts";
import { votStorage } from "./utils/storage.ts";
@@ -245,13 +245,10 @@ export class SubtitlesProcessor {
},
requestLang,
}),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error("Timeout")), 5000),
- ),
+ timeout(5000, "Timeout"),
]);
console.log("[VOT] Subtitles response:", res);
-
if (res.waiting) {
console.error("[VOT] Failed to get Yandex subtitles");
}
diff --git a/src/types/audioDownloader.ts b/src/types/audioDownloader.ts
new file mode 100644
index 00000000..2b2cf12d
--- /dev/null
+++ b/src/types/audioDownloader.ts
@@ -0,0 +1,89 @@
+import { AudioAdaptiveFormat } from "@vot.js/ext/types/helpers/youtube";
+import { AudioDownloader } from "../audioDownloader";
+
+export type ChunkRange = {
+ start: number;
+ end: number;
+ mustExist: boolean;
+};
+
+export type MessagePayload = {
+ messageId: string;
+ messageType: string;
+ messageDirection: string;
+ payload: P;
+ error?: E;
+};
+
+export type VideoIdPayload = {
+ videoId: string;
+};
+
+export type SerializedRequestInitData = {
+ body: Uint8Array;
+ cache?: RequestCache;
+ credentials?: RequestCredentials;
+ headersEntries?: [string, string][];
+ integrity?: string;
+ keepalive?: boolean;
+ method?: string;
+ mode?: RequestMode;
+ redirect?: RequestRedirect;
+ referrer?: string;
+ referrerPolicy?: ReferrerPolicy;
+};
+
+export type DownloadAudioDataIframeResponsePayload = {
+ requestInfo: string;
+ requestInit: RequestInit | SerializedRequestInitData;
+ adaptiveFormat: AudioAdaptiveFormat;
+ itag: number;
+};
+
+export type FetchMediaWithMetaOptions = {
+ mediaUrl: string;
+ chunkRange: ChunkRange;
+ requestInit: RequestInit;
+ signal: AbortSignal;
+ isUrlChanged?: boolean;
+};
+
+export type FetchMediaWithMetaResult = {
+ media: ArrayBuffer;
+ url: string | null;
+ isAcceptableLast: boolean;
+};
+
+export type FetchMediaWithMetaByChunkRangesResult = {
+ media: Uint8Array;
+ url: string | null;
+ isAcceptableLast: boolean;
+};
+
+export type GetAudioFromAPIWithReplacedFetchOptions = {
+ videoId: string;
+ returnByParts?: boolean;
+ signal: AbortSignal;
+};
+
+export type AudioDownloadRequestOptions = {
+ audioDownloader: AudioDownloader;
+ translationId: string;
+ videoId: string;
+ signal: AbortSignal;
+};
+
+export type DownloadedAudioData = {
+ videoId: string;
+ fileId: string;
+ audioData: Uint8Array;
+};
+
+export type DownloadedPartialAudioData = {
+ videoId: string;
+ fileId: string;
+ audioData: Uint8Array;
+ version: 1;
+ index: number;
+ amount: number;
+};
diff --git a/src/ui/manager.ts b/src/ui/manager.ts
index 421b934f..5293b3d0 100644
--- a/src/ui/manager.ts
+++ b/src/ui/manager.ts
@@ -13,7 +13,7 @@ import {
GM_fetch,
} from "../utils/utils.js";
import type { UIManagerProps } from "../types/uiManager";
-import type { LocaleStorageKey, StorageData } from "../types/storage";
+import type { StorageData } from "../types/storage";
import type { VideoHandler } from "..";
import type { Status } from "../types/components/votButton";
import { localizationProvider } from "../localization/localizationProvider";
diff --git a/src/utils/utils.js b/src/utils/utils.js
index f91e53cd..9e05dc44 100644
--- a/src/utils/utils.js
+++ b/src/utils/utils.js
@@ -233,6 +233,49 @@ async function exitFullscreen() {
}
}
+const isIframe = () => window.self !== window.top;
+// TODO: for ts:
+// const sleep = (ms: number): Promise =>
+// new Promise((resolve) => window.setTimeout(resolve, ms));
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+// TODO: for ts: function timeout(ms: number, message = "Operation timed out"): Promise {
+function timeout(ms, message = "Operation timed out") {
+ return new Promise((_, reject) => {
+ setTimeout(() => reject(new Error(message)), ms);
+ });
+}
+
+// TODO: for ts:
+// async function waitForCondition(
+// condition: () => boolean,
+// timeoutMs: number,
+// throwOnTimeout = false): Promise
+async function waitForCondition(condition, timeoutMs, throwOnTimeout = false) {
+ let timedOut = false;
+
+ return Promise.race([
+ (async () => {
+ while (!condition() && !timedOut) {
+ await sleep(100);
+ }
+ })(),
+ // new Promise((resolve, reject) => {
+ new Promise((resolve, reject) => {
+ window.setTimeout(() => {
+ timedOut = true;
+ if (throwOnTimeout) {
+ reject(
+ new Error(`Wait for condition reached timeout of ${timeoutMs}`),
+ );
+ } else {
+ resolve();
+ }
+ }, timeoutMs);
+ }),
+ ]);
+}
+
export {
secsToStrTime,
isPiPAvailable,
@@ -245,4 +288,8 @@ export {
clamp,
toFlatObj,
exitFullscreen,
+ isIframe,
+ sleep,
+ timeout,
+ waitForCondition,
};
diff --git a/tsconfig.json b/tsconfig.json
index 5846d7c2..166b4d4f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
// Enable latest features
- "lib": ["ESNext", "DOM"],
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["@toil/gm-types/v3", "@toil/gm-types/v4"],
"target": "ESNext",
"module": "ESNext",