// ==UserScript== // @name [VOT] - Voice Over Translation // @name:de [VOT] - Voice-Over-Video-Übersetzung // @name:es [VOT] - Traducción de vídeo en off // @name:fr [VOT] - Traduction vidéo voix-off // @name:it [VOT] - Traduzione Video fuori campo // @name:ru [VOT] - Закадровый перевод видео // @name:zh [VOT] - 画外音视频翻译 // @namespace vot // @version 1.11.0 // @author Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng // @description A small extension that adds a Yandex Browser video translation to other browsers // @description:de Eine kleine Erweiterung, die eine Voice-over-Übersetzung von Videos aus dem Yandex-Browser zu anderen Browsern hinzufügt // @description:es Una pequeña extensión que agrega una traducción de voz en off de un video de Yandex Browser a otros navegadores // @description:fr Une petite extension qui ajoute la traduction vocale de la vidéo du Navigateur Yandex à d'autres navigateurs // @description:it Una piccola estensione che aggiunge la traduzione vocale del video dal browser Yandex ad altri browser // @description:ru Небольшое расширение, которое добавляет закадровый перевод видео из Яндекс Браузера в другие браузеры // @description:zh 一个小扩展,它增加了视频从Yandex浏览器到其他浏览器的画外音翻译 // @license MIT // @icon https://translate.yandex.ru/icons/favicon.ico // @homepageURL https://github.com/ilyhalight/voice-over-translation // @source https://github.com/ilyhalight/voice-over-translation.git // @supportURL https://github.com/ilyhalight/voice-over-translation/issues // @downloadURL https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist/vot.user.js // @updateURL https://raw.githubusercontent.com/ilyhalight/voice-over-translation/master/dist/vot.user.js // @match *://*.youtube.com/* // @match *://*.youtube-nocookie.com/* // @match *://*.youtubekids.com/* // @match *://*.twitch.tv/* // @match *://*.xvideos.com/* // @match *://*.xvideos-ar.com/* // @match *://*.xvideos005.com/* // @match *://*.xv-ru.com/* // @match *://*.xhamster.com/* // @match *://*.xhamster.desi/* // @match *://*.xhvid.com/* // @match *://*.spankbang.com/* // @match *://*.rule34video.com/* // @match *://*.picarto.tv/* // @match *://*.olympics.com/* // @match *://*.pornhub.com/* // @match *://*.pornhub.org/* // @match *://*.vk.com/* // @match *://*.vkvideo.ru/* // @match *://*.vk.ru/* // @match *://*.vimeo.com/* // @match *://*.imdb.com/* // @match *://*.9gag.com/* // @match *://*.twitter.com/* // @match *://*.x.com/* // @match *://*.facebook.com/* // @match *://*.rutube.ru/* // @match *://*.bilibili.com/* // @match *://*.bilibili.tv/* // @match *://my.mail.ru/* // @match *://*.bitchute.com/* // @match *://*.coursera.org/* // @match *://*.udemy.com/course/* // @match *://*.tiktok.com/* // @match *://*.douyin.com/* // @match *://rumble.com/* // @match *://*.eporner.com/* // @match *://*.dailymotion.com/* // @match *://*.ok.ru/* // @match *://trovo.live/* // @match *://disk.yandex.ru/* // @match *://disk.yandex.kz/* // @match *://disk.yandex.com/* // @match *://disk.yandex.com.am/* // @match *://disk.yandex.com.ge/* // @match *://disk.yandex.com.tr/* // @match *://disk.yandex.by/* // @match *://disk.yandex.az/* // @match *://disk.yandex.co.il/* // @match *://disk.yandex.ee/* // @match *://disk.yandex.lt/* // @match *://disk.yandex.lv/* // @match *://disk.yandex.md/* // @match *://disk.yandex.net/* // @match *://disk.yandex.tj/* // @match *://disk.yandex.tm/* // @match *://disk.yandex.uz/* // @match *://disk.360.yandex.ru/* // @match *://youtube.googleapis.com/embed/* // @match *://*.banned.video/* // @match *://*.madmaxworld.tv/* // @match *://*.weverse.io/* // @match *://*.newgrounds.com/* // @match *://*.egghead.io/* // @match *://*.youku.com/* // @match *://*.archive.org/* // @match *://*.patreon.com/* // @match *://*.reddit.com/* // @match *://*.kodik.info/* // @match *://*.kodik.biz/* // @match *://*.kodik.cc/* // @match *://*.kick.com/* // @match *://developer.apple.com/* // @match *://dev.epicgames.com/* // @match *://*.rapid-cloud.co/* // @match *://odysee.com/* // @match *://learning.sap.com/* // @match *://*.watchporn.to/* // @match *://*.linkedin.com/* // @match *://*.incestflix.net/* // @match *://*.incestflix.to/* // @match *://*.porntn.com/* // @match *://*.dzen.ru/* // @match *://*.cloudflarestream.com/* // @match *://*.loom.com/* // @match *://*.artstation.com/learning/* // @match *://*.rt.com/* // @match *://*.bitview.net/* // @match *://*.kickstarter.com/* // @match *://*.thisvid.com/* // @match *://*.ign.com/* // @match *://*.bunkr.site/* // @match *://*.bunkr.black/* // @match *://*.bunkr.cat/* // @match *://*.bunkr.media/* // @match *://*.bunkr.red/* // @match *://*.bunkr.ws/* // @match *://*.bunkr.org/* // @match *://*.bunkr.sk/* // @match *://*.bunkr.si/* // @match *://*.bunkr.su/* // @match *://*.bunkr.ci/* // @match *://*.bunkr.cr/* // @match *://*.bunkr.fi/* // @match *://*.bunkr.ph/* // @match *://*.bunkr.pk/* // @match *://*.bunkr.ps/* // @match *://*.bunkr.ru/* // @match *://*.bunkr.la/* // @match *://*.bunkr.is/* // @match *://*.bunkr.to/* // @match *://*.bunkr.ac/* // @match *://*.bunkr.ax/* // @match *://web.telegram.org/k/* // @match *://t2mc.toil.cc/* // @match *://mylearn.oracle.com/* // @match *://learn.deeplearning.ai/* // @match *://learn-staging.deeplearning.ai/* // @match *://learn-dev.deeplearning.ai/* // @match *://*.netacad.com/content/i2cs/* // @match *://*.nicovideo.jp/* // @match *://*.zdf.de/* // @match *://*.weibo.com/* // @match *://*/*.mp4* // @match *://*/*.webm* // @match *://*.yewtu.be/* // @match *://inv.nadeko.net/* // @match *://invidious.nerdvpn.de/* // @match *://invidious.protokolla.fi/* // @match *://invidious.materialio.us/* // @match *://iv.melmac.space/* // @match *://*.piped.video/* // @match *://piped.kavin.rocks/* // @match *://piped.private.coffee/* // @match *://proxitok.pabloferreiro.es/* // @match *://proxitok.pussthecat.org/* // @match *://tok.habedieeh.re/* // @match *://proxitok.esmailelbob.xyz/* // @match *://proxitok.privacydev.net/* // @match *://tok.artemislena.eu/* // @match *://tok.adminforge.de/* // @match *://tt.vern.cc/* // @match *://cringe.whatever.social/* // @match *://proxitok.lunar.icu/* // @match *://proxitok.privacy.com.de/* // @match *://peertube.tmp.rcp.tf/* // @match *://*.dalek.zone/* // @match *://video.sadmin.io/* // @match *://videos.viorsan.com/* // @match *://peertube.1312.media/* // @match *://tube.shanti.cafe/* // @match *://*.bee-tube.fr/* // @match *://video.blender.org/* // @match *://*.beetoons.tv/* // @match *://*.makertube.net/* // @match *://*.peertube.tv/* // @match *://*.framatube.org/* // @match *://*.tilvids.com/* // @match *://*.diode.zone/* // @match *://*.fedimovie.com/* // @match *://video.hardlimit.com/* // @match *://*.share.tube/* // @match *://*.peervideo.club/* // @match *://*.coursehunter.net/* // @match *://*.coursetrain.net/* // @exclude file://*/*.mp4* // @exclude file://*/*.webm* // @exclude *://accounts.youtube.com/* // @require https://gist.githubusercontent.com/ilyhalight/6eb5bb4dffc7ca9e3c57d6933e2452f3/raw/7ab38af2228d0bed13912e503bc8a9ee4b11828d/gm-addstyle-polyfill.js // @connect yandex.ru // @connect disk.yandex.kz // @connect disk.yandex.com // @connect disk.yandex.com.am // @connect disk.yandex.com.ge // @connect disk.yandex.com.tr // @connect disk.yandex.by // @connect disk.yandex.az // @connect disk.yandex.co.il // @connect disk.yandex.ee // @connect disk.yandex.lt // @connect disk.yandex.lv // @connect disk.yandex.md // @connect disk.yandex.net // @connect disk.yandex.tj // @connect disk.yandex.tm // @connect disk.yandex.uz // @connect disk.360.yandex.ru // @connect yandex.net // @connect timeweb.cloud // @connect raw.githubusercontent.com // @connect vimeo.com // @connect toil.cc // @connect deno.dev // @connect onrender.com // @connect workers.dev // @connect cloudflare-dns.com // @connect porntn.com // @connect googlevideo.com // @grant GM.deleteValue // @grant GM.getValue // @grant GM.getValues // @grant GM.listValues // @grant GM.setValue // @grant GM_addStyle // @grant GM_deleteValue // @grant GM_getValue // @grant GM_info // @grant GM_listValues // @grant GM_notification // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // @grant window.focus // ==/UserScript== !function(){function e(e,t){return(t||"")+" (SystemJS Error#"+e+" https://github.com/systemjs/systemjs/blob/main/docs/errors.md#"+e+")"}function t(e,t){if(-1!==e.indexOf("\\")&&(e=e.replace(j,"/")),"/"===e[0]&&"/"===e[1])return t.slice(0,t.indexOf(":")+1)+e;if("."===e[0]&&("/"===e[1]||"."===e[1]&&("/"===e[2]||2===e.length&&(e+="/"))||1===e.length&&(e+="/"))||"/"===e[0]){var n,r=t.slice(0,t.indexOf(":")+1);if(n="/"===t[r.length+1]?"file:"!==r?(n=t.slice(r.length+2)).slice(n.indexOf("/")+1):t.slice(8):t.slice(r.length+("/"===t[r.length])),"/"===e[0])return t.slice(0,t.length-n.length-1)+e;for(var i=n.slice(0,n.lastIndexOf("/")+1)+e,o=[],s=-1,u=0;un.length&&"/"!==r[r.length-1]))return r+e.slice(n.length);u("W2",n,r,"should have a trailing '/'")}}function u(t,n,r,i){console.warn(e(t,"Package target "+i+", resolving target '"+r+"' for "+n))}function c(e,t,n){for(var r=e.scopes,i=n&&o(n,r);i;){var u=s(t,r[i]);if(u)return u;i=o(i.slice(0,i.lastIndexOf("/")),r)}return s(t,e.imports)||-1!==t.indexOf(":")&&t}function a(){this[M]={}}function f(e){return e.id}function l(e,t,n,r){if(e.onload(n,t.id,t.d&&t.d.map(f),!!r),n)throw n}function d(t,n,r,i){var o=t[M][n];if(o)return o;var s=[],u=Object.create(null);P&&Object.defineProperty(u,P,{value:"Module"});var c=Promise.resolve().then((function(){return t.instantiate(n,r,i)})).then((function(r){if(!r)throw Error(e(2,"Module "+n+" did not instantiate"));var i=r[1]((function(e,t){o.h=!0;var n=!1;if("string"==typeof e)e in u&&u[e]===t||(u[e]=t,n=!0);else{for(var r in e)t=e[r],r in u&&u[r]===t||(u[r]=t,n=!0);e&&e.__esModule&&(u.__esModule=e.__esModule)}if(n)for(var i=0;i-1){var n=document.createEvent("Event");n.initEvent("error",!1,!1),t.dispatchEvent(n)}return Promise.reject(e)}))}else if("systemjs-importmap"===t.type){t.sp=!0;var r=t.src?(System.fetch||fetch)(t.src,{integrity:t.integrity,priority:t.fetchPriority,passThrough:!0}).then((function(e){if(!e.ok)throw Error("Invalid status code: "+e.status);return e.text()})).catch((function(n){return n.message=e("W4","Error fetching systemjs-import map "+t.src)+"\n"+n.message,console.warn(n),"function"==typeof t.onerror&&t.onerror(),"{}"})):t.innerHTML;W=W.then((function(){return r})).then((function(n){!function(t,n,r){var o={};try{o=JSON.parse(n)}catch(s){console.warn(Error(e("W5","systemjs-importmap contains invalid JSON")+"\n\n"+n+"\n"))}i(o,r,t)}(N,n,t.src||g)}))}}))}var g,y="undefined"!=typeof Symbol,b="undefined"!=typeof self,S="undefined"!=typeof document,w=b?self:global;if(S){var O=document.querySelector("base[href]");O&&(g=O.href)}if(!g&&"undefined"!=typeof location){var E=(g=location.href.split("#")[0].split("?")[0]).lastIndexOf("/");-1!==E&&(g=g.slice(0,E+1))}var x,j=/\\/g,P=y&&Symbol.toStringTag,M=y?Symbol():"@",I=a.prototype;I.import=function(e,t,n){var r=this;return t&&"object"==typeof t&&(n=t,t=void 0),Promise.resolve(r.prepareImport()).then((function(){return r.resolve(e,t,n)})).then((function(e){var t=d(r,e,void 0,n);return t.C||p(r,t)}))},I.createContext=function(e){var t=this;return{url:e,resolve:function(n,r){return Promise.resolve(t.resolve(n,r||e))}}},I.onload=function(){},I.register=function(e,t,n){x=[e,t,n]},I.getRegister=function(){var e=x;return x=void 0,e};var L=Object.freeze(Object.create(null));w.System=new a;var C,R,W=Promise.resolve(),N={imports:{},scopes:{},depcache:{},integrity:{}},T=S;if(I.prepareImport=function(e){return(T||e)&&(m(),T=!1),W},I.getImportMap=function(){return JSON.parse(JSON.stringify(N))},S&&(m(),window.addEventListener("DOMContentLoaded",m)),I.addImportMap=function(e,t){i(e,t||g,N)},S){window.addEventListener("error",(function(e){J=e.filename,_=e.error}));var A=location.origin}I.createScript=function(e){var t=document.createElement("script");t.async=!0,e.indexOf(A+"/")&&(t.crossOrigin="anonymous");var n=N.integrity[e];return n&&(t.integrity=n),t.src=e,t};var J,_,k={},U=I.register;I.register=function(e,t){if(S&&"loading"===document.readyState&&"string"!=typeof e){var n=document.querySelectorAll("script[src]"),r=n[n.length-1];if(r){C=e;var i=this;R=setTimeout((function(){k[r.src]=[e,t],i.import(r.src)}))}}else C=void 0;return U.call(this,e,t)},I.instantiate=function(t,n){var r=k[t];if(r)return delete k[t],r;var i=this;return Promise.resolve(I.createScript(t)).then((function(r){return new Promise((function(o,s){r.addEventListener("error",(function(){s(Error(e(3,"Error loading "+t+(n?" from "+n:""))))})),r.addEventListener("load",(function(){if(document.head.removeChild(r),J===t)s(_);else{var e=i.getRegister(t);e&&e[0]===C&&clearTimeout(R),o(e)}})),document.head.appendChild(r)}))}))},I.shouldFetch=function(){return!1},"undefined"!=typeof fetch&&(I.fetch=fetch);var $=I.instantiate,B=/^(text|application)\/(x-)?javascript(;|$)/;I.instantiate=function(t,n,r){var i=this;return this.shouldFetch(t,n,r)?this.fetch(t,{credentials:"same-origin",integrity:N.integrity[t],meta:r}).then((function(r){if(!r.ok)throw Error(e(7,r.status+" "+r.statusText+", loading "+t+(n?" from "+n:"")));var o=r.headers.get("content-type");if(!o||!B.test(o))throw Error(e(4,'Unknown Content-Type "'+o+'", loading '+t+(n?" from "+n:"")));return r.text().then((function(e){return e.indexOf("//# sourceURL=")<0&&(e+="\n//# sourceURL="+t),(0,eval)(e),i.getRegister(t)}))})):$.apply(this,arguments)},I.resolve=function(n,r){return c(N,t(n,r=r||g)||n,r)||function(t,n){throw Error(e(8,"Unable to resolve bare specifier '"+t+(n?"' from "+n:"'")))}(n,r)};var F=I.instantiate;I.instantiate=function(e,t,n){var r=N.depcache[e];if(r)for(var i=0;i{t$1.has(e)||(t$1.add(e),(a=>GM_addStyle(a))(e));}; const votConfig = { "host": "api.browser.yandex.ru", "hostVOT": "vot.toil.cc/v1", "hostWorker": "vot-worker.toil.cc", "mediaProxy": "media-proxy.toil.cc", "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 YaBrowser/25.12.0.0 Safari/537.36", "componentVersion": "25.12.4.1198", "hmac": "bt8xH3VOlb4mqf0nqAibnDOoiPlXsisf", "defaultDuration": 343, "minChunkSize": 5295308, "loggerLevel": 1, "version": "2.4.14" }; function varint64read() { let lowBits = 0; let highBits = 0; for (let shift = 0; shift < 28; shift += 7) { let b2 = this.buf[this.pos++]; lowBits |= (b2 & 127) << shift; if ((b2 & 128) == 0) { this.assertBounds(); return [lowBits, highBits]; } } let middleByte = this.buf[this.pos++]; lowBits |= (middleByte & 15) << 28; highBits = (middleByte & 112) >> 4; if ((middleByte & 128) == 0) { this.assertBounds(); return [lowBits, highBits]; } for (let shift = 3; shift <= 31; shift += 7) { let b2 = this.buf[this.pos++]; highBits |= (b2 & 127) << shift; if ((b2 & 128) == 0) { this.assertBounds(); return [lowBits, highBits]; } } throw new Error("invalid varint"); } function varint64write(lo, hi, bytes) { for (let i2 = 0; i2 < 28; i2 = i2 + 7) { const shift = lo >>> i2; const hasNext = !(shift >>> 7 == 0 && hi == 0); const byte = (hasNext ? shift | 128 : shift) & 255; bytes.push(byte); if (!hasNext) { return; } } const splitBits = lo >>> 28 & 15 | (hi & 7) << 4; const hasMoreBits = !(hi >> 3 == 0); bytes.push((hasMoreBits ? splitBits | 128 : splitBits) & 255); if (!hasMoreBits) { return; } for (let i2 = 3; i2 < 31; i2 = i2 + 7) { const shift = hi >>> i2; const hasNext = !(shift >>> 7 == 0); const byte = (hasNext ? shift | 128 : shift) & 255; bytes.push(byte); if (!hasNext) { return; } } bytes.push(hi >>> 31 & 1); } const TWO_PWR_32_DBL = 4294967296; function int64FromString(dec) { const minus = dec[0] === "-"; if (minus) { dec = dec.slice(1); } const base = 1e6; let lowBits = 0; let highBits = 0; function add1e6digit(begin, end) { const digit1e6 = Number(dec.slice(begin, end)); highBits *= base; lowBits = lowBits * base + digit1e6; if (lowBits >= TWO_PWR_32_DBL) { highBits = highBits + (lowBits / TWO_PWR_32_DBL | 0); lowBits = lowBits % TWO_PWR_32_DBL; } } add1e6digit(-24, -18); add1e6digit(-18, -12); add1e6digit(-12, -6); add1e6digit(-6); return minus ? negate(lowBits, highBits) : newBits(lowBits, highBits); } function int64ToString(lo, hi) { let bits = newBits(lo, hi); const negative = bits.hi & 2147483648; if (negative) { bits = negate(bits.lo, bits.hi); } const result = uInt64ToString(bits.lo, bits.hi); return negative ? "-" + result : result; } function uInt64ToString(lo, hi) { ({ lo, hi } = toUnsigned(lo, hi)); if (hi <= 2097151) { return String(TWO_PWR_32_DBL * hi + lo); } const low = lo & 16777215; const mid = (lo >>> 24 | hi << 8) & 16777215; const high = hi >> 16 & 65535; let digitA = low + mid * 6777216 + high * 6710656; let digitB = mid + high * 8147497; let digitC = high * 2; const base = 1e7; if (digitA >= base) { digitB += Math.floor(digitA / base); digitA %= base; } if (digitB >= base) { digitC += Math.floor(digitB / base); digitB %= base; } return digitC.toString() + decimalFrom1e7WithLeadingZeros(digitB) + decimalFrom1e7WithLeadingZeros(digitA); } function toUnsigned(lo, hi) { return { lo: lo >>> 0, hi: hi >>> 0 }; } function newBits(lo, hi) { return { lo: lo | 0, hi: hi | 0 }; } function negate(lowBits, highBits) { highBits = ~highBits; if (lowBits) { lowBits = ~lowBits + 1; } else { highBits += 1; } return newBits(lowBits, highBits); } const decimalFrom1e7WithLeadingZeros = (digit1e7) => { const partial = String(digit1e7); return "0000000".slice(partial.length) + partial; }; function varint32write(value, bytes) { if (value >= 0) { while (value > 127) { bytes.push(value & 127 | 128); value = value >>> 7; } bytes.push(value); } else { for (let i2 = 0; i2 < 9; i2++) { bytes.push(value & 127 | 128); value = value >> 7; } bytes.push(1); } } function varint32read() { let b2 = this.buf[this.pos++]; let result = b2 & 127; if ((b2 & 128) == 0) { this.assertBounds(); return result; } b2 = this.buf[this.pos++]; result |= (b2 & 127) << 7; if ((b2 & 128) == 0) { this.assertBounds(); return result; } b2 = this.buf[this.pos++]; result |= (b2 & 127) << 14; if ((b2 & 128) == 0) { this.assertBounds(); return result; } b2 = this.buf[this.pos++]; result |= (b2 & 127) << 21; if ((b2 & 128) == 0) { this.assertBounds(); return result; } b2 = this.buf[this.pos++]; result |= (b2 & 15) << 28; for (let readBytes = 5; (b2 & 128) !== 0 && readBytes < 10; readBytes++) b2 = this.buf[this.pos++]; if ((b2 & 128) != 0) throw new Error("invalid varint"); this.assertBounds(); return result >>> 0; } var define_process_env_default = {}; const protoInt64 = makeInt64Support(); function makeInt64Support() { const dv = new DataView(new ArrayBuffer(8)); const ok = typeof BigInt === "function" && typeof dv.getBigInt64 === "function" && typeof dv.getBigUint64 === "function" && typeof dv.setBigInt64 === "function" && typeof dv.setBigUint64 === "function" && (!!globalThis.Deno || typeof process != "object" || typeof define_process_env_default != "object" || define_process_env_default.BUF_BIGINT_DISABLE !== "1"); if (ok) { const MIN = BigInt("-9223372036854775808"); const MAX = BigInt("9223372036854775807"); const UMIN = BigInt("0"); const UMAX = BigInt("18446744073709551615"); return { zero: BigInt(0), supported: true, parse(value) { const bi = typeof value == "bigint" ? value : BigInt(value); if (bi > MAX || bi < MIN) { throw new Error(`invalid int64: ${value}`); } return bi; }, uParse(value) { const bi = typeof value == "bigint" ? value : BigInt(value); if (bi > UMAX || bi < UMIN) { throw new Error(`invalid uint64: ${value}`); } return bi; }, enc(value) { dv.setBigInt64(0, this.parse(value), true); return { lo: dv.getInt32(0, true), hi: dv.getInt32(4, true) }; }, uEnc(value) { dv.setBigInt64(0, this.uParse(value), true); return { lo: dv.getInt32(0, true), hi: dv.getInt32(4, true) }; }, dec(lo, hi) { dv.setInt32(0, lo, true); dv.setInt32(4, hi, true); return dv.getBigInt64(0, true); }, uDec(lo, hi) { dv.setInt32(0, lo, true); dv.setInt32(4, hi, true); return dv.getBigUint64(0, true); } }; } return { zero: "0", supported: false, parse(value) { if (typeof value != "string") { value = value.toString(); } assertInt64String(value); return value; }, uParse(value) { if (typeof value != "string") { value = value.toString(); } assertUInt64String(value); return value; }, enc(value) { if (typeof value != "string") { value = value.toString(); } assertInt64String(value); return int64FromString(value); }, uEnc(value) { if (typeof value != "string") { value = value.toString(); } assertUInt64String(value); return int64FromString(value); }, dec(lo, hi) { return int64ToString(lo, hi); }, uDec(lo, hi) { return uInt64ToString(lo, hi); } }; } function assertInt64String(value) { if (!/^-?[0-9]+$/.test(value)) { throw new Error("invalid int64: " + value); } } function assertUInt64String(value) { if (!/^[0-9]+$/.test(value)) { throw new Error("invalid uint64: " + value); } } const symbol = Symbol.for("@bufbuild/protobuf/text-encoding"); function getTextEncoding() { if (globalThis[symbol] == void 0) { const te = new globalThis.TextEncoder(); const td = new globalThis.TextDecoder(); globalThis[symbol] = { encodeUtf8(text) { return te.encode(text); }, decodeUtf8(bytes) { return td.decode(bytes); }, checkUtf8(text) { try { encodeURIComponent(text); return true; } catch (_2) { return false; } } }; } return globalThis[symbol]; } var WireType; (function(WireType2) { WireType2[WireType2["Varint"] = 0] = "Varint"; WireType2[WireType2["Bit64"] = 1] = "Bit64"; WireType2[WireType2["LengthDelimited"] = 2] = "LengthDelimited"; WireType2[WireType2["StartGroup"] = 3] = "StartGroup"; WireType2[WireType2["EndGroup"] = 4] = "EndGroup"; WireType2[WireType2["Bit32"] = 5] = "Bit32"; })(WireType || (WireType = {})); const FLOAT32_MAX = 34028234663852886e22; const FLOAT32_MIN = -34028234663852886e22; const UINT32_MAX = 4294967295; const INT32_MAX = 2147483647; const INT32_MIN = -2147483648; class BinaryWriter { constructor(encodeUtf8 = getTextEncoding().encodeUtf8) { this.encodeUtf8 = encodeUtf8; this.stack = []; this.chunks = []; this.buf = []; } finish() { if (this.buf.length) { this.chunks.push(new Uint8Array(this.buf)); this.buf = []; } let len = 0; for (let i2 = 0; i2 < this.chunks.length; i2++) len += this.chunks[i2].length; let bytes = new Uint8Array(len); let offset = 0; for (let i2 = 0; i2 < this.chunks.length; i2++) { bytes.set(this.chunks[i2], offset); offset += this.chunks[i2].length; } this.chunks = []; return bytes; } fork() { this.stack.push({ chunks: this.chunks, buf: this.buf }); this.chunks = []; this.buf = []; return this; } join() { let chunk = this.finish(); let prev = this.stack.pop(); if (!prev) throw new Error("invalid state, fork stack empty"); this.chunks = prev.chunks; this.buf = prev.buf; this.uint32(chunk.byteLength); return this.raw(chunk); } tag(fieldNo, type) { return this.uint32((fieldNo << 3 | type) >>> 0); } raw(chunk) { if (this.buf.length) { this.chunks.push(new Uint8Array(this.buf)); this.buf = []; } this.chunks.push(chunk); return this; } uint32(value) { assertUInt32(value); while (value > 127) { this.buf.push(value & 127 | 128); value = value >>> 7; } this.buf.push(value); return this; } int32(value) { assertInt32(value); varint32write(value, this.buf); return this; } bool(value) { this.buf.push(value ? 1 : 0); return this; } bytes(value) { this.uint32(value.byteLength); return this.raw(value); } string(value) { let chunk = this.encodeUtf8(value); this.uint32(chunk.byteLength); return this.raw(chunk); } float(value) { assertFloat32(value); let chunk = new Uint8Array(4); new DataView(chunk.buffer).setFloat32(0, value, true); return this.raw(chunk); } double(value) { let chunk = new Uint8Array(8); new DataView(chunk.buffer).setFloat64(0, value, true); return this.raw(chunk); } fixed32(value) { assertUInt32(value); let chunk = new Uint8Array(4); new DataView(chunk.buffer).setUint32(0, value, true); return this.raw(chunk); } sfixed32(value) { assertInt32(value); let chunk = new Uint8Array(4); new DataView(chunk.buffer).setInt32(0, value, true); return this.raw(chunk); } sint32(value) { assertInt32(value); value = (value << 1 ^ value >> 31) >>> 0; varint32write(value, this.buf); return this; } sfixed64(value) { let chunk = new Uint8Array(8), view = new DataView(chunk.buffer), tc = protoInt64.enc(value); view.setInt32(0, tc.lo, true); view.setInt32(4, tc.hi, true); return this.raw(chunk); } fixed64(value) { let chunk = new Uint8Array(8), view = new DataView(chunk.buffer), tc = protoInt64.uEnc(value); view.setInt32(0, tc.lo, true); view.setInt32(4, tc.hi, true); return this.raw(chunk); } int64(value) { let tc = protoInt64.enc(value); varint64write(tc.lo, tc.hi, this.buf); return this; } sint64(value) { const tc = protoInt64.enc(value), sign = tc.hi >> 31, lo = tc.lo << 1 ^ sign, hi = (tc.hi << 1 | tc.lo >>> 31) ^ sign; varint64write(lo, hi, this.buf); return this; } uint64(value) { const tc = protoInt64.uEnc(value); varint64write(tc.lo, tc.hi, this.buf); return this; } } class BinaryReader { constructor(buf, decodeUtf8 = getTextEncoding().decodeUtf8) { this.decodeUtf8 = decodeUtf8; this.varint64 = varint64read; this.uint32 = varint32read; this.buf = buf; this.len = buf.length; this.pos = 0; this.view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); } tag() { let tag = this.uint32(), fieldNo = tag >>> 3, wireType = tag & 7; if (fieldNo <= 0 || wireType < 0 || wireType > 5) throw new Error("illegal tag: field no " + fieldNo + " wire type " + wireType); return [fieldNo, wireType]; } skip(wireType, fieldNo) { let start = this.pos; switch (wireType) { case WireType.Varint: while (this.buf[this.pos++] & 128) { } break; case WireType.Bit64: this.pos += 4; case WireType.Bit32: this.pos += 4; break; case WireType.LengthDelimited: let len = this.uint32(); this.pos += len; break; case WireType.StartGroup: for (; ; ) { const [fn, wt] = this.tag(); if (wt === WireType.EndGroup) { if (fieldNo !== void 0 && fn !== fieldNo) { throw new Error("invalid end group tag"); } break; } this.skip(wt, fn); } break; default: throw new Error("cant skip wire type " + wireType); } this.assertBounds(); return this.buf.subarray(start, this.pos); } assertBounds() { if (this.pos > this.len) throw new RangeError("premature EOF"); } int32() { return this.uint32() | 0; } sint32() { let zze = this.uint32(); return zze >>> 1 ^ -(zze & 1); } int64() { return protoInt64.dec(...this.varint64()); } uint64() { return protoInt64.uDec(...this.varint64()); } sint64() { let [lo, hi] = this.varint64(); let s2 = -(lo & 1); lo = (lo >>> 1 | (hi & 1) << 31) ^ s2; hi = hi >>> 1 ^ s2; return protoInt64.dec(lo, hi); } bool() { let [lo, hi] = this.varint64(); return lo !== 0 || hi !== 0; } fixed32() { return this.view.getUint32((this.pos += 4) - 4, true); } sfixed32() { return this.view.getInt32((this.pos += 4) - 4, true); } fixed64() { return protoInt64.uDec(this.sfixed32(), this.sfixed32()); } sfixed64() { return protoInt64.dec(this.sfixed32(), this.sfixed32()); } float() { return this.view.getFloat32((this.pos += 4) - 4, true); } double() { return this.view.getFloat64((this.pos += 8) - 8, true); } bytes() { let len = this.uint32(), start = this.pos; this.pos += len; this.assertBounds(); return this.buf.subarray(start, start + len); } string() { return this.decodeUtf8(this.bytes()); } } function assertInt32(arg) { if (typeof arg == "string") { arg = Number(arg); } else if (typeof arg != "number") { throw new Error("invalid int32: " + typeof arg); } if (!Number.isInteger(arg) || arg > INT32_MAX || arg < INT32_MIN) throw new Error("invalid int32: " + arg); } function assertUInt32(arg) { if (typeof arg == "string") { arg = Number(arg); } else if (typeof arg != "number") { throw new Error("invalid uint32: " + typeof arg); } if (!Number.isInteger(arg) || arg > UINT32_MAX || arg < 0) throw new Error("invalid uint32: " + arg); } function assertFloat32(arg) { if (typeof arg == "string") { const o2 = arg; arg = Number(arg); if (Number.isNaN(arg) && o2 !== "NaN") { throw new Error("invalid float32: " + o2); } } else if (typeof arg != "number") { throw new Error("invalid float32: " + typeof arg); } if (Number.isFinite(arg) && (arg > FLOAT32_MAX || arg < FLOAT32_MIN)) throw new Error("invalid float32: " + arg); } var StreamInterval; (function(StreamInterval2) { StreamInterval2[StreamInterval2["NO_CONNECTION"] = 0] = "NO_CONNECTION"; StreamInterval2[StreamInterval2["TRANSLATING"] = 10] = "TRANSLATING"; StreamInterval2[StreamInterval2["STREAMING"] = 20] = "STREAMING"; StreamInterval2[StreamInterval2["UNRECOGNIZED"] = -1] = "UNRECOGNIZED"; })(StreamInterval || (StreamInterval = {})); function streamIntervalFromJSON(object) { switch (object) { case 0: case "NO_CONNECTION": return StreamInterval.NO_CONNECTION; case 10: case "TRANSLATING": return StreamInterval.TRANSLATING; case 20: case "STREAMING": return StreamInterval.STREAMING; case -1: case "UNRECOGNIZED": default: return StreamInterval.UNRECOGNIZED; } } function streamIntervalToJSON(object) { switch (object) { case StreamInterval.NO_CONNECTION: return "NO_CONNECTION"; case StreamInterval.TRANSLATING: return "TRANSLATING"; case StreamInterval.STREAMING: return "STREAMING"; case StreamInterval.UNRECOGNIZED: default: return "UNRECOGNIZED"; } } function createBaseVideoTranslationHelpObject() { return { target: "", targetUrl: "" }; } const VideoTranslationHelpObject = { encode(message, writer = new BinaryWriter()) { if (message.target !== "") { writer.uint32(10).string(message.target); } if (message.targetUrl !== "") { writer.uint32(18).string(message.targetUrl); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseVideoTranslationHelpObject(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.target = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.targetUrl = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { target: isSet(object.target) ? globalThis.String(object.target) : "", targetUrl: isSet(object.targetUrl) ? globalThis.String(object.targetUrl) : "" }; }, toJSON(message) { const obj = {}; if (message.target !== "") { obj.target = message.target; } if (message.targetUrl !== "") { obj.targetUrl = message.targetUrl; } return obj; }, create(base) { return VideoTranslationHelpObject.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseVideoTranslationHelpObject(); message.target = object.target ?? ""; message.targetUrl = object.targetUrl ?? ""; return message; } }; function createBaseVideoTranslationRequest() { return { url: "", deviceId: void 0, firstRequest: false, duration: 0, unknown0: 0, language: "", forceSourceLang: false, unknown1: 0, translationHelp: [], wasStream: false, responseLanguage: "", unknown2: 0, unknown3: 0, bypassCache: false, useLivelyVoice: false, videoTitle: "" }; } const VideoTranslationRequest = { encode(message, writer = new BinaryWriter()) { if (message.url !== "") { writer.uint32(26).string(message.url); } if (message.deviceId !== void 0) { writer.uint32(34).string(message.deviceId); } if (message.firstRequest !== false) { writer.uint32(40).bool(message.firstRequest); } if (message.duration !== 0) { writer.uint32(49).double(message.duration); } if (message.unknown0 !== 0) { writer.uint32(56).int32(message.unknown0); } if (message.language !== "") { writer.uint32(66).string(message.language); } if (message.forceSourceLang !== false) { writer.uint32(72).bool(message.forceSourceLang); } if (message.unknown1 !== 0) { writer.uint32(80).int32(message.unknown1); } for (const v2 of message.translationHelp) { VideoTranslationHelpObject.encode(v2, writer.uint32(90).fork()).join(); } if (message.wasStream !== false) { writer.uint32(104).bool(message.wasStream); } if (message.responseLanguage !== "") { writer.uint32(114).string(message.responseLanguage); } if (message.unknown2 !== 0) { writer.uint32(120).int32(message.unknown2); } if (message.unknown3 !== 0) { writer.uint32(128).int32(message.unknown3); } if (message.bypassCache !== false) { writer.uint32(136).bool(message.bypassCache); } if (message.useLivelyVoice !== false) { writer.uint32(144).bool(message.useLivelyVoice); } if (message.videoTitle !== "") { writer.uint32(154).string(message.videoTitle); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseVideoTranslationRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 3: { if (tag !== 26) { break; } message.url = reader.string(); continue; } case 4: { if (tag !== 34) { break; } message.deviceId = reader.string(); continue; } case 5: { if (tag !== 40) { break; } message.firstRequest = reader.bool(); continue; } case 6: { if (tag !== 49) { break; } message.duration = reader.double(); continue; } case 7: { if (tag !== 56) { break; } message.unknown0 = reader.int32(); continue; } case 8: { if (tag !== 66) { break; } message.language = reader.string(); continue; } case 9: { if (tag !== 72) { break; } message.forceSourceLang = reader.bool(); continue; } case 10: { if (tag !== 80) { break; } message.unknown1 = reader.int32(); continue; } case 11: { if (tag !== 90) { break; } message.translationHelp.push(VideoTranslationHelpObject.decode(reader, reader.uint32())); continue; } case 13: { if (tag !== 104) { break; } message.wasStream = reader.bool(); continue; } case 14: { if (tag !== 114) { break; } message.responseLanguage = reader.string(); continue; } case 15: { if (tag !== 120) { break; } message.unknown2 = reader.int32(); continue; } case 16: { if (tag !== 128) { break; } message.unknown3 = reader.int32(); continue; } case 17: { if (tag !== 136) { break; } message.bypassCache = reader.bool(); continue; } case 18: { if (tag !== 144) { break; } message.useLivelyVoice = reader.bool(); continue; } case 19: { if (tag !== 154) { break; } message.videoTitle = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { url: isSet(object.url) ? globalThis.String(object.url) : "", deviceId: isSet(object.deviceId) ? globalThis.String(object.deviceId) : void 0, firstRequest: isSet(object.firstRequest) ? globalThis.Boolean(object.firstRequest) : false, duration: isSet(object.duration) ? globalThis.Number(object.duration) : 0, unknown0: isSet(object.unknown0) ? globalThis.Number(object.unknown0) : 0, language: isSet(object.language) ? globalThis.String(object.language) : "", forceSourceLang: isSet(object.forceSourceLang) ? globalThis.Boolean(object.forceSourceLang) : false, unknown1: isSet(object.unknown1) ? globalThis.Number(object.unknown1) : 0, translationHelp: globalThis.Array.isArray(object?.translationHelp) ? object.translationHelp.map((e2) => VideoTranslationHelpObject.fromJSON(e2)) : [], wasStream: isSet(object.wasStream) ? globalThis.Boolean(object.wasStream) : false, responseLanguage: isSet(object.responseLanguage) ? globalThis.String(object.responseLanguage) : "", unknown2: isSet(object.unknown2) ? globalThis.Number(object.unknown2) : 0, unknown3: isSet(object.unknown3) ? globalThis.Number(object.unknown3) : 0, bypassCache: isSet(object.bypassCache) ? globalThis.Boolean(object.bypassCache) : false, useLivelyVoice: isSet(object.useLivelyVoice) ? globalThis.Boolean(object.useLivelyVoice) : false, videoTitle: isSet(object.videoTitle) ? globalThis.String(object.videoTitle) : "" }; }, toJSON(message) { const obj = {}; if (message.url !== "") { obj.url = message.url; } if (message.deviceId !== void 0) { obj.deviceId = message.deviceId; } if (message.firstRequest !== false) { obj.firstRequest = message.firstRequest; } if (message.duration !== 0) { obj.duration = message.duration; } if (message.unknown0 !== 0) { obj.unknown0 = Math.round(message.unknown0); } if (message.language !== "") { obj.language = message.language; } if (message.forceSourceLang !== false) { obj.forceSourceLang = message.forceSourceLang; } if (message.unknown1 !== 0) { obj.unknown1 = Math.round(message.unknown1); } if (message.translationHelp?.length) { obj.translationHelp = message.translationHelp.map((e2) => VideoTranslationHelpObject.toJSON(e2)); } if (message.wasStream !== false) { obj.wasStream = message.wasStream; } if (message.responseLanguage !== "") { obj.responseLanguage = message.responseLanguage; } if (message.unknown2 !== 0) { obj.unknown2 = Math.round(message.unknown2); } if (message.unknown3 !== 0) { obj.unknown3 = Math.round(message.unknown3); } if (message.bypassCache !== false) { obj.bypassCache = message.bypassCache; } if (message.useLivelyVoice !== false) { obj.useLivelyVoice = message.useLivelyVoice; } if (message.videoTitle !== "") { obj.videoTitle = message.videoTitle; } return obj; }, create(base) { return VideoTranslationRequest.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseVideoTranslationRequest(); message.url = object.url ?? ""; message.deviceId = object.deviceId ?? void 0; message.firstRequest = object.firstRequest ?? false; message.duration = object.duration ?? 0; message.unknown0 = object.unknown0 ?? 0; message.language = object.language ?? ""; message.forceSourceLang = object.forceSourceLang ?? false; message.unknown1 = object.unknown1 ?? 0; message.translationHelp = object.translationHelp?.map((e2) => VideoTranslationHelpObject.fromPartial(e2)) || []; message.wasStream = object.wasStream ?? false; message.responseLanguage = object.responseLanguage ?? ""; message.unknown2 = object.unknown2 ?? 0; message.unknown3 = object.unknown3 ?? 0; message.bypassCache = object.bypassCache ?? false; message.useLivelyVoice = object.useLivelyVoice ?? false; message.videoTitle = object.videoTitle ?? ""; return message; } }; function createBaseVideoTranslationResponse() { return { url: void 0, duration: void 0, status: 0, remainingTime: void 0, unknown0: void 0, translationId: "", language: void 0, message: void 0, isLivelyVoice: false, unknown2: void 0, shouldRetry: void 0, unknown3: void 0 }; } const VideoTranslationResponse = { encode(message, writer = new BinaryWriter()) { if (message.url !== void 0) { writer.uint32(10).string(message.url); } if (message.duration !== void 0) { writer.uint32(17).double(message.duration); } if (message.status !== 0) { writer.uint32(32).int32(message.status); } if (message.remainingTime !== void 0) { writer.uint32(40).int32(message.remainingTime); } if (message.unknown0 !== void 0) { writer.uint32(48).int32(message.unknown0); } if (message.translationId !== "") { writer.uint32(58).string(message.translationId); } if (message.language !== void 0) { writer.uint32(66).string(message.language); } if (message.message !== void 0) { writer.uint32(74).string(message.message); } if (message.isLivelyVoice !== false) { writer.uint32(80).bool(message.isLivelyVoice); } if (message.unknown2 !== void 0) { writer.uint32(88).int32(message.unknown2); } if (message.shouldRetry !== void 0) { writer.uint32(96).int32(message.shouldRetry); } if (message.unknown3 !== void 0) { writer.uint32(104).int32(message.unknown3); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseVideoTranslationResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.url = reader.string(); continue; } case 2: { if (tag !== 17) { break; } message.duration = reader.double(); continue; } case 4: { if (tag !== 32) { break; } message.status = reader.int32(); continue; } case 5: { if (tag !== 40) { break; } message.remainingTime = reader.int32(); continue; } case 6: { if (tag !== 48) { break; } message.unknown0 = reader.int32(); continue; } case 7: { if (tag !== 58) { break; } message.translationId = reader.string(); continue; } case 8: { if (tag !== 66) { break; } message.language = reader.string(); continue; } case 9: { if (tag !== 74) { break; } message.message = reader.string(); continue; } case 10: { if (tag !== 80) { break; } message.isLivelyVoice = reader.bool(); continue; } case 11: { if (tag !== 88) { break; } message.unknown2 = reader.int32(); continue; } case 12: { if (tag !== 96) { break; } message.shouldRetry = reader.int32(); continue; } case 13: { if (tag !== 104) { break; } message.unknown3 = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { url: isSet(object.url) ? globalThis.String(object.url) : void 0, duration: isSet(object.duration) ? globalThis.Number(object.duration) : void 0, status: isSet(object.status) ? globalThis.Number(object.status) : 0, remainingTime: isSet(object.remainingTime) ? globalThis.Number(object.remainingTime) : void 0, unknown0: isSet(object.unknown0) ? globalThis.Number(object.unknown0) : void 0, translationId: isSet(object.translationId) ? globalThis.String(object.translationId) : "", language: isSet(object.language) ? globalThis.String(object.language) : void 0, message: isSet(object.message) ? globalThis.String(object.message) : void 0, isLivelyVoice: isSet(object.isLivelyVoice) ? globalThis.Boolean(object.isLivelyVoice) : false, unknown2: isSet(object.unknown2) ? globalThis.Number(object.unknown2) : void 0, shouldRetry: isSet(object.shouldRetry) ? globalThis.Number(object.shouldRetry) : void 0, unknown3: isSet(object.unknown3) ? globalThis.Number(object.unknown3) : void 0 }; }, toJSON(message) { const obj = {}; if (message.url !== void 0) { obj.url = message.url; } if (message.duration !== void 0) { obj.duration = message.duration; } if (message.status !== 0) { obj.status = Math.round(message.status); } if (message.remainingTime !== void 0) { obj.remainingTime = Math.round(message.remainingTime); } if (message.unknown0 !== void 0) { obj.unknown0 = Math.round(message.unknown0); } if (message.translationId !== "") { obj.translationId = message.translationId; } if (message.language !== void 0) { obj.language = message.language; } if (message.message !== void 0) { obj.message = message.message; } if (message.isLivelyVoice !== false) { obj.isLivelyVoice = message.isLivelyVoice; } if (message.unknown2 !== void 0) { obj.unknown2 = Math.round(message.unknown2); } if (message.shouldRetry !== void 0) { obj.shouldRetry = Math.round(message.shouldRetry); } if (message.unknown3 !== void 0) { obj.unknown3 = Math.round(message.unknown3); } return obj; }, create(base) { return VideoTranslationResponse.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseVideoTranslationResponse(); message.url = object.url ?? void 0; message.duration = object.duration ?? void 0; message.status = object.status ?? 0; message.remainingTime = object.remainingTime ?? void 0; message.unknown0 = object.unknown0 ?? void 0; message.translationId = object.translationId ?? ""; message.language = object.language ?? void 0; message.message = object.message ?? void 0; message.isLivelyVoice = object.isLivelyVoice ?? false; message.unknown2 = object.unknown2 ?? void 0; message.shouldRetry = object.shouldRetry ?? void 0; message.unknown3 = object.unknown3 ?? void 0; return message; } }; function createBaseVideoTranslationCacheItem() { return { status: 0, remainingTime: void 0, message: void 0, unknown0: void 0 }; } const VideoTranslationCacheItem = { encode(message, writer = new BinaryWriter()) { if (message.status !== 0) { writer.uint32(8).int32(message.status); } if (message.remainingTime !== void 0) { writer.uint32(16).int32(message.remainingTime); } if (message.message !== void 0) { writer.uint32(26).string(message.message); } if (message.unknown0 !== void 0) { writer.uint32(32).int32(message.unknown0); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseVideoTranslationCacheItem(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.status = reader.int32(); continue; } case 2: { if (tag !== 16) { break; } message.remainingTime = reader.int32(); continue; } case 3: { if (tag !== 26) { break; } message.message = reader.string(); continue; } case 4: { if (tag !== 32) { break; } message.unknown0 = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { status: isSet(object.status) ? globalThis.Number(object.status) : 0, remainingTime: isSet(object.remainingTime) ? globalThis.Number(object.remainingTime) : void 0, message: isSet(object.message) ? globalThis.String(object.message) : void 0, unknown0: isSet(object.unknown0) ? globalThis.Number(object.unknown0) : void 0 }; }, toJSON(message) { const obj = {}; if (message.status !== 0) { obj.status = Math.round(message.status); } if (message.remainingTime !== void 0) { obj.remainingTime = Math.round(message.remainingTime); } if (message.message !== void 0) { obj.message = message.message; } if (message.unknown0 !== void 0) { obj.unknown0 = Math.round(message.unknown0); } return obj; }, create(base) { return VideoTranslationCacheItem.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseVideoTranslationCacheItem(); message.status = object.status ?? 0; message.remainingTime = object.remainingTime ?? void 0; message.message = object.message ?? void 0; message.unknown0 = object.unknown0 ?? void 0; return message; } }; function createBaseVideoTranslationCacheRequest() { return { url: "", duration: 0, language: "", responseLanguage: "" }; } const VideoTranslationCacheRequest = { encode(message, writer = new BinaryWriter()) { if (message.url !== "") { writer.uint32(10).string(message.url); } if (message.duration !== 0) { writer.uint32(17).double(message.duration); } if (message.language !== "") { writer.uint32(26).string(message.language); } if (message.responseLanguage !== "") { writer.uint32(34).string(message.responseLanguage); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseVideoTranslationCacheRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.url = reader.string(); continue; } case 2: { if (tag !== 17) { break; } message.duration = reader.double(); continue; } case 3: { if (tag !== 26) { break; } message.language = reader.string(); continue; } case 4: { if (tag !== 34) { break; } message.responseLanguage = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { url: isSet(object.url) ? globalThis.String(object.url) : "", duration: isSet(object.duration) ? globalThis.Number(object.duration) : 0, language: isSet(object.language) ? globalThis.String(object.language) : "", responseLanguage: isSet(object.responseLanguage) ? globalThis.String(object.responseLanguage) : "" }; }, toJSON(message) { const obj = {}; if (message.url !== "") { obj.url = message.url; } if (message.duration !== 0) { obj.duration = message.duration; } if (message.language !== "") { obj.language = message.language; } if (message.responseLanguage !== "") { obj.responseLanguage = message.responseLanguage; } return obj; }, create(base) { return VideoTranslationCacheRequest.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseVideoTranslationCacheRequest(); message.url = object.url ?? ""; message.duration = object.duration ?? 0; message.language = object.language ?? ""; message.responseLanguage = object.responseLanguage ?? ""; return message; } }; function createBaseVideoTranslationCacheResponse() { return { default: void 0, cloning: void 0 }; } const VideoTranslationCacheResponse = { encode(message, writer = new BinaryWriter()) { if (message.default !== void 0) { VideoTranslationCacheItem.encode(message.default, writer.uint32(10).fork()).join(); } if (message.cloning !== void 0) { VideoTranslationCacheItem.encode(message.cloning, writer.uint32(18).fork()).join(); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseVideoTranslationCacheResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.default = VideoTranslationCacheItem.decode(reader, reader.uint32()); continue; } case 2: { if (tag !== 18) { break; } message.cloning = VideoTranslationCacheItem.decode(reader, reader.uint32()); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { default: isSet(object.default) ? VideoTranslationCacheItem.fromJSON(object.default) : void 0, cloning: isSet(object.cloning) ? VideoTranslationCacheItem.fromJSON(object.cloning) : void 0 }; }, toJSON(message) { const obj = {}; if (message.default !== void 0) { obj.default = VideoTranslationCacheItem.toJSON(message.default); } if (message.cloning !== void 0) { obj.cloning = VideoTranslationCacheItem.toJSON(message.cloning); } return obj; }, create(base) { return VideoTranslationCacheResponse.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseVideoTranslationCacheResponse(); message.default = object.default !== void 0 && object.default !== null ? VideoTranslationCacheItem.fromPartial(object.default) : void 0; message.cloning = object.cloning !== void 0 && object.cloning !== null ? VideoTranslationCacheItem.fromPartial(object.cloning) : void 0; return message; } }; function createBaseAudioBufferObject() { return { audioFile: new Uint8Array(0), fileId: "" }; } const AudioBufferObject = { encode(message, writer = new BinaryWriter()) { if (message.audioFile.length !== 0) { writer.uint32(18).bytes(message.audioFile); } if (message.fileId !== "") { writer.uint32(10).string(message.fileId); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseAudioBufferObject(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 2: { if (tag !== 18) { break; } message.audioFile = reader.bytes(); continue; } case 1: { if (tag !== 10) { break; } message.fileId = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { audioFile: isSet(object.audioFile) ? bytesFromBase64(object.audioFile) : new Uint8Array(0), fileId: isSet(object.fileId) ? globalThis.String(object.fileId) : "" }; }, toJSON(message) { const obj = {}; if (message.audioFile.length !== 0) { obj.audioFile = base64FromBytes(message.audioFile); } if (message.fileId !== "") { obj.fileId = message.fileId; } return obj; }, create(base) { return AudioBufferObject.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseAudioBufferObject(); message.audioFile = object.audioFile ?? new Uint8Array(0); message.fileId = object.fileId ?? ""; return message; } }; function createBasePartialAudioBufferObject() { return { audioFile: new Uint8Array(0), chunkId: 0 }; } const PartialAudioBufferObject = { encode(message, writer = new BinaryWriter()) { if (message.audioFile.length !== 0) { writer.uint32(18).bytes(message.audioFile); } if (message.chunkId !== 0) { writer.uint32(8).int32(message.chunkId); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBasePartialAudioBufferObject(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 2: { if (tag !== 18) { break; } message.audioFile = reader.bytes(); continue; } case 1: { if (tag !== 8) { break; } message.chunkId = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { audioFile: isSet(object.audioFile) ? bytesFromBase64(object.audioFile) : new Uint8Array(0), chunkId: isSet(object.chunkId) ? globalThis.Number(object.chunkId) : 0 }; }, toJSON(message) { const obj = {}; if (message.audioFile.length !== 0) { obj.audioFile = base64FromBytes(message.audioFile); } if (message.chunkId !== 0) { obj.chunkId = Math.round(message.chunkId); } return obj; }, create(base) { return PartialAudioBufferObject.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBasePartialAudioBufferObject(); message.audioFile = object.audioFile ?? new Uint8Array(0); message.chunkId = object.chunkId ?? 0; return message; } }; function createBaseChunkAudioObject() { return { audioBuffer: void 0, audioPartsLength: 0, fileId: "", version: 0 }; } const ChunkAudioObject = { encode(message, writer = new BinaryWriter()) { if (message.audioBuffer !== void 0) { PartialAudioBufferObject.encode(message.audioBuffer, writer.uint32(10).fork()).join(); } if (message.audioPartsLength !== 0) { writer.uint32(16).int32(message.audioPartsLength); } if (message.fileId !== "") { writer.uint32(26).string(message.fileId); } if (message.version !== 0) { writer.uint32(32).int32(message.version); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseChunkAudioObject(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.audioBuffer = PartialAudioBufferObject.decode(reader, reader.uint32()); continue; } case 2: { if (tag !== 16) { break; } message.audioPartsLength = reader.int32(); continue; } case 3: { if (tag !== 26) { break; } message.fileId = reader.string(); continue; } case 4: { if (tag !== 32) { break; } message.version = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { audioBuffer: isSet(object.audioBuffer) ? PartialAudioBufferObject.fromJSON(object.audioBuffer) : void 0, audioPartsLength: isSet(object.audioPartsLength) ? globalThis.Number(object.audioPartsLength) : 0, fileId: isSet(object.fileId) ? globalThis.String(object.fileId) : "", version: isSet(object.version) ? globalThis.Number(object.version) : 0 }; }, toJSON(message) { const obj = {}; if (message.audioBuffer !== void 0) { obj.audioBuffer = PartialAudioBufferObject.toJSON(message.audioBuffer); } if (message.audioPartsLength !== 0) { obj.audioPartsLength = Math.round(message.audioPartsLength); } if (message.fileId !== "") { obj.fileId = message.fileId; } if (message.version !== 0) { obj.version = Math.round(message.version); } return obj; }, create(base) { return ChunkAudioObject.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseChunkAudioObject(); message.audioBuffer = object.audioBuffer !== void 0 && object.audioBuffer !== null ? PartialAudioBufferObject.fromPartial(object.audioBuffer) : void 0; message.audioPartsLength = object.audioPartsLength ?? 0; message.fileId = object.fileId ?? ""; message.version = object.version ?? 0; return message; } }; function createBaseVideoTranslationAudioRequest() { return { translationId: "", url: "", partialAudioInfo: void 0, audioInfo: void 0 }; } const VideoTranslationAudioRequest = { encode(message, writer = new BinaryWriter()) { if (message.translationId !== "") { writer.uint32(10).string(message.translationId); } if (message.url !== "") { writer.uint32(18).string(message.url); } if (message.partialAudioInfo !== void 0) { ChunkAudioObject.encode(message.partialAudioInfo, writer.uint32(34).fork()).join(); } if (message.audioInfo !== void 0) { AudioBufferObject.encode(message.audioInfo, writer.uint32(50).fork()).join(); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseVideoTranslationAudioRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.translationId = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.url = reader.string(); continue; } case 4: { if (tag !== 34) { break; } message.partialAudioInfo = ChunkAudioObject.decode(reader, reader.uint32()); continue; } case 6: { if (tag !== 50) { break; } message.audioInfo = AudioBufferObject.decode(reader, reader.uint32()); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { translationId: isSet(object.translationId) ? globalThis.String(object.translationId) : "", url: isSet(object.url) ? globalThis.String(object.url) : "", partialAudioInfo: isSet(object.partialAudioInfo) ? ChunkAudioObject.fromJSON(object.partialAudioInfo) : void 0, audioInfo: isSet(object.audioInfo) ? AudioBufferObject.fromJSON(object.audioInfo) : void 0 }; }, toJSON(message) { const obj = {}; if (message.translationId !== "") { obj.translationId = message.translationId; } if (message.url !== "") { obj.url = message.url; } if (message.partialAudioInfo !== void 0) { obj.partialAudioInfo = ChunkAudioObject.toJSON(message.partialAudioInfo); } if (message.audioInfo !== void 0) { obj.audioInfo = AudioBufferObject.toJSON(message.audioInfo); } return obj; }, create(base) { return VideoTranslationAudioRequest.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseVideoTranslationAudioRequest(); message.translationId = object.translationId ?? ""; message.url = object.url ?? ""; message.partialAudioInfo = object.partialAudioInfo !== void 0 && object.partialAudioInfo !== null ? ChunkAudioObject.fromPartial(object.partialAudioInfo) : void 0; message.audioInfo = object.audioInfo !== void 0 && object.audioInfo !== null ? AudioBufferObject.fromPartial(object.audioInfo) : void 0; return message; } }; function createBaseVideoTranslationAudioResponse() { return { status: 0, remainingChunks: [] }; } const VideoTranslationAudioResponse = { encode(message, writer = new BinaryWriter()) { if (message.status !== 0) { writer.uint32(8).int32(message.status); } for (const v2 of message.remainingChunks) { writer.uint32(18).string(v2); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseVideoTranslationAudioResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.status = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.remainingChunks.push(reader.string()); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { status: isSet(object.status) ? globalThis.Number(object.status) : 0, remainingChunks: globalThis.Array.isArray(object?.remainingChunks) ? object.remainingChunks.map((e2) => globalThis.String(e2)) : [] }; }, toJSON(message) { const obj = {}; if (message.status !== 0) { obj.status = Math.round(message.status); } if (message.remainingChunks?.length) { obj.remainingChunks = message.remainingChunks; } return obj; }, create(base) { return VideoTranslationAudioResponse.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseVideoTranslationAudioResponse(); message.status = object.status ?? 0; message.remainingChunks = object.remainingChunks?.map((e2) => e2) || []; return message; } }; function createBaseSubtitlesObject() { return { language: "", url: "", unknown0: 0, translatedLanguage: "", translatedUrl: "", unknown1: 0, unknown2: 0 }; } const SubtitlesObject = { encode(message, writer = new BinaryWriter()) { if (message.language !== "") { writer.uint32(10).string(message.language); } if (message.url !== "") { writer.uint32(18).string(message.url); } if (message.unknown0 !== 0) { writer.uint32(24).int32(message.unknown0); } if (message.translatedLanguage !== "") { writer.uint32(34).string(message.translatedLanguage); } if (message.translatedUrl !== "") { writer.uint32(42).string(message.translatedUrl); } if (message.unknown1 !== 0) { writer.uint32(48).int32(message.unknown1); } if (message.unknown2 !== 0) { writer.uint32(56).int32(message.unknown2); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseSubtitlesObject(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.language = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.url = reader.string(); continue; } case 3: { if (tag !== 24) { break; } message.unknown0 = reader.int32(); continue; } case 4: { if (tag !== 34) { break; } message.translatedLanguage = reader.string(); continue; } case 5: { if (tag !== 42) { break; } message.translatedUrl = reader.string(); continue; } case 6: { if (tag !== 48) { break; } message.unknown1 = reader.int32(); continue; } case 7: { if (tag !== 56) { break; } message.unknown2 = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { language: isSet(object.language) ? globalThis.String(object.language) : "", url: isSet(object.url) ? globalThis.String(object.url) : "", unknown0: isSet(object.unknown0) ? globalThis.Number(object.unknown0) : 0, translatedLanguage: isSet(object.translatedLanguage) ? globalThis.String(object.translatedLanguage) : "", translatedUrl: isSet(object.translatedUrl) ? globalThis.String(object.translatedUrl) : "", unknown1: isSet(object.unknown1) ? globalThis.Number(object.unknown1) : 0, unknown2: isSet(object.unknown2) ? globalThis.Number(object.unknown2) : 0 }; }, toJSON(message) { const obj = {}; if (message.language !== "") { obj.language = message.language; } if (message.url !== "") { obj.url = message.url; } if (message.unknown0 !== 0) { obj.unknown0 = Math.round(message.unknown0); } if (message.translatedLanguage !== "") { obj.translatedLanguage = message.translatedLanguage; } if (message.translatedUrl !== "") { obj.translatedUrl = message.translatedUrl; } if (message.unknown1 !== 0) { obj.unknown1 = Math.round(message.unknown1); } if (message.unknown2 !== 0) { obj.unknown2 = Math.round(message.unknown2); } return obj; }, create(base) { return SubtitlesObject.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseSubtitlesObject(); message.language = object.language ?? ""; message.url = object.url ?? ""; message.unknown0 = object.unknown0 ?? 0; message.translatedLanguage = object.translatedLanguage ?? ""; message.translatedUrl = object.translatedUrl ?? ""; message.unknown1 = object.unknown1 ?? 0; message.unknown2 = object.unknown2 ?? 0; return message; } }; function createBaseSubtitlesRequest() { return { url: "", language: "" }; } const SubtitlesRequest = { encode(message, writer = new BinaryWriter()) { if (message.url !== "") { writer.uint32(10).string(message.url); } if (message.language !== "") { writer.uint32(18).string(message.language); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseSubtitlesRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.url = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.language = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { url: isSet(object.url) ? globalThis.String(object.url) : "", language: isSet(object.language) ? globalThis.String(object.language) : "" }; }, toJSON(message) { const obj = {}; if (message.url !== "") { obj.url = message.url; } if (message.language !== "") { obj.language = message.language; } return obj; }, create(base) { return SubtitlesRequest.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseSubtitlesRequest(); message.url = object.url ?? ""; message.language = object.language ?? ""; return message; } }; function createBaseSubtitlesResponse() { return { waiting: false, subtitles: [] }; } const SubtitlesResponse = { encode(message, writer = new BinaryWriter()) { if (message.waiting !== false) { writer.uint32(8).bool(message.waiting); } for (const v2 of message.subtitles) { SubtitlesObject.encode(v2, writer.uint32(18).fork()).join(); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseSubtitlesResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.waiting = reader.bool(); continue; } case 2: { if (tag !== 18) { break; } message.subtitles.push(SubtitlesObject.decode(reader, reader.uint32())); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { waiting: isSet(object.waiting) ? globalThis.Boolean(object.waiting) : false, subtitles: globalThis.Array.isArray(object?.subtitles) ? object.subtitles.map((e2) => SubtitlesObject.fromJSON(e2)) : [] }; }, toJSON(message) { const obj = {}; if (message.waiting !== false) { obj.waiting = message.waiting; } if (message.subtitles?.length) { obj.subtitles = message.subtitles.map((e2) => SubtitlesObject.toJSON(e2)); } return obj; }, create(base) { return SubtitlesResponse.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseSubtitlesResponse(); message.waiting = object.waiting ?? false; message.subtitles = object.subtitles?.map((e2) => SubtitlesObject.fromPartial(e2)) || []; return message; } }; function createBaseStreamTranslationObject() { return { url: "", timestamp: "" }; } const StreamTranslationObject = { encode(message, writer = new BinaryWriter()) { if (message.url !== "") { writer.uint32(10).string(message.url); } if (message.timestamp !== "") { writer.uint32(18).string(message.timestamp); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseStreamTranslationObject(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.url = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.timestamp = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { url: isSet(object.url) ? globalThis.String(object.url) : "", timestamp: isSet(object.timestamp) ? globalThis.String(object.timestamp) : "" }; }, toJSON(message) { const obj = {}; if (message.url !== "") { obj.url = message.url; } if (message.timestamp !== "") { obj.timestamp = message.timestamp; } return obj; }, create(base) { return StreamTranslationObject.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseStreamTranslationObject(); message.url = object.url ?? ""; message.timestamp = object.timestamp ?? ""; return message; } }; function createBaseStreamTranslationRequest() { return { url: "", language: "", responseLanguage: "", unknown0: 0, unknown1: 0 }; } const StreamTranslationRequest = { encode(message, writer = new BinaryWriter()) { if (message.url !== "") { writer.uint32(10).string(message.url); } if (message.language !== "") { writer.uint32(18).string(message.language); } if (message.responseLanguage !== "") { writer.uint32(26).string(message.responseLanguage); } if (message.unknown0 !== 0) { writer.uint32(40).int32(message.unknown0); } if (message.unknown1 !== 0) { writer.uint32(48).int32(message.unknown1); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseStreamTranslationRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.url = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.language = reader.string(); continue; } case 3: { if (tag !== 26) { break; } message.responseLanguage = reader.string(); continue; } case 5: { if (tag !== 40) { break; } message.unknown0 = reader.int32(); continue; } case 6: { if (tag !== 48) { break; } message.unknown1 = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { url: isSet(object.url) ? globalThis.String(object.url) : "", language: isSet(object.language) ? globalThis.String(object.language) : "", responseLanguage: isSet(object.responseLanguage) ? globalThis.String(object.responseLanguage) : "", unknown0: isSet(object.unknown0) ? globalThis.Number(object.unknown0) : 0, unknown1: isSet(object.unknown1) ? globalThis.Number(object.unknown1) : 0 }; }, toJSON(message) { const obj = {}; if (message.url !== "") { obj.url = message.url; } if (message.language !== "") { obj.language = message.language; } if (message.responseLanguage !== "") { obj.responseLanguage = message.responseLanguage; } if (message.unknown0 !== 0) { obj.unknown0 = Math.round(message.unknown0); } if (message.unknown1 !== 0) { obj.unknown1 = Math.round(message.unknown1); } return obj; }, create(base) { return StreamTranslationRequest.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseStreamTranslationRequest(); message.url = object.url ?? ""; message.language = object.language ?? ""; message.responseLanguage = object.responseLanguage ?? ""; message.unknown0 = object.unknown0 ?? 0; message.unknown1 = object.unknown1 ?? 0; return message; } }; function createBaseStreamTranslationResponse() { return { interval: 0, translatedInfo: void 0, pingId: void 0 }; } const StreamTranslationResponse = { encode(message, writer = new BinaryWriter()) { if (message.interval !== 0) { writer.uint32(8).int32(message.interval); } if (message.translatedInfo !== void 0) { StreamTranslationObject.encode(message.translatedInfo, writer.uint32(18).fork()).join(); } if (message.pingId !== void 0) { writer.uint32(24).int32(message.pingId); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseStreamTranslationResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.interval = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.translatedInfo = StreamTranslationObject.decode(reader, reader.uint32()); continue; } case 3: { if (tag !== 24) { break; } message.pingId = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { interval: isSet(object.interval) ? streamIntervalFromJSON(object.interval) : 0, translatedInfo: isSet(object.translatedInfo) ? StreamTranslationObject.fromJSON(object.translatedInfo) : void 0, pingId: isSet(object.pingId) ? globalThis.Number(object.pingId) : void 0 }; }, toJSON(message) { const obj = {}; if (message.interval !== 0) { obj.interval = streamIntervalToJSON(message.interval); } if (message.translatedInfo !== void 0) { obj.translatedInfo = StreamTranslationObject.toJSON(message.translatedInfo); } if (message.pingId !== void 0) { obj.pingId = Math.round(message.pingId); } return obj; }, create(base) { return StreamTranslationResponse.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseStreamTranslationResponse(); message.interval = object.interval ?? 0; message.translatedInfo = object.translatedInfo !== void 0 && object.translatedInfo !== null ? StreamTranslationObject.fromPartial(object.translatedInfo) : void 0; message.pingId = object.pingId ?? void 0; return message; } }; function createBaseStreamPingRequest() { return { pingId: 0 }; } const StreamPingRequest = { encode(message, writer = new BinaryWriter()) { if (message.pingId !== 0) { writer.uint32(8).int32(message.pingId); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseStreamPingRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.pingId = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { pingId: isSet(object.pingId) ? globalThis.Number(object.pingId) : 0 }; }, toJSON(message) { const obj = {}; if (message.pingId !== 0) { obj.pingId = Math.round(message.pingId); } return obj; }, create(base) { return StreamPingRequest.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseStreamPingRequest(); message.pingId = object.pingId ?? 0; return message; } }; function createBaseYandexSessionRequest() { return { uuid: "", module: "" }; } const YandexSessionRequest = { encode(message, writer = new BinaryWriter()) { if (message.uuid !== "") { writer.uint32(10).string(message.uuid); } if (message.module !== "") { writer.uint32(18).string(message.module); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseYandexSessionRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.uuid = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.module = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { uuid: isSet(object.uuid) ? globalThis.String(object.uuid) : "", module: isSet(object.module) ? globalThis.String(object.module) : "" }; }, toJSON(message) { const obj = {}; if (message.uuid !== "") { obj.uuid = message.uuid; } if (message.module !== "") { obj.module = message.module; } return obj; }, create(base) { return YandexSessionRequest.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseYandexSessionRequest(); message.uuid = object.uuid ?? ""; message.module = object.module ?? ""; return message; } }; function createBaseYandexSessionResponse() { return { secretKey: "", expires: 0 }; } const YandexSessionResponse = { encode(message, writer = new BinaryWriter()) { if (message.secretKey !== "") { writer.uint32(10).string(message.secretKey); } if (message.expires !== 0) { writer.uint32(16).int32(message.expires); } return writer; }, decode(input, length) { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === void 0 ? reader.len : reader.pos + length; const message = createBaseYandexSessionResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.secretKey = reader.string(); continue; } case 2: { if (tag !== 16) { break; } message.expires = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object) { return { secretKey: isSet(object.secretKey) ? globalThis.String(object.secretKey) : "", expires: isSet(object.expires) ? globalThis.Number(object.expires) : 0 }; }, toJSON(message) { const obj = {}; if (message.secretKey !== "") { obj.secretKey = message.secretKey; } if (message.expires !== 0) { obj.expires = Math.round(message.expires); } return obj; }, create(base) { return YandexSessionResponse.fromPartial(base ?? {}); }, fromPartial(object) { const message = createBaseYandexSessionResponse(); message.secretKey = object.secretKey ?? ""; message.expires = object.expires ?? 0; return message; } }; function bytesFromBase64(b64) { if (globalThis.Buffer) { return Uint8Array.from(globalThis.Buffer.from(b64, "base64")); } else { const bin = globalThis.atob(b64); const arr = new Uint8Array(bin.length); for (let i2 = 0; i2 < bin.length; ++i2) { arr[i2] = bin.charCodeAt(i2); } return arr; } } function base64FromBytes(arr) { if (globalThis.Buffer) { return globalThis.Buffer.from(arr).toString("base64"); } else { const bin = []; arr.forEach((byte) => { bin.push(globalThis.String.fromCharCode(byte)); }); return globalThis.btoa(bin.join("")); } } function isSet(value) { return value !== null && value !== void 0; } const scriptRel = (function detectScriptRel() { const relList = typeof document !== "undefined" && document.createElement("link").relList; return relList && relList.supports && relList.supports("modulepreload") ? "modulepreload" : "preload"; })(); const assetsURL = function(dep) { return "/" + dep; }; const seen = {}; const __vitePreload = function preload(baseModule, deps, importerUrl) { let promise = Promise.resolve(); if (deps && deps.length > 0) { let allSettled = function(promises$2) { return Promise.all(promises$2.map((p2) => Promise.resolve(p2).then((value$1) => ({ status: "fulfilled", value: value$1 }), (reason) => ({ status: "rejected", reason })))); }; document.getElementsByTagName("link"); const cspNonceMeta = document.querySelector("meta[property=csp-nonce]"); const cspNonce = cspNonceMeta?.nonce || cspNonceMeta?.getAttribute("nonce"); promise = allSettled(deps.map((dep) => { dep = assetsURL(dep); if (dep in seen) return; seen[dep] = true; const isCss = dep.endsWith(".css"); const cssSelector = isCss ? '[rel="stylesheet"]' : ""; if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) return; const link = document.createElement("link"); link.rel = isCss ? "stylesheet" : scriptRel; if (!isCss) link.as = "script"; link.crossOrigin = ""; link.href = dep; if (cspNonce) link.setAttribute("nonce", cspNonce); document.head.appendChild(link); if (isCss) return new Promise((res, rej) => { link.addEventListener("load", res); link.addEventListener("error", () => rej( new Error(`Unable to preload CSS for ${dep}`))); }); })); } function handlePreloadError(err$2) { const e$1 = new Event("vite:preloadError", { cancelable: true }); e$1.payload = err$2; window.dispatchEvent(e$1); if (!e$1.defaultPrevented) throw err$2; } return promise.then((res) => { for (const item of res || []) { if (item.status !== "rejected") continue; handlePreloadError(item.reason); } return baseModule().catch(handlePreloadError); }); }; var LoggerLevel; (function(LoggerLevel2) { LoggerLevel2[LoggerLevel2["DEBUG"] = 0] = "DEBUG"; LoggerLevel2[LoggerLevel2["INFO"] = 1] = "INFO"; LoggerLevel2[LoggerLevel2["WARN"] = 2] = "WARN"; LoggerLevel2[LoggerLevel2["ERROR"] = 3] = "ERROR"; LoggerLevel2[LoggerLevel2["SILENCE"] = 4] = "SILENCE"; })(LoggerLevel || (LoggerLevel = {})); const prefix = `[vot.js v${votConfig.version}]`; function canLog(level) { return votConfig.loggerLevel <= level; } function log$1(...messages) { if (!canLog(LoggerLevel.DEBUG)) { return; } console.log(prefix, ...messages); } function info(...messages) { if (!canLog(LoggerLevel.INFO)) { return; } console.info(prefix, ...messages); } function warn$1(...messages) { if (!canLog(LoggerLevel.WARN)) { return; } console.warn(prefix, ...messages); } function error$1(...messages) { if (!canLog(LoggerLevel.ERROR)) { return; } console.error(prefix, ...messages); } const Logger = { canLog, log: log$1, info, warn: warn$1, error: error$1 }; const { componentVersion } = votConfig; async function getCrypto() { if (typeof window !== "undefined" && window.crypto) { return window.crypto; } return await __vitePreload(() => module.import('./__vite-browser-external-2Ng8QIWW-Xya9USxv.js'), void 0 ); } const utf8Encoder = new TextEncoder(); async function signHMAC(hashName, hmac, data) { const crypto2 = await getCrypto(); const key = await crypto2.subtle.importKey("raw", utf8Encoder.encode(hmac), { name: "HMAC", hash: { name: hashName } }, false, ["sign", "verify"]); return await crypto2.subtle.sign("HMAC", key, data); } async function getSignature(body) { const signature = await signHMAC("SHA-256", votConfig.hmac, body); return new Uint8Array(signature).reduce((str, byte) => str + byte.toString(16).padStart(2, "0"), ""); } async function getSecYaHeaders(secType, session, body, path) { const { secretKey, uuid } = session; const token = `${uuid}:${path}:${componentVersion}`; const tokenBody = utf8Encoder.encode(token); const tokenSign = await getSignature(tokenBody); if (secType === "Ya-Summary") { return { [`X-${secType}-Sk`]: secretKey, [`X-${secType}-Token`]: `${tokenSign}:${token}` }; } if (!body) { throw new TypeError(`Body is required for sec type ${secType}`); } const sign = await getSignature(body); return { [`${secType}-Signature`]: sign, [`Sec-${secType}-Sk`]: secretKey, [`Sec-${secType}-Token`]: `${tokenSign}:${token}` }; } function getUUID() { const hexDigits = "0123456789ABCDEF"; let uuid = ""; for (let i2 = 0; i2 < 32; i2++) { const randomDigit = Math.floor(Math.random() * 16); uuid += hexDigits[randomDigit]; } return uuid; } async function getHmacSha1(hmacKey, salt) { try { const hmacSalt = utf8Encoder.encode(salt); const signature = await signHMAC("SHA-1", hmacKey, hmacSalt); return btoa(String.fromCharCode(...new Uint8Array(signature))); } catch (err) { Logger.error(err); return false; } } const browserSecHeaders = { "sec-ch-ua": `"Chromium";v="142", "YaBrowser";v="${componentVersion.slice(0, 5)}", "Not?A_Brand";v="24", "Yowser";v="2.5"`, "sec-ch-ua-full-version-list": `"Chromium";v="142.0.7444.59", "YaBrowser";v="${componentVersion}", "Not?A_Brand";v="24.0.0.0", "Yowser";v="2.5"`, "Sec-Fetch-Mode": "no-cors" }; const iso6392to6391 = { afr: "af", aka: "ak", alb: "sq", amh: "am", ara: "ar", arm: "hy", asm: "as", aym: "ay", aze: "az", baq: "eu", bel: "be", ben: "bn", bos: "bs", bul: "bg", bur: "my", cat: "ca", chi: "zh", cos: "co", cze: "cs", dan: "da", div: "dv", dut: "nl", eng: "en", epo: "eo", est: "et", ewe: "ee", fin: "fi", fre: "fr", fry: "fy", geo: "ka", ger: "de", gla: "gd", gle: "ga", glg: "gl", gre: "el", grn: "gn", guj: "gu", hat: "ht", hau: "ha", hin: "hi", hrv: "hr", hun: "hu", ibo: "ig", ice: "is", ind: "id", ita: "it", jav: "jv", jpn: "ja", kan: "kn", kaz: "kk", khm: "km", kin: "rw", kir: "ky", kor: "ko", kur: "ku", lao: "lo", lat: "la", lav: "lv", lin: "ln", lit: "lt", ltz: "lb", lug: "lg", mac: "mk", mal: "ml", mao: "mi", mar: "mr", may: "ms", mlg: "mg", mlt: "mt", mon: "mn", nep: "ne", nor: "no", nya: "ny", ori: "or", orm: "om", pan: "pa", per: "fa", pol: "pl", por: "pt", pus: "ps", que: "qu", rum: "ro", rus: "ru", san: "sa", sin: "si", slo: "sk", slv: "sl", smo: "sm", sna: "sn", snd: "sd", som: "so", sot: "st", spa: "es", srp: "sr", sun: "su", swa: "sw", swe: "sv", tam: "ta", tat: "tt", tel: "te", tgk: "tg", tha: "th", tir: "ti", tso: "ts", tuk: "tk", tur: "tr", uig: "ug", ukr: "uk", urd: "ur", uzb: "uz", vie: "vi", wel: "cy", xho: "xh", yid: "yi", yor: "yo", zul: "zu" }; async function fetchWithTimeout(url, options = { headers: { "User-Agent": votConfig.userAgent } }) { const { timeout: timeout2 = 3e3, signal, ...fetchOptions } = options; if (!signal && (!timeout2 || timeout2 <= 0)) { return await fetch(url, fetchOptions); } const controller = new AbortController(); const abort = (reason) => { if (!controller.signal.aborted) { controller.abort(reason); } }; if (signal) { if (signal.aborted) { abort(signal.reason); } else { signal.addEventListener("abort", () => abort(signal.reason), { once: true }); } } let timeoutId; if (timeout2 && timeout2 > 0) { timeoutId = setTimeout(() => abort(new Error("Fetch timeout")), timeout2); } try { return await fetch(url, { ...fetchOptions, signal: controller.signal }); } finally { if (timeoutId) { clearTimeout(timeoutId); } } } function getTimestamp$1() { return Math.floor(Date.now() / 1e3); } function normalizeLang$1(lang2) { if (lang2.length === 3) { return iso6392to6391[lang2]; } return lang2.toLowerCase().split(/[_;-]/)[0].trim(); } function proxyMedia(url, format = "mp4") { const generalUrl = `https://${votConfig.mediaProxy}/v1/proxy/video.${format}?format=base64&force=true`; if (!(url instanceof URL)) { return `${generalUrl}&url=${btoa(url)}`; } return `${generalUrl}&url=${btoa(url.href)}&origin=${url.origin}&referer=${url.origin}`; } function buildVkVideoUrl(videoId, sourceUrl) { const protocol = "https:"; const hostname = sourceUrl.hostname.replace(/^m\./, ""); const cleanedVideoId = videoId.replace(/^\/+/, ""); const canonicalHost = hostname.endsWith("vkvideo.ru") ? "vkvideo.ru" : hostname.endsWith("vk.com") || hostname.endsWith("vk.ru") ? "vk.com" : hostname; const pathname = sourceUrl.pathname.replace(/\/+$/, ""); const pathAlreadyContainsId = /^\/(?:video|clip)-?\d+_\d+$/.test(pathname); const base = pathAlreadyContainsId ? `${protocol}//${canonicalHost}${pathname}` : `${protocol}//${canonicalHost}/${cleanedVideoId}`; const out = new URL(base); for (const key of ["list", "access_key"]) { const value = sourceUrl.searchParams.get(key); if (value) { out.searchParams.set(key, value); } } return out.toString(); } function encodeTranslationRequest(url, duration, requestLang, responseLang, translationHelp, { forceSourceLang = false, wasStream = false, videoTitle = "", bypassCache = false, useLivelyVoice = false, firstRequest = true } = {}) { return VideoTranslationRequest.encode({ url, firstRequest, duration, unknown0: 1, language: requestLang, forceSourceLang, unknown1: 0, translationHelp: translationHelp ?? [], responseLanguage: responseLang, wasStream, unknown2: 1, unknown3: 2, bypassCache, useLivelyVoice, videoTitle }).finish(); } function decodeTranslationResponse(response) { return VideoTranslationResponse.decode(new Uint8Array(response)); } function encodeTranslationCacheRequest(url, duration, requestLang, responseLang) { return VideoTranslationCacheRequest.encode({ url, duration, language: requestLang, responseLanguage: responseLang }).finish(); } function decodeTranslationCacheResponse(response) { return VideoTranslationCacheResponse.decode(new Uint8Array(response)); } function isPartialAudioBuffer(audioBuffer) { return "chunkId" in audioBuffer; } function encodeTranslationAudioRequest(url, translationId, audioBuffer, partialAudio) { if (partialAudio && isPartialAudioBuffer(audioBuffer)) { return VideoTranslationAudioRequest.encode({ url, translationId, partialAudioInfo: { ...partialAudio, audioBuffer } }).finish(); } return VideoTranslationAudioRequest.encode({ url, translationId, audioInfo: audioBuffer }).finish(); } function decodeTranslationAudioResponse(response) { return VideoTranslationAudioResponse.decode(new Uint8Array(response)); } function encodeSubtitlesRequest(url, requestLang) { return SubtitlesRequest.encode({ url, language: requestLang }).finish(); } function decodeSubtitlesResponse(response) { return SubtitlesResponse.decode(new Uint8Array(response)); } function encodeStreamPingRequest(pingId) { return StreamPingRequest.encode({ pingId }).finish(); } function encodeStreamRequest(url, requestLang, responseLang) { return StreamTranslationRequest.encode({ url, language: requestLang, responseLanguage: responseLang, unknown0: 1, unknown1: 0 }).finish(); } function decodeStreamResponse(response) { return StreamTranslationResponse.decode(new Uint8Array(response)); } const YandexVOTProtobuf = { encodeTranslationRequest, decodeTranslationResponse, encodeTranslationCacheRequest, decodeTranslationCacheResponse, isPartialAudioBuffer, encodeTranslationAudioRequest, decodeTranslationAudioResponse, encodeSubtitlesRequest, decodeSubtitlesResponse, encodeStreamPingRequest, encodeStreamRequest, decodeStreamResponse }; function encodeSessionRequest(uuid, module) { return YandexSessionRequest.encode({ uuid, module }).finish(); } function decodeSessionResponse(response) { return YandexSessionResponse.decode(new Uint8Array(response)); } const YandexSessionProtobuf = { encodeSessionRequest, decodeSessionResponse }; var VideoTranslationStatus; (function(VideoTranslationStatus2) { VideoTranslationStatus2[VideoTranslationStatus2["FAILED"] = 0] = "FAILED"; VideoTranslationStatus2[VideoTranslationStatus2["FINISHED"] = 1] = "FINISHED"; VideoTranslationStatus2[VideoTranslationStatus2["WAITING"] = 2] = "WAITING"; VideoTranslationStatus2[VideoTranslationStatus2["LONG_WAITING"] = 3] = "LONG_WAITING"; VideoTranslationStatus2[VideoTranslationStatus2["PART_CONTENT"] = 5] = "PART_CONTENT"; VideoTranslationStatus2[VideoTranslationStatus2["AUDIO_REQUESTED"] = 6] = "AUDIO_REQUESTED"; VideoTranslationStatus2[VideoTranslationStatus2["SESSION_REQUIRED"] = 7] = "SESSION_REQUIRED"; })(VideoTranslationStatus || (VideoTranslationStatus = {})); var AudioDownloadType; (function(AudioDownloadType2) { AudioDownloadType2["WEB_API_VIDEO_SRC_FROM_IFRAME"] = "web_api_video_src_from_iframe"; AudioDownloadType2["WEB_API_VIDEO_SRC"] = "web_api_video_src"; AudioDownloadType2["WEB_API_GET_ALL_GENERATING_URLS_DATA_FROM_IFRAME"] = "web_api_get_all_generating_urls_data_from_iframe"; AudioDownloadType2["WEB_API_GET_ALL_GENERATING_URLS_DATA_FROM_IFRAME_TMP_EXP"] = "web_api_get_all_generating_urls_data_from_iframe_tmp_exp"; AudioDownloadType2["WEB_API_REPLACED_FETCH_INSIDE_IFRAME"] = "web_api_replaced_fetch_inside_iframe"; AudioDownloadType2["ANDROID_API"] = "android_api"; AudioDownloadType2["WEB_API_SLOW"] = "web_api_slow"; AudioDownloadType2["WEB_API_STEAL_SIG_AND_N"] = "web_api_steal_sig_and_n"; AudioDownloadType2["WEB_API_COMBINED"] = "web_api_get_all_generating_urls_data_from_iframe,web_api_steal_sig_and_n"; })(AudioDownloadType || (AudioDownloadType = {})); var VideoService; (function(VideoService2) { VideoService2["custom"] = "custom"; VideoService2["directlink"] = "custom"; VideoService2["youtube"] = "youtube"; VideoService2["piped"] = "piped"; VideoService2["invidious"] = "invidious"; VideoService2["niconico"] = "niconico"; VideoService2["vk"] = "vk"; VideoService2["nine_gag"] = "nine_gag"; VideoService2["gag"] = "nine_gag"; VideoService2["twitch"] = "twitch"; VideoService2["proxitok"] = "proxitok"; VideoService2["tiktok"] = "tiktok"; VideoService2["vimeo"] = "vimeo"; VideoService2["xvideos"] = "xvideos"; VideoService2["xhamster"] = "xhamster"; VideoService2["spankbang"] = "spankbang"; VideoService2["rule34video"] = "rule34video"; VideoService2["picarto"] = "picarto"; VideoService2["olympicsreplay"] = "olympics_replay"; VideoService2["pornhub"] = "pornhub"; VideoService2["twitter"] = "twitter"; VideoService2["x"] = "twitter"; VideoService2["rumble"] = "rumble"; VideoService2["facebook"] = "facebook"; VideoService2["rutube"] = "rutube"; VideoService2["coub"] = "coub"; VideoService2["bilibili"] = "bilibili"; VideoService2["mail_ru"] = "mailru"; VideoService2["mailru"] = "mailru"; VideoService2["bitchute"] = "bitchute"; VideoService2["eporner"] = "eporner"; VideoService2["peertube"] = "peertube"; VideoService2["dailymotion"] = "dailymotion"; VideoService2["trovo"] = "trovo"; VideoService2["yandexdisk"] = "yandexdisk"; VideoService2["ok_ru"] = "okru"; VideoService2["okru"] = "okru"; VideoService2["googledrive"] = "googledrive"; VideoService2["bannedvideo"] = "bannedvideo"; VideoService2["weverse"] = "weverse"; VideoService2["weibo"] = "weibo"; VideoService2["newgrounds"] = "newgrounds"; VideoService2["egghead"] = "egghead"; VideoService2["youku"] = "youku"; VideoService2["archive"] = "archive"; VideoService2["kodik"] = "kodik"; VideoService2["patreon"] = "patreon"; VideoService2["reddit"] = "reddit"; VideoService2["kick"] = "kick"; VideoService2["apple_developer"] = "apple_developer"; VideoService2["appledeveloper"] = "apple_developer"; VideoService2["epicgames"] = "epicgames"; VideoService2["odysee"] = "odysee"; VideoService2["coursehunterLike"] = "coursehunterLike"; VideoService2["sap"] = "sap"; VideoService2["watchpornto"] = "watchpornto"; VideoService2["linkedin"] = "linkedin"; VideoService2["incestflix"] = "incestflix"; VideoService2["porntn"] = "porntn"; VideoService2["dzen"] = "dzen"; VideoService2["cloudflarestream"] = "cloudflarestream"; VideoService2["loom"] = "loom"; VideoService2["rtnews"] = "rtnews"; VideoService2["bitview"] = "bitview"; VideoService2["thisvid"] = "thisvid"; VideoService2["ign"] = "ign"; VideoService2["zdf"] = "zdf"; VideoService2["bunkr"] = "bunkr"; VideoService2["imdb"] = "imdb"; VideoService2["telegram"] = "telegram"; })(VideoService || (VideoService = {})); function convertVOT(service, videoId, url) { if (service === VideoService.patreon) { return { service: "mux", videoId: new URL(url).pathname.slice(1) }; } return { service, videoId }; } class VOTJSError extends Error { data; constructor(message, data = void 0) { super(message); this.data = data; this.name = "VOTJSError"; } } class MinimalClient { host; schema; fetch; fetchOpts; sessions = {}; userAgent = votConfig.userAgent; headers = { "User-Agent": this.userAgent, Accept: "application/x-protobuf", "Accept-Language": "en", "Content-Type": "application/x-protobuf", Pragma: "no-cache", "Cache-Control": "no-cache" }; hostSchemaRe = /(http(s)?):\/\//; constructor({ host = votConfig.host, fetchFn = fetchWithTimeout, fetchOpts = {}, headers = {} } = {}) { const schema = this.hostSchemaRe.exec(host)?.[1]; this.host = schema ? host.replace(`${schema}://`, "") : host; this.schema = schema ?? "https"; this.fetch = fetchFn; this.fetchOpts = fetchOpts; this.headers = { ...this.headers, ...headers }; } async request(path, body, headers = {}, method = "POST") { const options = this.getOpts(new Blob([body]), headers, method); try { const res = await this.fetch(`${this.schema}://${this.host}${path}`, options); const data = await res.arrayBuffer(); return { success: res.status === 200, data }; } catch (err) { return { success: false, data: err?.message }; } } async requestJSON(path, body = null, headers = {}, method = "POST") { const options = this.getOpts(body, { "Content-Type": "application/json", ...headers }, method); try { const res = await this.fetch(`${this.schema}://${this.host}${path}`, options); const data = await res.json(); return { success: res.status === 200, data }; } catch (err) { return { success: false, data: err?.message }; } } getOpts(body, headers = {}, method = "POST") { return { method, headers: { ...this.headers, ...headers }, body, ...this.fetchOpts }; } async getSession(module) { const timestamp = getTimestamp$1(); const session = this.sessions[module]; if (session && session.timestamp + session.expires > timestamp) { return session; } const { secretKey, expires, uuid } = await this.createSession(module); this.sessions[module] = { secretKey, expires, timestamp, uuid }; return this.sessions[module]; } async createSession(module) { const uuid = getUUID(); const body = YandexSessionProtobuf.encodeSessionRequest(uuid, module); const res = await this.request("/session/create", body, { "Vtrans-Signature": await getSignature(body) }); if (!res.success) { throw new VOTJSError("Failed to request create session", res); } const sessionResponse = YandexSessionProtobuf.decodeSessionResponse(res.data); return { ...sessionResponse, uuid }; } } let VOTClient$1 = class VOTClient extends MinimalClient { hostVOT; schemaVOT; apiToken; requestLang; responseLang; paths = { videoTranslation: "/video-translation/translate", videoTranslationFailAudio: "/video-translation/fail-audio-js", videoTranslationAudio: "/video-translation/audio", videoTranslationCache: "/video-translation/cache", videoSubtitles: "/video-subtitles/get-subtitles", streamPing: "/stream-translation/ping-stream", streamTranslation: "/stream-translation/translate-stream" }; isCustomLink(url) { return !!(/\.(m3u8|m4(a|v)|mpd)/.exec(url) ?? /^https:\/\/cdn\.qstv\.on\.epicgames\.com/.exec(url)); } headersVOT = { "User-Agent": `vot.js/${votConfig.version}`, "Content-Type": "application/json", Pragma: "no-cache", "Cache-Control": "no-cache" }; constructor({ host, hostVOT = votConfig.hostVOT, fetchFn, fetchOpts, requestLang = "en", responseLang = "ru", apiToken, headers } = {}) { super({ host, fetchFn, fetchOpts, headers }); const schemaVOT = this.hostSchemaRe.exec(hostVOT)?.[1]; this.hostVOT = schemaVOT ? hostVOT.replace(`${schemaVOT}://`, "") : hostVOT; this.schemaVOT = schemaVOT ?? "https"; this.requestLang = requestLang; this.responseLang = responseLang; this.apiToken = apiToken; } get apiTokenHeader() { if (!this.apiToken) { return {}; } return { Authorization: `OAuth ${this.apiToken}` }; } async requestVOT(path, body, headers = {}) { const options = this.getOpts(JSON.stringify(body), { ...this.headersVOT, ...headers }); try { const res = await this.fetch(`${this.schemaVOT}://${this.hostVOT}${path}`, options); const data = await res.json(); return { success: res.status === 200, data }; } catch (err) { return { success: false, data: err?.message }; } } async translateVideoYAImpl({ videoData, requestLang = this.requestLang, responseLang = this.responseLang, translationHelp = null, headers = {}, extraOpts = {}, shouldSendFailedAudio = true }) { const { url, duration = votConfig.defaultDuration } = videoData; const session = await this.getSession("video-translation"); const body = YandexVOTProtobuf.encodeTranslationRequest(url, duration, requestLang, responseLang, translationHelp, extraOpts); const path = this.paths.videoTranslation; const vtransHeaders = await getSecYaHeaders("Vtrans", session, body, path); const apiTokenHeader = extraOpts.useLivelyVoice ? this.apiTokenHeader : {}; const res = await this.request(path, body, { ...vtransHeaders, ...apiTokenHeader, ...headers }); if (!res.success) { throw new VOTJSError("Failed to request video translation", res); } const translationData = YandexVOTProtobuf.decodeTranslationResponse(res.data); Logger.log("translateVideo", translationData); const { status, translationId } = translationData; switch (status) { case VideoTranslationStatus.FAILED: throw new VOTJSError("Yandex couldn't translate video", translationData); case VideoTranslationStatus.FINISHED: case VideoTranslationStatus.PART_CONTENT: if (!translationData.url) { throw new VOTJSError("Audio link wasn't received from Yandex response", translationData); } return { translationId, translated: true, url: translationData.url, status, remainingTime: translationData.remainingTime ?? -1 }; case VideoTranslationStatus.WAITING: case VideoTranslationStatus.LONG_WAITING: return { translationId, translated: false, status, remainingTime: translationData.remainingTime ?? -1 }; case VideoTranslationStatus.AUDIO_REQUESTED: if (url.startsWith("https://youtu.be/") && shouldSendFailedAudio) { await this.requestVtransFailAudio(url); await this.requestVtransAudio(url, translationData.translationId, { audioFile: new Uint8Array(), fileId: AudioDownloadType.WEB_API_GET_ALL_GENERATING_URLS_DATA_FROM_IFRAME }); return await this.translateVideoYAImpl({ videoData, requestLang, responseLang, translationHelp, headers, shouldSendFailedAudio: false }); } return { translationId, translated: false, status, remainingTime: translationData.remainingTime ?? -1 }; case VideoTranslationStatus.SESSION_REQUIRED: throw new VOTJSError("Yandex auth required to translate video. See docs for more info", translationData); default: Logger.error("Unknown response", translationData); throw new VOTJSError("Unknown response from Yandex", translationData); } } async translateVideoVOTImpl({ url, videoId, service, requestLang = this.requestLang, responseLang = this.responseLang, headers = {}, provider = "yandex" }) { const votData = convertVOT(service, videoId, url); const res = await this.requestVOT(this.paths.videoTranslation, { provider, service: votData.service, video_id: votData.videoId, from_lang: requestLang, to_lang: responseLang, raw_video: url }, { ...headers }); if (!res.success) { throw new VOTJSError("Failed to request video translation", res); } const translationData = res.data; switch (translationData.status) { case "failed": throw new VOTJSError("Yandex couldn't translate video", translationData); case "success": if (!translationData.translated_url) { throw new VOTJSError("Audio link wasn't received from VOT response", translationData); } return { translationId: String(translationData.id), translated: true, url: translationData.translated_url, status: 1, remainingTime: -1 }; case "waiting": return { translationId: "", translated: false, remainingTime: translationData.remaining_time, status: 2, message: translationData.message }; } } async requestVtransFailAudio(url) { const res = await this.requestJSON(this.paths.videoTranslationFailAudio, JSON.stringify({ video_url: url }), void 0, "PUT"); if (!res.data || typeof res.data === "string" || res.data.status !== 1) { throw new VOTJSError("Failed to request to fake video translation fail audio js", res); } return res; } async requestVtransAudio(url, translationId, audioBuffer, partialAudio, headers = {}) { const session = await this.getSession("video-translation"); let body; if (YandexVOTProtobuf.isPartialAudioBuffer(audioBuffer)) { if (!partialAudio) { throw new VOTJSError("Partial audio metadata is required for partial audio buffer", audioBuffer); } body = YandexVOTProtobuf.encodeTranslationAudioRequest(url, translationId, audioBuffer, partialAudio); } else { body = YandexVOTProtobuf.encodeTranslationAudioRequest(url, translationId, audioBuffer, void 0); } const path = this.paths.videoTranslationAudio; const vtransHeaders = await getSecYaHeaders("Vtrans", session, body, path); const res = await this.request(path, body, { ...vtransHeaders, ...headers }, "PUT"); if (!res.success) { throw new VOTJSError("Failed to request video translation audio", res); } return YandexVOTProtobuf.decodeTranslationAudioResponse(res.data); } async translateVideoCache({ videoData, requestLang = this.requestLang, responseLang = this.responseLang, headers = {} }) { const { url, duration = votConfig.defaultDuration } = videoData; const session = await this.getSession("video-translation"); const body = YandexVOTProtobuf.encodeTranslationCacheRequest(url, duration, requestLang, responseLang); const path = this.paths.videoTranslationCache; const vtransHeaders = await getSecYaHeaders("Vtrans", session, body, path); const res = await this.request(path, body, { ...vtransHeaders, ...headers }, "POST"); if (!res.success) { throw new VOTJSError("Failed to request video translation cache", res); } return YandexVOTProtobuf.decodeTranslationCacheResponse(res.data); } async translateVideo({ videoData, requestLang = this.requestLang, responseLang = this.responseLang, translationHelp = null, headers = {}, extraOpts = {}, shouldSendFailedAudio = true }) { const { url, videoId, host } = videoData; return this.isCustomLink(url) ? await this.translateVideoVOTImpl({ url, videoId, service: host, requestLang, responseLang, headers, provider: extraOpts.useLivelyVoice ? "yandex_lively" : "yandex" }) : await this.translateVideoYAImpl({ videoData, requestLang, responseLang, translationHelp, headers, extraOpts, shouldSendFailedAudio }); } async getSubtitlesYAImpl({ videoData, requestLang = this.requestLang, headers = {} }) { const { url } = videoData; const session = await this.getSession("video-translation"); const body = YandexVOTProtobuf.encodeSubtitlesRequest(url, requestLang); const path = this.paths.videoSubtitles; const vsubsHeaders = await getSecYaHeaders("Vsubs", session, body, path); const res = await this.request(path, body, { ...vsubsHeaders, ...headers }); if (!res.success) { throw new VOTJSError("Failed to request video subtitles", res); } const subtitlesData = YandexVOTProtobuf.decodeSubtitlesResponse(res.data); const subtitles = subtitlesData.subtitles.map((subtitle) => { const { language, url: url2, translatedLanguage, translatedUrl } = subtitle; return { language, url: url2, translatedLanguage, translatedUrl }; }); return { waiting: subtitlesData.waiting, subtitles }; } async getSubtitlesVOTImpl({ url, videoId, service, headers = {} }) { const votData = convertVOT(service, videoId, url); const res = await this.requestVOT(this.paths.videoSubtitles, { provider: "yandex", service: votData.service, video_id: votData.videoId }, headers); if (!res.success) { throw new VOTJSError("Failed to request video subtitles", res); } const subtitlesData = res.data; const subtitles = subtitlesData.reduce((result, subtitle) => { if (!subtitle.lang_from) { return result; } const originalSubtitle = subtitlesData.find((sub) => sub.lang === subtitle.lang_from); if (!originalSubtitle) { return result; } result.push({ language: originalSubtitle.lang, url: originalSubtitle.subtitle_url, translatedLanguage: subtitle.lang, translatedUrl: subtitle.subtitle_url }); return result; }, []); return { waiting: false, subtitles }; } async getSubtitles({ videoData, requestLang = this.requestLang, headers = {} }) { const { url, videoId, host } = videoData; return this.isCustomLink(url) ? await this.getSubtitlesVOTImpl({ url, videoId, service: host, headers }) : await this.getSubtitlesYAImpl({ videoData, requestLang, headers }); } async pingStream({ pingId, headers = {} }) { const session = await this.getSession("video-translation"); const body = YandexVOTProtobuf.encodeStreamPingRequest(pingId); const path = this.paths.streamPing; const vtransHeaders = await getSecYaHeaders("Vtrans", session, body, path); const res = await this.request(path, body, { ...vtransHeaders, ...headers }); if (!res.success) { throw new VOTJSError("Failed to request stream ping", res); } return true; } async translateStream({ videoData, requestLang = this.requestLang, responseLang = this.responseLang, headers = {} }) { const { url } = videoData; if (this.isCustomLink(url)) { throw new VOTJSError("Unsupported video URL for getting stream translation"); } const session = await this.getSession("video-translation"); const body = YandexVOTProtobuf.encodeStreamRequest(url, requestLang, responseLang); const path = this.paths.streamTranslation; const vtransHeaders = await getSecYaHeaders("Vtrans", session, body, path); const res = await this.request(path, body, { ...vtransHeaders, ...headers }); if (!res.success) { throw new VOTJSError("Failed to request stream translation", res); } const translateResponse = YandexVOTProtobuf.decodeStreamResponse(res.data); const interval = translateResponse.interval; switch (interval) { case StreamInterval.NO_CONNECTION: case StreamInterval.TRANSLATING: return { translated: false, interval, message: interval === StreamInterval.NO_CONNECTION ? "streamNoConnectionToServer" : "translationTakeFewMinutes" }; case StreamInterval.STREAMING: { if (translateResponse.pingId === void 0) { throw new VOTJSError("Stream ping id wasn't received from Yandex response", translateResponse); } return { translated: true, interval, pingId: translateResponse.pingId, result: translateResponse.translatedInfo }; } default: Logger.error("Unknown response", translateResponse); throw new VOTJSError("Unknown response from Yandex", translateResponse); } } }; let VOTWorkerClient$1 = class VOTWorkerClient extends VOTClient$1 { constructor(opts = {}) { opts.host = opts.host ?? votConfig.hostWorker; super(opts); } async request(path, body, headers = {}, method = "POST") { const options = this.getOpts(JSON.stringify({ headers: { ...this.headers, ...headers }, body: Array.from(body) }), { "Content-Type": "application/json" }, method); try { const res = await this.fetch(`${this.schema}://${this.host}${path}`, options); const data = await res.arrayBuffer(); return { success: res.status === 200, data }; } catch (err) { return { success: false, data: err?.message }; } } async requestJSON(path, body = null, headers = {}, method = "POST") { const options = this.getOpts(JSON.stringify({ headers: { ...this.headers, "Content-Type": "application/json", Accept: "application/json", ...headers }, body }), { Accept: "application/json", "Content-Type": "application/json" }, method); try { const res = await this.fetch(`${this.schema}://${this.host}${path}`, options); const data = await res.json(); return { success: res.status === 200, data }; } catch (err) { return { success: false, data: err?.message }; } } }; class VOTClient2 extends VOTClient$1 { constructor(opts) { super(opts); this.headers = { ...browserSecHeaders, ...this.headers }; } } class VOTWorkerClient2 extends VOTWorkerClient$1 { constructor(opts) { super(opts); this.headers = { ...browserSecHeaders, ...this.headers }; } } class VideoDataError extends Error { constructor(message) { super(message); this.name = "VideoDataError"; } } const localLinkRe = /(file:\/\/(\/)?|(http(s)?:\/\/)(127\.0\.0\.1|localhost|192\.168\.(\d){1,3}\.(\d){1,3}))/; const sitesInvidious = [ "yewtu.be", "inv.nadeko.net", "invidious.nerdvpn.de", "invidious.protokolla.fi", "invidious.materialio.us", "iv.melmac.space" ]; const sitesPiped = [ "piped.video", "piped.kavin.rocks", "piped.private.coffee" ]; const sitesProxiTok = [ "proxitok.pabloferreiro.es", "proxitok.pussthecat.org", "tok.habedieeh.re", "proxitok.esmailelbob.xyz", "proxitok.privacydev.net", "tok.artemislena.eu", "tok.adminforge.de", "tt.vern.cc", "cringe.whatever.social", "proxitok.lunar.icu", "proxitok.privacy.com.de" ]; const sitesPeertube = [ "peertube.tmp.rcp.tf", "dalek.zone", "video.sadmin.io", "videos.viorsan.com", "peertube.1312.media", "tube.shanti.cafe", "bee-tube.fr", "video.blender.org", "beetoons.tv", "makertube.net", "peertube.tv", "framatube.org", "tilvids.com", "diode.zone", "fedimovie.com", "video.hardlimit.com", "share.tube", "peervideo.club" ]; const sitesCoursehunterLike = ["coursehunter.net", "coursetrain.net"]; var ExtVideoService; (function(ExtVideoService2) { ExtVideoService2["udemy"] = "udemy"; ExtVideoService2["coursera"] = "coursera"; ExtVideoService2["douyin"] = "douyin"; ExtVideoService2["artstation"] = "artstation"; ExtVideoService2["kickstarter"] = "kickstarter"; ExtVideoService2["oraclelearn"] = "oraclelearn"; ExtVideoService2["deeplearningai"] = "deeplearningai"; ExtVideoService2["netacad"] = "netacad"; })(ExtVideoService || (ExtVideoService = {})); ({ ...VideoService, ...ExtVideoService }); const sharedSelectors = { bilibiliPlayer: ".bpx-player-video-wrap, div.player-mobile-box.player-mobile-autoplay", flowplayer: ".fp-player", idPlayer: "#player", jwPlayer: ".jwplayer, .jw-media", player: ".player", videoJsUniversal: "video-js, .video-js:not(video), .vjs-player, [data-vjs-player], [id^='vjs_video_']:not(video)", vkVideoPlayer: "vk-video-player" }; const sites = [ { additionalData: "mobile", host: VideoService.youtube, url: "https://youtu.be/", match: /^m.youtube.com$/, selector: ".player-container", needExtraData: true }, { host: VideoService.youtube, url: "https://youtu.be/", match: /^(www.)?youtube(-nocookie|kids)?.com$/, selector: ".html5-video-container:not(#inline-player *)", needExtraData: true }, { host: VideoService.invidious, url: "https://youtu.be/", match: sitesInvidious, selector: sharedSelectors.idPlayer, needBypassCSP: true }, { host: VideoService.piped, url: "https://youtu.be/", match: sitesPiped, selector: ".shaka-video-container", needBypassCSP: true }, { host: VideoService.zdf, url: "https://www.zdf.de/play/", match: [/^zdf.de$/, /^(www.)?zdf.de$/], selector: "div.zdfplayer-app.zdfplayer-desktop, div.zdfplayer-app" }, { host: VideoService.niconico, url: "https://www.nicovideo.jp/watch/", match: [/^(www\.|sp\.)?nicovideo\.jp$/, /^nico\.ms$/], selector: `[class*="grid-area_[player]"] > div` }, { additionalData: "mobile", host: VideoService.vk, url: "https://vk.com/video?z=", match: [/^m.vk.(com|ru)$/, /^m.vkvideo.ru$/], selector: sharedSelectors.vkVideoPlayer, shadowRoot: true, needExtraData: true }, { additionalData: "clips", host: VideoService.vk, url: "https://vk.com/video?z=", match: /^(www.|m.)?vk.(com|ru)$/, selector: 'div[data-testid="clipcontainer-video"]', needExtraData: true }, { host: VideoService.vk, url: "https://vk.com/video?z=", match: [/^(www\.|m\.)?vk\.(com|ru)$/, /^(.*\.)?vkvideo\.ru$/], selector: sharedSelectors.vkVideoPlayer, needExtraData: true }, { host: VideoService.nine_gag, url: "https://9gag.com/gag/", match: /^9gag.com$/, selector: ".video-post", needExtraData: true }, { host: VideoService.twitch, url: "https://twitch.tv/", match: [ /^m.twitch.tv$/, /^(www.)?twitch.tv$/, /^clips.twitch.tv$/, /^player.twitch.tv$/ ], needExtraData: true, selector: ".video-ref, main > div > section > div > div > div" }, { host: VideoService.proxitok, url: "https://www.tiktok.com/", match: sitesProxiTok, selector: ".column.has-text-centered" }, { host: VideoService.tiktok, url: "https://www.tiktok.com/", match: /^(www.)?tiktok.com$/, selector: null }, { host: ExtVideoService.douyin, url: "https://www.douyin.com/", match: /^(www.)?douyin.com/, selector: ".xg-video-container", needExtraData: true, needBypassCSP: true }, { host: VideoService.vimeo, url: "https://vimeo.com/", match: /^(www\.|m\.)?vimeo.com$/, needExtraData: true, selector: sharedSelectors.player }, { host: VideoService.vimeo, url: "https://player.vimeo.com/", match: /^player.vimeo.com$/, additionalData: "embed", needExtraData: true, needBypassCSP: true, selector: sharedSelectors.player }, { host: VideoService.xvideos, url: "https://www.xvideos.com/", match: [ /^(www.)?xvideos(-ar)?.com$/, /^(www.)?xvideos(\d\d\d).com$/, /^(www.)?xv-ru.com$/ ], selector: "#hlsplayer", needBypassCSP: true }, { host: VideoService.xhamster, url: "https://xhamster.com/", match: (url) => /^(?:[^.]+\.)?(?:xhamster\.(?:com|desi)|xhamster\d+\.(?:com|desi)|xhvid\.com)$/.test(url.host) && /\/(?:videos\/[^/]+-[\dA-Za-z]+)\/?$/.test(url.pathname), selector: "#player-container" }, { host: VideoService.spankbang, url: "https://spankbang.com/", match: (url) => /^(?:[^.]+\.)?spankbang\.com$/.test(url.host) && /\/(?:[\da-z]+\/(?:video|play|embed)(?:\/[^/]+)?|[\da-z]+-[\da-z]+\/playlist\/[^/?#&]+)\/?$/i.test(url.pathname), selector: "#main_video_player" }, { host: VideoService.rule34video, url: "https://rule34video.com/video/", match: (url) => /^(www\.)?rule34video\.com$/.test(url.host) && /\/videos?\/\d+/.test(url.pathname), selector: sharedSelectors.flowplayer }, { host: VideoService.picarto, url: "https://picarto.tv/", match: (url) => /^(www\.)?picarto\.tv$/.test(url.host) && /^(?:\/[^/]+\/(?:profile\/)?videos\/[^/?#&]+|\/videopopout\/[^/?#&]+|\/[^/#?]+\/?)$/.test(url.pathname), selector: `[class*="VideosTab__PlayerWrapper"]` }, { host: VideoService.olympicsreplay, url: "https://olympics.com/", match: (url) => /^(www\.)?olympics\.com$/.test(url.host) && /^\/[a-z]{2}\/(?:paris-2024\/)?(?:replay|videos?|original-series\/episode)\/[\w-]+\/?$/i.test(url.pathname), selector: sharedSelectors.videoJsUniversal }, { host: VideoService.pornhub, url: "https://rt.pornhub.com/view_video.php?viewkey=", match: /^[a-z]+.pornhub.(com|org)$/, selector: ".mainPlayerDiv > .video-element-wrapper-js > div", eventSelector: ".mgp_eventCatcher" }, { additionalData: "embed", host: VideoService.pornhub, url: "https://rt.pornhub.com/view_video.php?viewkey=", match: (url) => /^[a-z]+.pornhub.(com|org)$/.exec(url.host) && url.pathname.startsWith("/embed/"), selector: sharedSelectors.idPlayer }, { host: VideoService.twitter, url: "https://twitter.com/i/status/", match: /^(twitter|x).com$/, selector: 'div[data-testid="videoComponent"] > div:nth-child(1) > div', eventSelector: 'div[data-testid="videoPlayer"]', needBypassCSP: true }, { host: VideoService.rumble, url: "https://rumble.com/", match: /^rumble.com$/, selector: "#videoPlayer > .videoPlayer-Rumble-cls > div" }, { host: VideoService.facebook, url: "https://facebook.com/", match: (url) => url.host.includes("facebook.com") && url.pathname.includes("/videos/"), selector: 'div[role="main"] div[data-pagelet$="video" i]', needBypassCSP: true }, { additionalData: "reels", host: VideoService.facebook, url: "https://facebook.com/", match: (url) => url.host.includes("facebook.com") && url.pathname.includes("/reel/"), selector: 'div[role="main"]', needBypassCSP: true }, { host: VideoService.rutube, url: "https://rutube.ru/video/", match: /^rutube.ru$/, selector: ".video-player > div > div > div:nth-child(2)" }, { additionalData: "embed", host: VideoService.rutube, url: "https://rutube.ru/video/", match: /^rutube.ru$/, selector: "#app > div > div" }, { host: VideoService.bilibili, url: "https://www.bilibili.com/", match: /^(www|m|player).bilibili.com$/, selector: sharedSelectors.bilibiliPlayer }, { host: VideoService.bilibili, url: "https://www.bilibili.tv/", match: /^(?:www\.|m\.)?bilibili\.tv$/, selector: sharedSelectors.bilibiliPlayer }, { additionalData: "old", host: VideoService.bilibili, url: "https://www.bilibili.com/", match: /^(www|m).bilibili.com$/, selector: null }, { host: VideoService.mailru, url: "https://my.mail.ru/", match: /^my.mail.ru$/, selector: "#b-video-wrapper" }, { host: VideoService.bitchute, url: "https://www.bitchute.com/video/", match: /^(www.)?bitchute.com$/, selector: sharedSelectors.videoJsUniversal }, { host: VideoService.eporner, url: "https://www.eporner.com/", match: /^(www.)?eporner.com$/, selector: sharedSelectors.videoJsUniversal }, { host: VideoService.peertube, url: "stub", match: sitesPeertube, selector: sharedSelectors.videoJsUniversal }, { host: VideoService.dailymotion, url: "https://www.dailymotion.com/video/", match: /^((www\.)?dailymotion\.com|geo(\d+)?\.dailymotion\.com|dai\.ly)$/, selector: sharedSelectors.player }, { host: VideoService.trovo, url: "https://trovo.live/s/", match: /^trovo.live$/, selector: ".player-video" }, { host: VideoService.yandexdisk, url: "https://yadi.sk/", match: /^disk.yandex.(ru|kz|com(\.(am|ge|tr))?|by|az|co\.il|ee|lt|lv|md|net|tj|tm|uz)$/, selector: ".video-player__player > div:nth-child(1)", eventSelector: ".video-player__player", needBypassCSP: true, needExtraData: true }, { host: VideoService.okru, url: "https://ok.ru/video/", match: /^ok.ru$/, selector: sharedSelectors.vkVideoPlayer, shadowRoot: true }, { host: VideoService.googledrive, url: "https://drive.google.com/file/d/", match: /^youtube.googleapis.com$/, selector: ".html5-video-container" }, { host: VideoService.bannedvideo, url: "https://madmaxworld.tv/watch?id=", match: /^(www.)?banned.video|madmaxworld.tv$/, selector: sharedSelectors.videoJsUniversal, needExtraData: true }, { host: VideoService.weverse, url: "https://weverse.io/", match: /^weverse.io$/, selector: ".webplayer-internal-source-wrapper", needExtraData: true }, { host: VideoService.weibo, url: "https://weibo.com/", match: (url) => /^(?:www\.)?weibo\.com$/.test(url.host) && /^\/(?:\d+\/[A-Za-z0-9]+|0\/[A-Za-z0-9]+|tv\/show\/\d+:(?:[\da-f]{32}|\d{16,}))\/?$/.test(url.pathname) || /^video\.weibo\.com$/.test(url.host) && /^\/show\/?$/.test(url.pathname) && /^\d+:(?:[\da-f]{32}|\d{16,})$/i.test(url.searchParams.get("fid") ?? ""), selector: `#playVideo, sharedSelectors.videoJsUniversal` }, { host: VideoService.newgrounds, url: "https://www.newgrounds.com/", match: /^(www.)?newgrounds.com$/, selector: ".ng-video-player" }, { host: VideoService.egghead, url: "https://egghead.io/", match: /^egghead.io$/, selector: ".cueplayer-react-video-holder" }, { host: VideoService.youku, url: "https://v.youku.com/", match: /^v.youku.com$/, selector: "#ykPlayer" }, { host: VideoService.archive, url: "https://archive.org/details/", match: /^archive.org$/, selector: sharedSelectors.jwPlayer }, { host: VideoService.kodik, url: "stub", match: /^kodik.(info|biz|cc)$/, selector: sharedSelectors.flowplayer, needExtraData: true }, { host: VideoService.patreon, url: "stub", match: /^(www.)?patreon.com$/, selector: 'div[data-tag="post-card"] div[elevation="subtle"] > div > div > div > div', needExtraData: true }, { additionalData: "old", host: VideoService.reddit, url: "stub", match: /^old.reddit.com$/, selector: ".reddit-video-player-root", needExtraData: true, needBypassCSP: true }, { host: VideoService.reddit, url: "stub", match: /^(www.|new.)?reddit.com$/, selector: "div[slot=post-media-container]", shadowRoot: true, needExtraData: true, needBypassCSP: true }, { host: VideoService.kick, url: "https://kick.com/", match: /^kick.com$/, selector: "#injected-embedded-channel-player-video > div", needExtraData: true }, { host: VideoService.appledeveloper, url: "https://developer.apple.com/", match: /^developer.apple.com$/, selector: ".developer-video-player", needExtraData: true, needBypassCSP: true }, { host: VideoService.epicgames, url: "https://dev.epicgames.com/community/learning/", match: /^dev.epicgames.com$/, selector: sharedSelectors.videoJsUniversal, needExtraData: true }, { host: VideoService.odysee, url: "stub", match: /^odysee.com$/, selector: sharedSelectors.videoJsUniversal, needExtraData: true }, { host: VideoService.coursehunterLike, url: "stub", match: sitesCoursehunterLike, selector: "#oframeplayer > pjsdiv:has(video)", needExtraData: true }, { host: VideoService.sap, url: "https://learning.sap.com/courses/", match: /^learning.sap.com$/, selector: ".playkit-container", eventSelector: ".playkit-player", needExtraData: true, needBypassCSP: true }, { host: ExtVideoService.udemy, url: "https://www.udemy.com/", match: /udemy.com$/, selector: 'div[data-purpose="curriculum-item-viewer-content"] > section > div > div > div > div:nth-of-type(2)', needExtraData: true }, { host: ExtVideoService.coursera, url: "https://www.coursera.org/", match: /coursera.org$/, selector: sharedSelectors.videoJsUniversal, needExtraData: true }, { host: VideoService.watchpornto, url: "https://watchporn.to/", match: /^watchporn.to$/, selector: sharedSelectors.flowplayer }, { host: VideoService.linkedin, url: "https://www.linkedin.com/learning/", match: /^(www.)?linkedin.com$/, selector: sharedSelectors.videoJsUniversal, needExtraData: true, needBypassCSP: true }, { host: VideoService.incestflix, url: "https://www.incestflix.net/watch/", match: /^(www.)?incestflix.(net|to|com)$/, selector: "#incflix-stream", needExtraData: true }, { host: VideoService.porntn, url: "https://porntn.com/videos/", match: /^porntn.com$/, selector: sharedSelectors.flowplayer, needExtraData: true }, { host: VideoService.dzen, url: "https://dzen.ru/video/watch/", match: /^dzen.ru$/, selector: ".zen-ui-video-video-player" }, { host: VideoService.cloudflarestream, url: "stub", match: /^(watch|embed|iframe|customer-[^.]+).cloudflarestream.com$/, selector: null }, { host: VideoService.loom, url: "https://www.loom.com/share/", match: /^(www.)?loom.com$/, selector: ".VideoLayersContainer", needExtraData: true, needBypassCSP: true }, { host: ExtVideoService.artstation, url: "https://www.artstation.com/learning/", match: /^(www.)?artstation.com$/, selector: sharedSelectors.videoJsUniversal, needExtraData: true }, { host: VideoService.rtnews, url: "https://www.rt.com/", match: /^(www.)?rt.com$/, selector: sharedSelectors.jwPlayer, needExtraData: true }, { host: VideoService.bitview, url: "https://www.bitview.net/watch?v=", match: /^(www.)?bitview.net$/, selector: ".vlScreen", needExtraData: true }, { host: ExtVideoService.kickstarter, url: "https://www.kickstarter.com/", match: /^(www.)?kickstarter.com/, selector: ".ksr-video-player", needExtraData: true }, { host: VideoService.thisvid, url: "https://thisvid.com/", match: /^(www.)?thisvid.com$/, selector: sharedSelectors.flowplayer }, { additionalData: "regional", host: VideoService.ign, url: "https://de.ign.com/", match: /^(\w{2}.)?ign.com$/, needExtraData: true, selector: ".video-container" }, { host: VideoService.ign, url: "https://www.ign.com/", match: /^(www.)?ign.com$/, selector: sharedSelectors.player, needExtraData: true }, { host: VideoService.bunkr, url: "https://bunkr.site/", match: /^bunkr\.(site|black|cat|media|red|site|ws|org|s[kiu]|c[ir]|fi|p[hks]|ru|la|is|to|a[cx])$/, needExtraData: true, selector: ".plyr__video-wrapper" }, { host: VideoService.imdb, url: "https://www.imdb.com/video/", match: /^(www\.)?imdb\.com$/, selector: sharedSelectors.jwPlayer }, { host: VideoService.telegram, url: "https://t.me/", match: (url) => /^web\.telegram\.org$/.test(url.hostname) && url.pathname.startsWith("/k"), selector: ".ckin__player" }, { host: ExtVideoService.oraclelearn, url: "https://mylearn.oracle.com/ou/course/", match: /^mylearn\.oracle\.com/, selector: sharedSelectors.videoJsUniversal, needExtraData: true, needBypassCSP: true }, { host: ExtVideoService.deeplearningai, url: "https://learn.deeplearning.ai/courses/", match: /^learn(-dev|-staging)?\.deeplearning\.ai/, selector: ".lesson-video-player", needExtraData: true }, { host: ExtVideoService.netacad, url: "https://www.netacad.com/", match: /^(www\.)?netacad\.com/, selector: sharedSelectors.videoJsUniversal, needExtraData: true }, { host: VideoService.custom, url: "stub", match: (url) => /([^.]+)\.(mp4|webm)/.test(url.pathname), rawResult: true } ]; class VideoHelperError extends Error { constructor(message) { super(message); this.name = "VideoHelperError"; } } class BaseHelper { API_ORIGIN = window.location.origin; fetch; extraInfo; referer; origin; service; video; language; constructor({ fetchFn = fetchWithTimeout, extraInfo = true, referer = document.referrer ?? `${window.location.origin}/`, origin = window.location.origin, service, video, language = "en" } = {}) { this.fetch = fetchFn; this.extraInfo = extraInfo; this.referer = referer; this.origin = /^(http(s)?):\/\//.test(String(origin)) ? origin : window.location.origin; this.service = service; this.video = video; this.language = language; } getVideoData(_videoId) { return Promise.resolve(void 0); } getVideoId(_url) { return Promise.resolve(void 0); } returnBaseData(videoId) { if (!this.service) { return void 0; } return { url: this.service.url + videoId, videoId, host: this.service.host, duration: void 0 }; } } class AppleDeveloperHelper extends BaseHelper { API_ORIGIN = "https://developer.apple.com"; async getVideoData(videoId) { try { const contentUrl2 = document.querySelector("meta[property='og:video']")?.content; if (!contentUrl2) { throw new VideoHelperError("Failed to find content url"); } return { url: contentUrl2 }; } catch (err) { Logger.error(`Failed to get apple developer video data by video ID: ${videoId}`, err.message); return void 0; } } async getVideoId(url) { return /videos\/play\/([^/]+)\/([\d]+)/.exec(url.pathname)?.[0]; } } class ArchiveHelper extends BaseHelper { async getVideoId(url) { return /(details|embed)\/([^/]+)/.exec(url.pathname)?.[2]; } } class ArtstationHelper extends BaseHelper { API_ORIGIN = "https://www.artstation.com/api/v2/learning"; getCSRFToken() { return document.querySelector('meta[name="public-csrf-token"]')?.content; } async getCourseInfo(courseId) { try { const csrfToken = this.getCSRFToken(); const res = await this.fetch(`${this.API_ORIGIN}/courses/${courseId}/autoplay.json`, { method: "POST", headers: csrfToken ? { "PUBLIC-CSRF-TOKEN": csrfToken } : {} }); return await res.json(); } catch (err) { Logger.error(`Failed to get artstation course info by courseId: ${courseId}.`, err.message); return false; } } async getVideoUrl(chapterId) { try { const res = await this.fetch(`${this.API_ORIGIN}/quicksilver/video_url.json?chapter_id=${chapterId}`); const data = await res.json(); return data.url.replace("qsep://", "https://"); } catch (err) { Logger.error(`Failed to get artstation video url by chapterId: ${chapterId}.`, err.message); return false; } } async getVideoData(videoId) { const [, courseId, , , chapterId] = videoId.split("/"); const courseInfo = await this.getCourseInfo(courseId); if (!courseInfo) { return void 0; } const chapter = courseInfo.chapters.find((chapter2) => chapter2.hash_id === chapterId); if (!chapter) { return void 0; } const videoUrl = await this.getVideoUrl(chapter.id); if (!videoUrl) { return void 0; } const { title, duration, subtitles: videoSubtitles } = chapter; const subtitles = videoSubtitles.filter((subtitle) => subtitle.format === "vtt").map((subtitle) => ({ language: normalizeLang$1(subtitle.locale), source: "artstation", format: "vtt", url: subtitle.file_url })); return { url: videoUrl, title, duration, subtitles }; } async getVideoId(url) { return /courses\/(\w{3,5})\/([^/]+)\/chapters\/(\w{3,5})/.exec(url.pathname)?.[0]; } } class BannedVideoHelper extends BaseHelper { API_ORIGIN = "https://api.banned.video"; async getVideoInfo(videoId) { try { const res = await this.fetch(`${this.API_ORIGIN}/graphql`, { method: "POST", body: JSON.stringify({ operationName: "GetVideo", query: `query GetVideo($id: String!) { getVideo(id: $id) { title description: summary duration: videoDuration videoUrl: directUrl isStream: live } }`, variables: { id: videoId } }), headers: { "User-Agent": "bannedVideoFrontEnd", "apollographql-client-name": "banned-web", "apollographql-client-version": "1.3", "content-type": "application/json" } }); return await res.json(); } catch (err) { console.error(`Failed to get bannedvideo video info by videoId: ${videoId}.`, err.message); return false; } } async getVideoData(videoId) { const videoInfo = await this.getVideoInfo(videoId); if (!videoInfo) { return void 0; } const { videoUrl, duration, isStream, description, title } = videoInfo.data.getVideo; return { url: videoUrl, duration, isStream, title, description }; } async getVideoId(url) { return url.searchParams.get("id") ?? void 0; } } class BilibiliHelper extends BaseHelper { async getVideoId(url) { const bangumiId = /bangumi\/play\/([^/]+)/.exec(url.pathname)?.[0]; if (bangumiId) { return bangumiId; } const bvid = url.searchParams.get("bvid"); if (bvid) { return `video/${bvid}`; } const intlId = /^\/(?:[a-z]{2}\/)?((?:play\/\d+(?:\/\d+)?|video\/\d+))\/?$/i.exec(url.pathname)?.[1]; if (intlId) { return intlId; } let vid = /video\/([^/]+)/.exec(url.pathname)?.[0]; if (vid && url.searchParams.get("p") !== null) { vid += `/?p=${url.searchParams.get("p")}`; } return vid; } } class BitchuteHelper extends BaseHelper { async getVideoId(url) { return /(video|embed)\/([^/]+)/.exec(url.pathname)?.[2]; } } class BitviewHelper extends BaseHelper { async getVideoData(videoId) { try { const videoUrl = document.querySelector(".vlScreen > video")?.src; if (!videoUrl) { throw new VideoHelperError("Failed to find video URL"); } return { url: videoUrl }; } catch (err) { Logger.error(`Failed to get Bitview data by videoId: ${videoId}`, err.message); return void 0; } } async getVideoId(url) { return url.searchParams.get("v"); } } class BunkrHelper extends BaseHelper { async getVideoData(_videoId) { const url = document.querySelector('#player > source[type="video/mp4"]')?.src; if (!url) { return void 0; } return { url }; } async getVideoId(url) { return /\/f\/([^/]+)/.exec(url.pathname)?.[1]; } } class CloudflareStreamHelper extends BaseHelper { async getVideoId(url) { return url.pathname + url.search; } } class CoursehunterLikeHelper extends BaseHelper { API_ORIGIN = this.origin ?? "https://coursehunter.net"; async getCourseId() { const courseId = window.course_id; if (courseId !== void 0) { return String(courseId); } return document.querySelector('input[name="course_id"]')?.value; } async getLessonsData(courseId) { const lessons = window.lessons; if (lessons?.length) { return lessons; } try { const res = await this.fetch(`${this.API_ORIGIN}/api/v1/course/${courseId}/lessons`); return await res.json(); } catch (err) { Logger.error(`Failed to get CoursehunterLike lessons data by courseId: ${courseId}, because ${err.message}`); return void 0; } } getLessondId(videoId) { let lessondId = videoId.split("?lesson=")?.[1]; if (lessondId) { return +lessondId; } const activeLessondEl = document.querySelector(".lessons-item_active"); lessondId = activeLessondEl?.dataset?.index; if (lessondId) { return +lessondId; } return 1; } async getVideoData(videoId) { const courseId = await this.getCourseId(); if (!courseId) { return void 0; } const lessonsData = await this.getLessonsData(courseId); if (!lessonsData) { return void 0; } const lessonId = this.getLessondId(videoId); const currentLesson = lessonsData?.[lessonId - 1]; const { file: videoUrl, duration, title } = currentLesson; if (!videoUrl) { return void 0; } return { url: proxyMedia(videoUrl), duration, title }; } async getVideoId(url) { const courseId = /course\/([^/]+)/.exec(url.pathname)?.[0]; return courseId ? courseId + url.search : void 0; } } const availableLangs = [ "auto", "ru", "en", "zh", "ko", "lt", "lv", "ar", "fr", "it", "es", "de", "ja" ]; const availableTTS = ["ru", "en", "kk"]; const subtitlesFormats = ["srt", "vtt", "json"]; class VideoJSHelper extends BaseHelper { SUBTITLE_SOURCE = "videojs"; SUBTITLE_FORMAT = "vtt"; static getPlayer() { const vjs = window.videojs; const techEl = document.querySelector("video.vjs-tech[id], video[id$='_html5_api']"); const derivedPlayerId = techEl?.id?.endsWith("_html5_api") ? techEl.id.slice(0, -"_html5_api".length) : void 0; if (vjs?.getPlayer) { if (derivedPlayerId) { const p2 = vjs.getPlayer(derivedPlayerId); if (p2) return p2; } if (techEl) { const p2 = vjs.getPlayer(techEl); if (p2) return p2; } } const players = (typeof vjs?.getPlayers === "function" ? vjs.getPlayers() : vjs?.players) ?? {}; for (const p2 of Object.values(players)) { const player2 = p2; const el = typeof player2.el === "function" ? player2.el() : null; const innerVideo = el?.querySelector?.("video.vjs-tech, video") ?? null; if (innerVideo && techEl && innerVideo === techEl) { return p2; } if (derivedPlayerId && typeof player2.id === "function" && player2.id() === derivedPlayerId) { return p2; } } return void 0; } getVideoDataByPlayer(videoId) { try { const player2 = VideoJSHelper.getPlayer(); const techEl = document.querySelector("video.vjs-tech, video[id$='_html5_api'], video[src]"); if (!player2 && !techEl) { throw new Error(`Video player/video element not found, videoId ${videoId}`); } const duration = player2?.duration?.() ?? techEl?.duration; let url; if (player2) { const sources = typeof player2.currentSources === "function" ? player2.currentSources() : player2.getCache?.()?.sources; const videoUrl = Array.isArray(sources) ? sources.find((source) => source?.type === "video/mp4" || source?.type === "video/webm" || source?.src) : void 0; url = videoUrl?.src; } url ??= techEl?.currentSrc || techEl?.src || techEl?.getAttribute?.("src") || void 0; if (!url) { throw new Error(`Failed to find video url for videoID ${videoId}`); } const trackEls = techEl ? Array.from(techEl.querySelectorAll("track[src]")) : []; const subtitles = trackEls.filter((t2) => t2.kind !== "metadata").flatMap((t2) => { const src = t2.getAttribute("src"); if (!src) { return []; } const absUrl = new URL(src, window.location.href).toString(); return [ { language: normalizeLang$1(t2.srclang || ""), source: this.SUBTITLE_SOURCE, format: this.SUBTITLE_FORMAT, url: absUrl } ]; }); return { url, duration, subtitles }; } catch (err) { Logger.error("Failed to get videojs video data", err.message); return void 0; } } } class CourseraHelper extends VideoJSHelper { API_ORIGIN = "https://www.coursera.org/api"; SUBTITLE_SOURCE = "coursera"; async getCourseData(courseId) { try { const response = await this.fetch(`${this.API_ORIGIN}/onDemandCourses.v1/${courseId}`); const resJSON = await response.json(); return resJSON?.elements?.[0]; } catch (err) { Logger.error(`Failed to get course data by courseId: ${courseId}`, err.message); return void 0; } } static getPlayer() { return VideoJSHelper.getPlayer(); } async getVideoData(videoId) { const data = this.getVideoDataByPlayer(videoId); if (!data) { return void 0; } const { options_: options } = CourseraHelper.getPlayer() ?? {}; if (!data.subtitles?.length && options) { data.subtitles = options.tracks.map((track) => ({ url: track.src, language: normalizeLang$1(track.srclang), source: this.SUBTITLE_SOURCE, format: this.SUBTITLE_FORMAT })); } const courseId = options?.courseId; if (!courseId) { return data; } let courseLang = "en"; const courseData = await this.getCourseData(courseId); if (courseData) { const { primaryLanguageCodes: [primaryLangauge] } = courseData; courseLang = primaryLangauge ? normalizeLang$1(primaryLangauge) : "en"; } if (!availableLangs.includes(courseLang)) { courseLang = "en"; } const subtitleItem = data.subtitles.find((subtitle) => subtitle.language === courseLang) ?? data.subtitles?.[0]; const subtitleUrl = subtitleItem?.url; if (!subtitleUrl) { Logger.warn("Failed to find any subtitle file"); } const { url, duration } = data; const translationHelp = subtitleUrl ? [ { target: "subtitles_file_url", targetUrl: subtitleUrl }, { target: "video_file_url", targetUrl: url } ] : null; return { ...subtitleUrl ? { url: this.service?.url + videoId, translationHelp } : { url, translationHelp }, detectedLanguage: courseLang, duration }; } async getVideoId(url) { const matched = /learn\/([^/]+)\/lecture\/([^/]+)/.exec(url.pathname) ?? /lecture\/([^/]+)\/([^/]+)/.exec(url.pathname); return matched?.[0]; } } class DailymotionHelper extends BaseHelper { async getVideoId(_url) { return new Promise((resolve) => { const origin = "https://www.dailymotion.com"; const timeout2 = setTimeout(() => resolve(void 0), 3e3); window.addEventListener("message", (e2) => { if (e2.origin !== origin) return; if (typeof e2.data !== "object" || e2.data?.type !== "dailymotionVideoId") return; clearTimeout(timeout2); resolve(e2.data.videoId); }); window.top?.postMessage({ type: "getDailymotionVideoId" }, origin); }); } } class DeeplearningAIHelper extends BaseHelper { async getVideoData(_videoId) { if (!this.video) { return void 0; } const sourceUrl = this.video.querySelector('source[type="application/x-mpegurl"]')?.src; if (!sourceUrl) { return void 0; } return { url: sourceUrl }; } async getVideoId(url) { return /courses\/(([^/]+)\/lesson\/([^/]+)\/([^/]+))/.exec(url.pathname)?.[1]; } } class DouyinHelper extends BaseHelper { static getPlayer() { if (typeof player === "undefined") { return void 0; } return player; } async getVideoData(_videoId) { const xgPlayer = DouyinHelper.getPlayer(); if (!xgPlayer) { return void 0; } const { config: { url: sources, duration, lang: lang2, isLive: isStream } } = xgPlayer; if (!sources) { return void 0; } const source = sources.find((s2) => s2.src.includes("www.douyin.com/aweme/v1/play/")); if (!source) { return void 0; } return { url: proxyMedia(source.src), duration, isStream, ...availableLangs.includes(lang2) ? { detectedLanguage: lang2 } : {} }; } async getVideoId(url) { const pathId = /video\/([\d]+)/.exec(url.pathname)?.[0]; if (pathId) { return pathId; } return DouyinHelper.getPlayer()?.config.vid; } } class DzenHelper extends BaseHelper { async getVideoId(url) { return /video\/watch\/([^/]+)/.exec(url.pathname)?.[1]; } } class EggheadHelper extends BaseHelper { async getVideoId(url) { return url.pathname.slice(1); } } class EpicGamesHelper extends BaseHelper { API_ORIGIN = "https://dev.epicgames.com/community/api/learning"; async getPostInfo(videoId) { try { const res = await this.fetch(`${this.API_ORIGIN}/post.json?hash_id=${videoId}`); return await res.json(); } catch (err) { Logger.error(`Failed to get epicgames post info by videoId: ${videoId}.`, err.message); return false; } } getVideoBlock() { const videoUrlRe = /videoUrl\s?=\s"([^"]+)"?/; const script = Array.from(document.body.querySelectorAll("script")).find((s2) => videoUrlRe.exec(s2.innerHTML)); if (!script) { return void 0; } const content = script.innerHTML.trim(); const playlistUrl = videoUrlRe.exec(content)?.[1]?.replace("qsep://", "https://"); if (!playlistUrl) { return void 0; } let subtitlesString = /sources\s?=\s(\[([^\]]+)\])?/.exec(content)?.[1]; if (!subtitlesString) { return { playlistUrl, subtitles: [] }; } try { subtitlesString = `${subtitlesString.replace(/src:(\s)+?(videoUrl)/g, 'src:"removed"').substring(0, subtitlesString.lastIndexOf("},"))}]`.split("\n").map((line) => line.replace(/([^\s]+):\s?(?!.*\1)/, '"$1":')).join("\n"); const subtitlesObj = JSON.parse(subtitlesString); const subtitles = subtitlesObj.filter((sub) => sub.type === "captions"); return { playlistUrl, subtitles }; } catch { return { playlistUrl, subtitles: [] }; } } async getVideoData(videoId) { const courseId = videoId.split(":")?.[1]; const postInfo = await this.getPostInfo(courseId); if (!postInfo) { return void 0; } const videoBlock = this.getVideoBlock(); if (!videoBlock) { return void 0; } const { playlistUrl, subtitles: videoSubtitles } = videoBlock; const { title, description } = postInfo; const subtitles = videoSubtitles.map((caption) => ({ language: normalizeLang$1(caption.srclang), source: "epicgames", format: "vtt", url: caption.src })); return { url: playlistUrl, title, description, subtitles }; } async getVideoId(_url) { return new Promise((resolve) => { const origin = "https://dev.epicgames.com"; const reqId = btoa(window.location.href); window.addEventListener("message", (e2) => { if (e2.origin !== origin) { return void 0; } if (!(typeof e2.data === "string" && e2.data.startsWith("getVideoId:"))) { return void 0; } const videoId = e2.data.replace("getVideoId:", ""); return resolve(videoId); }); window.top?.postMessage(`getVideoId:${reqId}`, origin); }); } } class EpornerHelper extends BaseHelper { async getVideoId(url) { return /video-([^/]+)\/([^/]+)/.exec(url.pathname)?.[0]; } } class FacebookHelper extends BaseHelper { async getVideoId(url) { return url.pathname.slice(1); } } class GoogleDriveHelper extends BaseHelper { getPlayerData() { const playerEl = document.querySelector("#movie_player"); return playerEl?.getVideoData?.() ?? void 0; } async getVideoId(_url) { return this.getPlayerData()?.video_id; } } class IgnHelper extends BaseHelper { getVideoDataBySource(videoId) { const url = document.querySelector('.icms.video > source[type="video/mp4"][data-quality="360"]')?.src; if (!url) { return this.returnBaseData(videoId); } return { url: proxyMedia(url) }; } getVideoDataByNext(videoId) { try { const nextContent = document.getElementById("__NEXT_DATA__")?.textContent; if (!nextContent) { throw new VideoDataError("Not found __NEXT_DATA__ content"); } const data = JSON.parse(nextContent); const { props: { pageProps: { page: { description, title, video: { videoMetadata: { duration }, assets } } } } } = data; const videoUrl = assets.find((asset) => asset.height === 360 && asset.url.includes(".mp4"))?.url; if (!videoUrl) { throw new VideoDataError("Not found video URL in assets"); } return { url: proxyMedia(videoUrl), duration, title, description }; } catch (err) { Logger.warn(`Failed to get ign video data by video ID: ${videoId}, because ${err.message}. Using clear link instead...`); return this.returnBaseData(videoId); } } async getVideoData(videoId) { if (document.getElementById("__NEXT_DATA__")) { return this.getVideoDataByNext(videoId); } return this.getVideoDataBySource(videoId); } async getVideoId(url) { return /([^/]+)\/([\d]+)\/video\/([^/]+)/.exec(url.pathname)?.[0] ?? /\/videos\/([^/]+)/.exec(url.pathname)?.[0]; } } class IMDbHelper extends BaseHelper { async getVideoId(url) { return /video\/([^/]+)/.exec(url.pathname)?.[1]; } } class IncestflixHelper extends BaseHelper { async getVideoData(videoId) { try { const sourceEl = document.querySelector("#incflix-stream source:first-of-type"); if (!sourceEl) { throw new VideoHelperError("Failed to find source element"); } const srcLink = sourceEl.getAttribute("src"); if (!srcLink) { throw new VideoHelperError("Failed to find source link"); } const source = new URL(srcLink.startsWith("//") ? `https:${srcLink}` : srcLink); source.searchParams.append("media-proxy", "video.mp4"); return { url: proxyMedia(source) }; } catch (err) { Logger.error(`Failed to get Incestflix data by videoId: ${videoId}`, err.message); return void 0; } } async getVideoId(url) { return /\/watch\/([^/]+)/.exec(url.pathname)?.[1]; } } class KickHelper extends BaseHelper { API_ORIGIN = "https://kick.com/api"; async getClipInfo(clipId) { try { const res = await this.fetch(`${this.API_ORIGIN}/v2/clips/${clipId}`); const data = await res.json(); const { clip_url: url, duration, title } = data.clip; return { url, duration, title }; } catch (err) { Logger.error(`Failed to get kick clip info by clipId: ${clipId}.`, err.message); return void 0; } } async getVideoInfo(videoId) { try { const res = await this.fetch(`${this.API_ORIGIN}/v1/video/${videoId}`); const data = await res.json(); const { source: url, livestream } = data; const { session_title: title, duration } = livestream; return { url, duration: Math.round(duration / 1e3), title }; } catch (err) { Logger.error(`Failed to get kick video info by videoId: ${videoId}.`, err.message); return void 0; } } async getVideoData(videoId) { return videoId.startsWith("videos") ? await this.getVideoInfo(videoId.replace("videos/", "")) : await this.getClipInfo(videoId.replace("clips/", "")); } async getVideoId(url) { return /([^/]+)\/((videos|clips)\/([^/]+))/.exec(url.pathname)?.[2]; } } class KickstarterHelper extends BaseHelper { async getVideoData(videoId) { try { const videoEl = document.querySelector(".ksr-video-player > video"); const url = videoEl?.querySelector("source[type^='video/mp4']")?.src; if (!url) { throw new VideoHelperError("Failed to find video URL"); } const subtitles = videoEl?.querySelectorAll("track") ?? []; return { url, subtitles: Array.from(subtitles).reduce((result, sub) => { const lang2 = sub.getAttribute("srclang"); const url2 = sub.getAttribute("src"); if (!lang2 || !url2) { return result; } result.push({ language: normalizeLang$1(lang2), url: url2, format: "vtt", source: "kickstarter" }); return result; }, []) }; } catch (err) { Logger.error(`Failed to get Kickstarter data by videoId: ${videoId}`, err.message); return void 0; } } async getVideoId(url) { return url.pathname.slice(1); } } class KodikHelper extends BaseHelper { API_ORIGIN = window.location.origin; getSecureData(videoPath) { try { const [videoType, videoId, hash] = videoPath.split("/").filter((a2) => a2); const allScripts = Array.from(document.getElementsByTagName("script")); const secureScript = allScripts.filter((s2) => s2.innerHTML.includes(`videoId = "${videoId}"`) || s2.innerHTML.includes(`serialId = Number(${videoId})`)); if (!secureScript.length) { throw new VideoHelperError("Failed to find secure script"); } const secureScriptContent = secureScript[0]?.textContent?.trim(); if (!secureScriptContent) { throw new VideoHelperError("Secure script content is empty"); } const secureContent = /'{[^']+}'/.exec(secureScriptContent)?.[0]; if (!secureContent) { throw new VideoHelperError("Secure json wasn't found in secure script"); } const secureJSON = JSON.parse(secureContent.replaceAll("'", "")); if (videoType !== "serial") { return { videoType, videoId, hash, ...secureJSON }; } const videoInfoContent = allScripts.find((s2) => s2.innerHTML.includes(`var videoInfo = {}`))?.textContent?.trim(); if (!videoInfoContent) { throw new VideoHelperError("Failed to find videoInfo content"); } const realVideoType = /videoInfo\.type\s+?=\s+?'([^']+)'/.exec(videoInfoContent)?.[1]; const realVideoId = /videoInfo\.id\s+?=\s+?'([^']+)'/.exec(videoInfoContent)?.[1]; const realHash = /videoInfo\.hash\s+?=\s+?'([^']+)'/.exec(videoInfoContent)?.[1]; if (!realVideoType || !realVideoId || !realHash) { throw new VideoHelperError("Failed to parse videoInfo content"); } return { videoType: realVideoType, videoId: realVideoId, hash: realHash, ...secureJSON }; } catch (err) { Logger.error(`Failed to get kodik secure data by videoPath: ${videoPath}.`, err.message); return false; } } async getFtor(secureData) { const { videoType, videoId: id, hash, d: d2, d_sign, pd, pd_sign, ref, ref_sign } = secureData; try { const res = await this.fetch(`${this.API_ORIGIN}/ftor`, { method: "POST", headers: { "User-Agent": votConfig.userAgent, Origin: this.API_ORIGIN, Referer: `${this.API_ORIGIN}/${videoType}/${id}/${hash}/360p` }, body: new URLSearchParams({ d: d2, d_sign, pd, pd_sign, ref: decodeURIComponent(ref), ref_sign, bad_user: "false", cdn_is_working: "true", info: "{}", type: videoType, hash, id }) }); return await res.json(); } catch (err) { Logger.error(`Failed to get kodik video data (type: ${videoType}, id: ${id}, hash: ${hash})`, err.message); return false; } } decryptUrl(encryptedUrl) { const decryptedUrl = atob(encryptedUrl.replace(/[a-zA-Z]/g, (e2) => { const charCode = e2.charCodeAt(0) + 18; const pos = e2 <= "Z" ? 90 : 122; return String.fromCharCode(pos >= charCode ? charCode : charCode - 26); })); return `https:${decryptedUrl}`; } async getVideoData(videoId) { const secureData = this.getSecureData(videoId); if (!secureData) { return void 0; } const videoData = await this.getFtor(secureData); if (!videoData) { return void 0; } const videoDataLinks = Object.entries(videoData.links[videoData.default.toString()]); const videoLink = videoDataLinks.find(([, data]) => data.type === "application/x-mpegURL")?.[1]; if (!videoLink) { return void 0; } return { url: videoLink.src.startsWith("//") ? `https:${videoLink.src}` : this.decryptUrl(videoLink.src) }; } async getVideoId(url) { return /\/(uv|video|seria|episode|season|serial)\/([^/]+)\/([^/]+)\/([\d]+)p/.exec(url.pathname)?.[0]; } } class LinkedinHelper extends VideoJSHelper { SUBTITLE_SOURCE = "linkedin"; async getVideoData(videoId) { const data = this.getVideoDataByPlayer(videoId); if (!data) { return void 0; } const { url, duration, subtitles } = data; return { url: proxyMedia(new URL(url)), duration, subtitles }; } async getVideoId(url) { return /\/learning\/(([^/]+)\/([^/]+))/.exec(url.pathname)?.[1]; } } var TypeName; (function(TypeName2) { TypeName2["Channel"] = "Channel"; TypeName2["Video"] = "Video"; })(TypeName || (TypeName = {})); function convertToStrTime(ms, delimiter = ",") { const seconds = ms / 1e3; const hours = Math.floor(seconds / 3600); const minutes = Math.floor(seconds % 3600 / 60); const remainingSeconds = Math.floor(seconds % 60); const milliseconds = Math.floor(ms % 1e3); return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}${delimiter}${milliseconds.toString().padStart(3, "0")}`; } function convertToMSTime(time) { const parts = time.split(" ")?.[0]?.split(":"); if (parts.length < 3) { parts.unshift("00"); } const [strHours, strMinutes, strSeconds] = parts; const secs2 = +strSeconds.replace(/[,.]/, ""); const mins = +strMinutes * 6e4; const hours = +strHours * 36e5; return hours + mins + secs2; } function convertSubsFromJSON(data, output = "srt") { const isVTT = output === "vtt"; const delimiter = isVTT ? "." : ","; const subs = data.subtitles.map((sub, idx) => { const result = isVTT ? "" : `${idx + 1} `; return result + `${convertToStrTime(sub.startMs, delimiter)} --> ${convertToStrTime(sub.startMs + sub.durationMs, delimiter)} ${sub.text} `; }).join("").trim(); return isVTT ? `WEBVTT ${subs}` : subs; } function convertSubsToJSON(data, from = "srt") { const parts = data.split(/\r?\n\r?\n/g); if (from === "vtt") { parts.shift(); } if (/^\d+\r?\n/.exec(parts?.[0] ?? "")) { from = "srt"; } const offset = +(from === "srt"); const subtitles = parts.reduce((result, part) => { const lines = part.trim().split("\n"); const time = lines[offset]; const text = lines.slice(offset + 1).join("\n"); if ((lines.length !== 2 || !part.includes(" --> ")) && !time?.includes(" --> ")) { if (result.length === 0) { return result; } result[result.length - 1].text += ` ${lines.join("\n")}`; return result; } const [start, end] = time.split(" --> "); const startMs = convertToMSTime(start); const endMs = convertToMSTime(end); const durationMs = endMs - startMs; result.push({ text, startMs, durationMs, speakerId: "0" }); return result; }, []); return { containsTokens: false, subtitles }; } function getSubsFormat(data) { if (typeof data !== "string") { return "json"; } if (/^(WEBVTT([^\n]+)?)(\r?\n)/.exec(data)) { return "vtt"; } return "srt"; } function convertSubs(data, output = "srt") { const from = getSubsFormat(data); if (from === output) return data; if (from === "json") { return convertSubsFromJSON(data, output); } data = convertSubsToJSON(data, from); if (output === "json") { return data; } return convertSubsFromJSON(data, output); } class LoomHelper extends BaseHelper { getClientVersion() { if (typeof SENTRY_RELEASE === "undefined") { return void 0; } return SENTRY_RELEASE.id; } async getVideoData(videoId) { try { const clientVer = this.getClientVersion(); if (!clientVer) { throw new VideoHelperError("Failed to get client version"); } const res = await this.fetch("https://www.loom.com/graphql", { headers: { "User-Agent": votConfig.userAgent, "content-type": "application/json", "x-loom-request-source": `loom_web_${clientVer}`, "apollographql-client-name": "web", "apollographql-client-version": clientVer, "Alt-Used": "www.loom.com" }, body: `{"operationName":"FetchCaptions","variables":{"videoId":"${videoId}"},"query":"query FetchCaptions($videoId: ID!, $password: String) {\\n fetchVideoTranscript(videoId: $videoId, password: $password) {\\n ... on VideoTranscriptDetails {\\n id\\n captions_source_url\\n language\\n __typename\\n }\\n ... on GenericError {\\n message\\n __typename\\n }\\n __typename\\n }\\n}"}`, method: "POST" }); if (res.status !== 200) { throw new VideoHelperError("Failed to get data from graphql"); } const result = await res.json(); const data = result.data.fetchVideoTranscript; if (data.__typename === "GenericError") { throw new VideoHelperError(data.message); } return { url: this.service?.url + videoId, subtitles: [ { format: "vtt", language: normalizeLang$1(data.language), source: "loom", url: data.captions_source_url } ] }; } catch (err) { Logger.error(`Failed to get Loom video data, because: ${err.message}`); return this.returnBaseData(videoId); } } async getVideoId(url) { return /(embed|share)\/([^/]+)?/.exec(url.pathname)?.[2]; } } class MailRuHelper extends BaseHelper { API_ORIGIN = "https://my.mail.ru"; async getVideoMeta(videoId) { try { const res = await this.fetch(`${this.API_ORIGIN}/+/video/meta/${videoId}?xemail=&ajax_call=1&func_name=&mna=&mnb=&ext=1&_=${Date.now()}`); return await res.json(); } catch (err) { Logger.error("Failed to get mail.ru video data", err.message); return void 0; } } async getVideoId(url) { const pathname = url.pathname; if (/\/(v|mail|bk|inbox)\//.exec(pathname)) { return pathname.slice(1); } const videoId = /video\/embed\/([^/]+)/.exec(pathname)?.[1]; if (!videoId) { return void 0; } const videoData = await this.getVideoMeta(videoId); if (!videoData) { return void 0; } return videoData.meta.url.replace("//my.mail.ru/", ""); } } class NetacadHelper extends VideoJSHelper { SUBTITLE_SOURCE = "netacad"; async getVideoData(videoId) { const data = this.getVideoDataByPlayer(videoId); if (!data) { return void 0; } const { url, duration, subtitles } = data; return { url: proxyMedia(new URL(url)), duration, subtitles }; } async getVideoId(url) { return url.pathname + url.search; } } class NewgroundsHelper extends BaseHelper { async getVideoId(url) { return /([^/]+)\/(view)\/([^/]+)/.exec(url.pathname)?.[0]; } } class NicoNicoHelper extends BaseHelper { async getVideoId(url) { if (url.hostname === "nico.ms") { return url.pathname.replace(/^\//, "").split("/")[0] || void 0; } return /\/watch\/([^/?#]+)/.exec(url.pathname)?.[1]; } } class NineGAGHelper extends BaseHelper { async getVideoData(videoId) { const data = this.returnBaseData(videoId); if (!data) { return data; } try { if (!this.video) { throw new Error("Video element not found"); } const videoUrl = this.video.querySelector('source[type^="video/mp4"], source[type^="video/webm"]')?.src; if (!videoUrl || !/^https?:\/\//.test(videoUrl)) { throw new Error("Video source not found"); } return { ...data, translationHelp: [ { target: "video_file_url", targetUrl: videoUrl } ] }; } catch { return data; } } async getVideoId(url) { return /gag\/([^/]+)/.exec(url.pathname)?.[1]; } } class OdyseeHelper extends BaseHelper { API_ORIGIN = "https://odysee.com"; async getVideoData(videoId) { try { const res = await this.fetch(`${this.API_ORIGIN}/${videoId}`); const content = await res.text(); const url = /"contentUrl":(\s)?"([^"]+)"/.exec(content)?.[2]; if (!url) { throw new VideoHelperError("Odysee url doesn't parsed"); } return { url }; } catch (err) { Logger.error(`Failed to get odysee video data by video ID: ${videoId}`, err.message); return void 0; } } async getVideoId(url) { return url.pathname.slice(1); } } class OKRuHelper extends BaseHelper { async getVideoId(url) { return /\/video\/(\d+)/.exec(url.pathname)?.[1]; } } class OlympicsReplayHelper extends BaseHelper { async getVideoId(url) { return /\/([a-z]{2}\/(?:paris-2024\/)?(?:replay|videos?|original-series\/episode)\/[\w-]+)\/?$/i.exec(url.pathname)?.[1]; } } class OracleLearnHelper extends VideoJSHelper { SUBTITLE_SOURCE = "oraclelearn"; async getVideoData(videoId) { const data = this.getVideoDataByPlayer(videoId); if (!data) { return void 0; } const { url, duration, subtitles } = data; const baseData = this.returnBaseData(videoId); const videoUrl = proxyMedia(new URL(url)); if (!baseData) { return { url: videoUrl, duration, subtitles }; } return { url: baseData.url, duration, subtitles, translationHelp: [ { target: "video_file_url", targetUrl: videoUrl } ] }; } async getVideoId(url) { return /\/ou\/course\/(([^/]+)\/(\d+)\/(\d+))/.exec(url.pathname)?.[1]; } } class PatreonHelper extends BaseHelper { API_ORIGIN = "https://www.patreon.com/api"; async getPosts(postId) { try { const res = await this.fetch(`${this.API_ORIGIN}/posts/${postId}?json-api-use-default-includes=false`); return await res.json(); } catch (err) { Logger.error(`Failed to get patreon posts by postId: ${postId}.`, err.message); return false; } } async getVideoData(postId) { const postData = await this.getPosts(postId); if (!postData) { return void 0; } const postFileUrl = postData.data.attributes.post_file.url; if (!postFileUrl) { return void 0; } return { url: postFileUrl }; } async getVideoId(url) { const fullPostId = /posts\/([^/]+)/.exec(url.pathname)?.[1]; if (!fullPostId) { return void 0; } return fullPostId.replace(/[^\d.]/g, ""); } } class PeertubeHelper extends BaseHelper { async getVideoId(url) { return /\/w\/([^/]+)/.exec(url.pathname)?.[0]; } } class PicartoHelper extends BaseHelper { async getVideoId(url) { return /\/((?:videopopout|[^/]+(?:\/profile)?\/videos)\/[^/?#&/]+)\/?$/.exec(url.pathname)?.[1] ?? /^\/([^/#?]+)\/?$/.exec(url.pathname)?.[1]; } } class PornhubHelper extends BaseHelper { async getVideoId(url) { return url.searchParams.get("viewkey") ?? /embed\/([^/]+)/.exec(url.pathname)?.[1]; } } class PornTNHelper extends BaseHelper { async getVideoData(videoId) { try { if (typeof flashvars === "undefined") { return void 0; } const { rnd, video_url: source, video_title: title } = flashvars; if (!source || !rnd) { throw new VideoHelperError("Failed to find video source or rnd"); } const getFileUrl = new URL(source); getFileUrl.searchParams.append("rnd", rnd); Logger.log("PornTN get_file link", getFileUrl.href); const cdnResponse = await this.fetch(getFileUrl.href, { method: "head" }); const cdnUrl = new URL(cdnResponse.url); Logger.log("PornTN cdn link", cdnUrl.href); const proxiedUrl = proxyMedia(cdnUrl); return { url: proxiedUrl, title }; } catch (err) { Logger.error(`Failed to get PornTN data by videoId: ${videoId}`, err.message); return void 0; } } async getVideoId(url) { return /\/videos\/(([^/]+)\/([^/]+))/.exec(url.pathname)?.[1]; } } class RedditHelper extends BaseHelper { API_ORIGIN = "https://www.reddit.com"; async getContentUrl(_videoId) { if (this.service?.additionalData !== "old") { const player2 = document.querySelector("shreddit-player-2, shreddit-player"); const src = player2?.getAttribute("src") ?? player2?.querySelector('source[type="application/vnd.apple.mpegURL"]')?.getAttribute("src"); return src?.replaceAll("&", "&"); } const playerEl = document.querySelector("[data-hls-url]"); return playerEl?.dataset.hlsUrl?.replaceAll("&", "&"); } async getVideoData(videoId) { try { const contentUrl2 = await this.getContentUrl(videoId); if (!contentUrl2) { throw new VideoHelperError("Failed to find content url"); } return { url: decodeURIComponent(contentUrl2) }; } catch (err) { Logger.error(`Failed to get reddit video data by video ID: ${videoId}`, err.message); return void 0; } } async getVideoId(url) { return /\/r\/(([^/]+)\/([^/]+)\/([^/]+)\/([^/]+))/.exec(url.pathname)?.[1]; } } class RtNewsHelper extends BaseHelper { async getVideoData(videoId) { const videoEl = document.querySelector(".jw-video, .media__video_noscript"); if (!videoEl) { return void 0; } let videoSrc = videoEl.getAttribute("src"); if (!videoSrc) { return void 0; } if (videoSrc.endsWith(".MP4")) { videoSrc = proxyMedia(videoSrc); } return { videoId, url: videoSrc }; } async getVideoId(url) { return url.pathname.slice(1); } } class Rule34VideoHelper extends BaseHelper { async getVideoId(url) { const parts = /\/videos?\/(\d+)(?:\/(.+))?\/?$/.exec(url.pathname); if (!parts) { return void 0; } const [, id, tail] = parts; return tail ? `${id}/${tail.replace(/\/+$/, "")}/` : id; } } class RumbleHelper extends BaseHelper { async getVideoId(url) { return url.pathname.slice(1); } } class RutubeHelper extends BaseHelper { async getVideoId(url) { return /(?:video|embed)\/([^/]+)/.exec(url.pathname)?.[1]; } } class SapHelper extends BaseHelper { API_ORIGIN = "https://learning.sap.com/"; async requestKaltura(kalturaDomain, partnerId, entryId) { const clientTag = "html5:v3.17.22"; const apiVersion = "3.3.0"; try { const res = await this.fetch(`https://${kalturaDomain}/api_v3/service/multirequest`, { method: "POST", body: JSON.stringify({ "1": { service: "session", action: "startWidgetSession", widgetId: `_${partnerId}` }, "2": { service: "baseEntry", action: "list", ks: "{1:result:ks}", filter: { redirectFromEntryId: entryId }, responseProfile: { type: 1, fields: "id,referenceId,name,description,dataUrl,duration,flavorParamsIds,type,dvrStatus,externalSourceType,createdAt,updatedAt,endDate,plays,views,downloadUrl,creatorId" } }, "3": { service: "baseEntry", action: "getPlaybackContext", entryId: "{2:result:objects:0:id}", ks: "{1:result:ks}", contextDataParams: { objectType: "KalturaContextDataParams", flavorTags: "all" } }, apiVersion, format: 1, ks: "", clientTag, partnerId }), headers: { "Content-Type": "application/json" } }); return await res.json(); } catch (err) { Logger.error("Failed to request kaltura data", err.message); return void 0; } } async getKalturaData(videoId) { try { const scriptEl = document.querySelector('script[data-nscript="beforeInteractive"]'); if (!scriptEl) { throw new VideoHelperError("Failed to find script element"); } const sapData = /https:\/\/([^"]+)\/p\/([^"]+)\/embedPlaykitJs\/uiconf_id\/([^"]+)/.exec(scriptEl?.src); if (!sapData) { throw new VideoHelperError(`Failed to get sap data for videoId: ${videoId}`); } const [, kalturaDomain, partnerId] = sapData; let entryId = document.querySelector("#shadow")?.firstChild?.getAttribute("id"); if (!entryId) { const nextDataEl = document.querySelector("#__NEXT_DATA__"); if (!nextDataEl) { throw new VideoHelperError("Failed to find next data element"); } entryId = /"sourceId":\s?"([^"]+)"/.exec(nextDataEl.innerText)?.[1]; } if (!kalturaDomain || Number.isNaN(+partnerId) || !entryId) { throw new VideoHelperError(`One of the necessary parameters for getting a link to a sap video in wasn't found for ${videoId}. Params: kalturaDomain = ${kalturaDomain}, partnerId = ${partnerId}, entryId = ${entryId}`); } return await this.requestKaltura(kalturaDomain, partnerId, entryId); } catch (err) { Logger.error("Failed to get kaltura data", err.message); return void 0; } } async getVideoData(videoId) { const kalturaData = await this.getKalturaData(videoId); if (!kalturaData) { return void 0; } const [, baseEntryList, playbackContext] = kalturaData; const { duration } = baseEntryList.objects[0]; const videoUrl = playbackContext.sources.find((source) => source.format === "url" && source.protocols === "http,https" && source.url.includes(".mp4"))?.url; if (!videoUrl) { return void 0; } const subtitles = playbackContext.playbackCaptions.map((caption) => { return { language: normalizeLang$1(caption.languageCode), source: "sap", format: "vtt", url: caption.webVttUrl, isAutoGenerated: caption.label.includes("auto-generated") }; }); return { url: videoUrl, subtitles, duration }; } async getVideoId(url) { return /((courses|learning-journeys)\/([^/]+)(\/[^/]+)?)/.exec(url.pathname)?.[1]; } } class SpankBangHelper extends BaseHelper { async getVideoId(url) { return /\/([\da-z]+\/(?:video|play|embed)(?:\/[^/]+)?)\/?$/i.exec(url.pathname)?.[1] ?? /\/([\da-z]+-[\da-z]+\/playlist\/[^/]+)\/?$/i.exec(url.pathname)?.[1]; } } class TelegramHelper extends BaseHelper { static getMediaViewer() { if (typeof appMediaViewer === "undefined") { return void 0; } return appMediaViewer; } async getVideoId(_url) { const mediaViewer = TelegramHelper.getMediaViewer(); if (!mediaViewer) { return void 0; } if (mediaViewer.live) { return void 0; } const message = mediaViewer.target.message; if (message.peer_id._ !== "peerChannel") { return void 0; } const media = message.media; if (media._ !== "messageMediaDocument") { return void 0; } if (media.document.type !== "video") { return void 0; } const postId = message.mid & 4294967295; const username = await mediaViewer.managers.appPeersManager.getPeerUsername(message.peerId); return `${username}/${postId}`; } } class ThisVidHelper extends BaseHelper { async getVideoId(url) { return /(videos|embed)\/[^/]+/.exec(url.pathname)?.[0]; } } class TikTokHelper extends BaseHelper { async getVideoId(url) { return /([^/]+)\/video\/([^/]+)/.exec(url.pathname)?.[0]; } } class TrovoHelper extends BaseHelper { async getVideoId(url) { const vid = url.searchParams.get("vid"); const path = /([^/]+)\/([\d]+)/.exec(url.pathname)?.[0]; if (!vid || !path) { return void 0; } return `${path}?vid=${vid}`; } } class TwitchHelper extends BaseHelper { API_ORIGIN = "https://clips.twitch.tv"; async getClipLink(pathname, clipId) { const schema = document.querySelector("script[type='application/ld+json']"); const clearPathname = pathname.slice(1); if (schema) { const schemaJSON = JSON.parse(schema.innerText); const channelLink2 = schemaJSON["@graph"].find((obj) => obj["@type"] === "VideoObject")?.creator.url; if (!channelLink2) { throw new VideoHelperError("Failed to find channel link"); } const channelName2 = channelLink2.replace("https://www.twitch.tv/", ""); return `${channelName2}/clip/${clearPathname}`; } const isEmbed = clearPathname === "embed"; const channelLink = document.querySelector(isEmbed ? ".tw-link[data-test-selector='stream-info-card-component__stream-avatar-link']" : ".clips-player a:not([class])"); if (!channelLink) { return void 0; } const channelName = channelLink.href.replace("https://www.twitch.tv/", ""); return `${channelName}/clip/${isEmbed ? clipId : clearPathname}`; } async getVideoData(videoId) { const title = document.querySelector('[data-a-target="stream-title"], [data-test-selector="stream-info-card-component__subtitle"]')?.innerText; const isStream = !!document.querySelector('[data-a-target="animated-channel-viewers-count"], .channel-status-info--live, .top-bar--pointer-enabled .tw-channel-status-text-indicator'); return { url: this.service?.url + videoId, isStream, title }; } async getVideoId(url) { const pathname = url.pathname; if (/^m\.twitch\.tv$/.test(pathname)) { return /videos\/([^/]+)/.exec(url.href)?.[0] ?? pathname.slice(1); } else if (/^player\.twitch\.tv$/.test(url.hostname)) { return `videos/${url.searchParams.get("video")}`; } const clipPath = /([^/]+)\/(?:clip)\/([^/]+)/.exec(pathname); if (clipPath) { return clipPath[0]; } const isClipsDomain = /^clips\.twitch\.tv$/.test(url.hostname); if (isClipsDomain) { return await this.getClipLink(pathname, url.searchParams.get("clip")); } const videoPath = /(?:videos)\/([^/]+)/.exec(pathname); if (videoPath) { return videoPath[0]; } const isUserOfflinePage = document.querySelector(".home-offline-hero .tw-link"); if (isUserOfflinePage?.href) { const pageUrl = new URL(isUserOfflinePage.href); return /(?:videos)\/([^/]+)/.exec(pageUrl.pathname)?.[0]; } return document.querySelector(".persistent-player") ? pathname : void 0; } } class TwitterHelper extends BaseHelper { async getVideoId(url) { const videoId = /status\/([^/]+)/.exec(url.pathname)?.[1]; if (videoId) { return videoId; } const postEl = this.video?.closest('[data-testid="tweet"]'); const newLink = postEl?.querySelector('a[role="link"][aria-label]')?.href; return newLink ? /status\/([^/]+)/.exec(newLink)?.[1] : void 0; } } function isUrlCandidate(value) { return typeof value === "object" && value !== null; } function getUrlCandidates(data) { if (Array.isArray(data)) { return data.filter(isUrlCandidate); } if (typeof data !== "object" || data === null) { return []; } const source = data; const values = Array.isArray(source.Video) ? source.Video : Array.isArray(source.video) ? source.video : []; return values.filter(isUrlCandidate); } class UdemyHelper extends BaseHelper { API_ORIGIN = `${window.location.origin}/api-2.0`; getModuleData() { const appLoaderEl = document.querySelector(".ud-app-loader[data-module-id='course-taking']") ?? document.querySelector("[data-module-id='course-taking']"); const moduleData = appLoaderEl?.dataset?.moduleArgs; if (!moduleData) { return void 0; } try { return JSON.parse(moduleData); } catch { return void 0; } } getLectureId() { return /learn\/lecture\/([^/]+)/.exec(window.location.pathname)?.[1]; } isErrorData(data) { return Object.hasOwn(data, "error") || Object.hasOwn(data, "detail") && !Object.hasOwn(data, "_class"); } async getLectureData(courseId, lectureId) { try { const res = await this.fetch(`${this.API_ORIGIN}/users/me/subscribed-courses/${courseId}/lectures/${lectureId}/?` + new URLSearchParams({ "fields[lecture]": "title,description,asset,download_url,is_free,last_watched_second", "fields[asset]": "asset_type,length,media_sources,stream_urls,download_urls,external_url,captions,thumbnail_sprite,slides,slide_urls,course_is_drmed,media_license_token" }).toString()); const data = await res.json(); if (this.isErrorData(data)) { throw new VideoHelperError(data.detail ?? "unknown error"); } return data; } catch (err) { Logger.error(`Failed to get lecture data by courseId: ${courseId} and lectureId: ${lectureId}`, err.message); return void 0; } } async getCourseLang(courseId) { try { const res = await this.fetch(`${this.API_ORIGIN}/users/me/subscribed-courses/${courseId}?` + new URLSearchParams({ "fields[course]": "locale" }).toString()); const data = await res.json(); if (!this.isErrorData(data)) { return data; } const res2 = await this.fetch(`${this.API_ORIGIN}/courses/${courseId}/?` + new URLSearchParams({ "fields[course]": "locale" }).toString()); const data2 = await res2.json(); if (this.isErrorData(data2)) { throw new VideoHelperError(data2.detail ?? "unknown error"); } return data2; } catch (err) { Logger.error(`Failed to get course lang by courseId: ${courseId}`, err.message); return void 0; } } findVideoUrl(sources, streamUrls, downloadUrls) { const mp4Sources = (sources ?? []).filter((src) => src?.type === "video/mp4" && typeof src.src === "string"); if (mp4Sources.length) { const getQ = (v2) => Number(String(v2 ?? "").match(/(\d{3,4})/)?.[1] ?? 0); mp4Sources.sort((a2, b2) => getQ(b2.label ?? b2.quality) - getQ(a2.label ?? a2.quality)); return mp4Sources[0]?.src; } const streamCandidates = getUrlCandidates(streamUrls); const streamUrl = streamCandidates.find((x2) => typeof x2?.src === "string" && x2?.type === "video/mp4")?.src ?? streamCandidates.find((x2) => typeof x2?.src === "string" && (String(x2?.type).toLowerCase().includes("mpegurl") || String(x2?.src).toLowerCase().includes(".m3u8")))?.src ?? streamCandidates.find((x2) => typeof x2?.src === "string" && (String(x2?.type).toLowerCase().includes("dash") || String(x2?.src).toLowerCase().includes(".mpd")))?.src; if (typeof streamUrl === "string") { return streamUrl; } const downloadCandidates = getUrlCandidates(downloadUrls); const downloadUrl = downloadCandidates.find((x2) => typeof x2?.file === "string" && x2?.type === "video/mp4")?.file ?? downloadCandidates.find((x2) => typeof x2?.file === "string")?.file ?? downloadCandidates.find((x2) => typeof x2?.src === "string")?.src; return typeof downloadUrl === "string" ? downloadUrl : void 0; } findSubtitleUrl(captions, detectedLanguage) { const captionsWithDownload = captions; const subtitle = captionsWithDownload.find((caption) => normalizeLang$1(caption.locale_id) === detectedLanguage) ?? captionsWithDownload.find((caption) => normalizeLang$1(caption.locale_id) === "en") ?? captionsWithDownload[0]; return subtitle?.url ?? subtitle?.download_url; } async getVideoData(videoId) { const moduleData = this.getModuleData(); if (!moduleData) { return void 0; } const { courseId } = moduleData; const lectureId = this.getLectureId(); Logger.log(`[Udemy] courseId: ${courseId}, lectureId: ${lectureId}`); if (!lectureId) { return void 0; } const lectureData = await this.getLectureData(courseId, lectureId); if (!lectureData) { return void 0; } const { title, description, asset } = lectureData; const { length: duration, media_sources, captions } = asset; const assetWithExtraUrls = asset; const streamUrls = assetWithExtraUrls.stream_urls; const downloadUrls = assetWithExtraUrls.download_urls; const videoUrl = this.findVideoUrl(media_sources, streamUrls, downloadUrls); if (!videoUrl) { Logger.log("Failed to find video file in asset sources", asset); return void 0; } let courseLang = "en"; const courseLangData = await this.getCourseLang(courseId); if (courseLangData) { const { locale: { locale: courseLocale } } = courseLangData; courseLang = courseLocale ? normalizeLang$1(courseLocale) : courseLang; } if (!availableLangs.includes(courseLang)) { courseLang = "en"; } const subtitleUrl = this.findSubtitleUrl(captions, courseLang); if (!subtitleUrl) { Logger.log("Failed to find subtitle file in captions", captions); } return { ...subtitleUrl ? { url: this.service?.url + videoId, translationHelp: [ { target: "subtitles_file_url", targetUrl: subtitleUrl }, { target: "video_file_url", targetUrl: videoUrl } ], detectedLanguage: courseLang } : { url: videoUrl, translationHelp: null }, duration, title, description }; } async getVideoId(url) { return url.pathname.slice(1); } } class VimeoHelper extends BaseHelper { API_KEY = ""; DEFAULT_SITE_ORIGIN = "https://vimeo.com"; SITE_ORIGIN = this.service?.url?.slice(0, -1) ?? this.DEFAULT_SITE_ORIGIN; isErrorData(data) { return Object.hasOwn(data, "error"); } isPrivatePlayer() { return this.referer && !this.referer.includes("vimeo.com") && this.origin.endsWith("player.vimeo.com"); } async getViewerData() { try { const res = await this.fetch("https://vimeo.com/_next/viewer"); const data = await res.json(); const { apiUrl, jwt } = data; this.API_ORIGIN = `https://${apiUrl}`; this.API_KEY = `jwt ${jwt}`; return data; } catch (err) { Logger.error(`Failed to get default viewer data.`, err.message); return false; } } async getVideoInfo(videoId) { try { const params = new URLSearchParams({ fields: "name,link,description,duration" }).toString(); const res = await this.fetch(`${this.API_ORIGIN}/videos/${videoId}?${params}`, { headers: { Authorization: this.API_KEY } }); const data = await res.json(); if (this.isErrorData(data)) { throw new Error(data.developer_message ?? data.error); } return data; } catch (err) { Logger.error(`Failed to get video info by video ID: ${videoId}`, err.message); return false; } } async getPrivateVideoSource(files) { try { const { default_cdn, cdns } = files.dash; const cdnUrl = cdns[default_cdn].url; const res = await this.fetch(cdnUrl); if (res.status !== 200) { throw new VideoHelperError(await res.text()); } const data = await res.json(); const baseUrl = new URL(data.base_url, cdnUrl); const videoData = data.audio.find((v2) => v2.mime_type === "audio/mp4" && v2.format === "dash"); if (!videoData) { throw new VideoHelperError("Failed to find video data"); } const segmentUrl = videoData.segments?.[0]?.url; if (!segmentUrl) { throw new VideoHelperError("Failed to find first segment url"); } const [videoName, videoParams] = segmentUrl.split("?", 2); const params = new URLSearchParams(videoParams); params.delete("range"); return new URL(`${videoData.base_url}${videoName}?${params.toString()}`, baseUrl).href; } catch (err) { Logger.error(`Failed to get private video source`, err.message); return false; } } async getPrivateVideoInfo(videoId) { try { if (typeof playerConfig === "undefined") { return void 0; } const videoSource = await this.getPrivateVideoSource(playerConfig.request.files); if (!videoSource) { throw new VideoHelperError("Failed to get private video source"); } const { video: { title, duration }, request: { text_tracks: subs } } = playerConfig; return { url: `${this.SITE_ORIGIN}/${videoId}`, video_url: videoSource, title, duration, subs }; } catch (err) { Logger.error(`Failed to get private video info by video ID: ${videoId}`, err.message); return false; } } async getSubsInfo(videoId) { try { const params = new URLSearchParams({ per_page: "100", fields: "language,type,link" }).toString(); const res = await this.fetch(`${this.API_ORIGIN}/videos/${videoId}/texttracks?${params}`, { headers: { Authorization: this.API_KEY } }); const content = await res.json(); if (this.isErrorData(content)) { throw new Error(content.developer_message ?? content.error); } return content.data; } catch (err) { Logger.error(`Failed to get subtitles info by video ID: ${videoId}`, err.message); return []; } } async getVideoData(videoId) { const isPrivate = this.isPrivatePlayer(); if (isPrivate) { const videoInfo2 = await this.getPrivateVideoInfo(videoId); if (!videoInfo2) { return void 0; } const { url: url2, subs, video_url, title: title2, duration: duration2 } = videoInfo2; const subtitles2 = subs.map((sub) => ({ language: normalizeLang$1(sub.lang), source: "vimeo", format: "vtt", url: this.SITE_ORIGIN + sub.url, isAutoGenerated: sub.lang.includes("autogenerated") })); const translationHelp = subtitles2.length ? [ { target: "video_file_url", targetUrl: video_url }, { target: "subtitles_file_url", targetUrl: subtitles2[0].url } ] : null; return { ...translationHelp ? { url: url2, translationHelp } : { url: video_url }, subtitles: subtitles2, title: title2, duration: duration2 }; } if (!this.extraInfo) { return this.returnBaseData(videoId); } if (videoId.includes("/")) { videoId = videoId.replace("/", ":"); } const viewerData = await this.getViewerData(); if (!viewerData) { return this.returnBaseData(videoId); } const videoInfo = await this.getVideoInfo(videoId); if (!videoInfo) { return this.returnBaseData(videoId); } const subsData = await this.getSubsInfo(videoId); const subtitles = subsData.map((caption) => ({ language: normalizeLang$1(caption.language), source: "vimeo", format: "vtt", url: caption.link, isAutoGenerated: caption.language.includes("autogen") })); const { link: url, duration, name: title, description } = videoInfo; return { url, title, description, subtitles, duration }; } async getVideoId(url) { const embedId = /video\/[^/]+$/.exec(url.pathname)?.[0]; if (this.isPrivatePlayer()) { return embedId; } if (embedId) { const hash = url.searchParams.get("h"); const videoId = embedId.replace("video/", ""); return hash ? `${videoId}/${hash}` : videoId; } const categoriesVideoId = /channels\/[^/]+\/([^/]+)/.exec(url.pathname)?.[1] ?? /groups\/[^/]+\/videos\/([^/]+)/.exec(url.pathname)?.[1] ?? /(showcase|album)\/[^/]+\/video\/([^/]+)/.exec(url.pathname)?.[2]; if (categoriesVideoId) { return categoriesVideoId; } return /([^/]+\/)?[^/]+$/.exec(url.pathname)?.[0]; } } class VKHelper extends BaseHelper { static getPlayer() { if (typeof Videoview === "undefined") { return void 0; } try { return Videoview?.getPlayerObject?.(); } catch { return void 0; } } async getVideoData(videoId) { const currentUrl = new URL(window.location.href); const player2 = VKHelper.getPlayer(); if (!player2) { const base = this.returnBaseData(videoId); return base ? { ...base, url: buildVkVideoUrl(videoId, currentUrl) } : base; } try { const { description: descriptionHTML, duration, md_title: title } = player2.vars; const parser = new DOMParser(); const doc = parser.parseFromString(descriptionHTML, "text/html"); const description = Array.from(doc.body.childNodes).filter((el) => el.nodeName !== "BR").map((el) => el.textContent).join("\n"); let subtitles; if (Object.hasOwn(player2.vars, "subs")) { subtitles = player2.vars.subs.map((sub) => ({ language: normalizeLang$1(sub.lang), source: "vk", format: "vtt", url: sub.url, isAutoGenerated: !!sub.is_auto })); } return { url: buildVkVideoUrl(videoId, currentUrl), title, description, duration, subtitles }; } catch (err) { Logger.error(`Failed to get VK video data, because: ${err.message}`); const base = this.returnBaseData(videoId); return base ? { ...base, url: buildVkVideoUrl(videoId, currentUrl) } : base; } } async getVideoId(url) { const pathID = /^\/((?:video|clip)-?\d+_\d+)(?:\/)?$/.exec(url.pathname); if (pathID) { return pathID[1]; } const idInsidePlaylist = /\/playlist\/[^/]+\/(video-?\d+_\d+)/.exec(url.pathname); if (idInsidePlaylist) { return idInsidePlaylist[1]; } const paramZ = url.searchParams.get("z"); if (paramZ) { return paramZ.split("/")[0]; } const paramOID = url.searchParams.get("oid"); const paramID = url.searchParams.get("id"); if (paramOID && paramID) { const ownerId = Math.abs(Number.parseInt(paramOID, 10)); if (!Number.isNaN(ownerId)) { return `video-${ownerId}_${paramID}`; } } return void 0; } } class WatchPornToHelper extends BaseHelper { async getVideoId(url) { return /(video|embed)\/(\d+)(\/[^/]+\/)?/.exec(url.pathname)?.[0]; } } const weiboVideoIdRe = /^\d+:(?:[\da-f]{32}|\d{16,})$/i; class WeiboHelper extends BaseHelper { async getVideoId(url) { if (url.hostname === "video.weibo.com") { const fid = url.searchParams.get("fid"); if (!fid || !weiboVideoIdRe.test(fid)) { return void 0; } return `tv/show/${fid}`; } const normalizedPath = url.pathname.replace(/\/+$/, ""); if (/^\/\d+\/[A-Za-z0-9]+$/.test(normalizedPath) || /^\/0\/[A-Za-z0-9]+$/.test(normalizedPath) || /^\/tv\/show\/\d+:(?:[\da-f]{32}|\d{16,})$/i.test(normalizedPath)) { return normalizedPath.slice(1); } return void 0; } } class WeverseHelper extends BaseHelper { API_ORIGIN = "https://global.apis.naver.com/weverse/wevweb"; API_APP_ID = "be4d79eb8fc7bd008ee82c8ec4ff6fd4"; API_HMAC_KEY = "1b9cb6378d959b45714bec49971ade22e6e24e42"; HEADERS = { Accept: "application/json, text/plain, */*", Origin: "https://weverse.io", Referer: "https://weverse.io/" }; getURLData() { return { appId: this.API_APP_ID, language: "en", os: "WEB", platform: "WEB", wpf: "pc" }; } async createHash(pathname) { const timestamp = Date.now(); const salt = pathname.substring(0, Math.min(255, pathname.length)) + timestamp; const sign = await getHmacSha1(this.API_HMAC_KEY, salt); if (!sign) { throw new VideoHelperError("Failed to get weverse HMAC signature"); } return { wmsgpad: timestamp.toString(), wmd: sign }; } async getHashURLParams(pathname) { const hash = await this.createHash(pathname); return new URLSearchParams(hash).toString(); } async getPostPreview(postId) { const pathname = `/post/v1.0/post-${postId}/preview?` + new URLSearchParams({ fieldSet: "postForPreview", ...this.getURLData() }).toString(); try { const urlParams = await this.getHashURLParams(pathname); const res = await this.fetch(`${this.API_ORIGIN + pathname}&${urlParams}`, { headers: this.HEADERS }); return await res.json(); } catch (err) { Logger.error(`Failed to get weverse post preview by postId: ${postId}`, err.message); return false; } } async getVideoInKey(videoId) { const pathname = `/video/v1.1/vod/${videoId}/inKey?` + new URLSearchParams({ gcc: "RU", ...this.getURLData() }).toString(); try { const urlParams = await this.getHashURLParams(pathname); const res = await this.fetch(`${this.API_ORIGIN + pathname}&${urlParams}`, { method: "POST", headers: this.HEADERS }); return await res.json(); } catch (err) { Logger.error(`Failed to get weverse InKey by videoId: ${videoId}`, err.message); return false; } } async getVideoInfo(infraVideoId, inkey, serviceId) { const timestamp = Date.now(); try { const urlParams = new URLSearchParams({ key: inkey, sid: serviceId, nonce: timestamp.toString(), devt: "html5_pc", prv: "N", aup: "N", stpb: "N", cpl: "en", env: "prod", lc: "en", adi: JSON.stringify([ { adSystem: null } ]), adu: "/" }).toString(); const res = await this.fetch(`https://global.apis.naver.com/rmcnmv/rmcnmv/vod/play/v2.0/${infraVideoId}?` + urlParams, { headers: this.HEADERS }); return await res.json(); } catch (err) { Logger.error(`Failed to get weverse video info (infraVideoId: ${infraVideoId}, inkey: ${inkey}, serviceId: ${serviceId}`, err.message); return false; } } extractVideoInfo(videoList) { return videoList.find((video) => video.useP2P === false && video.source.includes(".mp4")); } async getVideoData(videoId) { const videoPreview = await this.getPostPreview(videoId); if (!videoPreview) { return void 0; } const { videoId: internalVideoId, serviceId, infraVideoId } = videoPreview.extension.video; if (!(internalVideoId && serviceId && infraVideoId)) { return void 0; } const inkeyData = await this.getVideoInKey(internalVideoId); if (!inkeyData) { return void 0; } const videoInfo = await this.getVideoInfo(infraVideoId, inkeyData.inKey, serviceId); if (!videoInfo) { return void 0; } const videoItem = this.extractVideoInfo(videoInfo.videos.list); if (!videoItem) { return void 0; } return { url: videoItem.source, duration: videoItem.duration }; } async getVideoId(url) { return /([^/]+)\/(live|media)\/([^/]+)/.exec(url.pathname)?.[3]; } } class XHamsterHelper extends BaseHelper { async getVideoId(url) { return /\/(videos\/[^/]+-[\dA-Za-z]+)\/?$/.exec(url.pathname)?.[1]; } } class XVideosHelper extends BaseHelper { async getVideoId(url) { return /[^/]+\/[^/]+$/.exec(url.pathname)?.[0]; } } class YandexDiskHelper extends BaseHelper { API_ORIGIN = window.location.origin; CLIENT_PREFIX = "/client/disk"; INLINE_PREFIX = "/i/"; DISK_PREFIX = "/d/"; isErrorData(data) { return Object.hasOwn(data, "error"); } async getClientVideoData(videoId) { const url = new URL(window.location.href); const dialogId = url.searchParams.get("idDialog"); if (!dialogId) { return void 0; } const preloadedScript = document.querySelector("#preloaded-data"); if (!preloadedScript) { return void 0; } try { const preloadedData = JSON.parse(preloadedScript.innerText); const { idClient, sk } = preloadedData.config; const res = await this.fetch(`${this.API_ORIGIN}/models-v2?m=mpfs/info`, { method: "POST", body: JSON.stringify({ apiMethod: "mpfs/info", connection_id: idClient, requestParams: { path: dialogId }, sk }), headers: { "Content-Type": "application/json" } }); const data = await res.json(); if (this.isErrorData(data)) { throw new VideoHelperError(data.error?.message ?? data.error?.code); } if (data?.type !== "file") { throw new VideoHelperError("Failed to get resource info"); } const { meta: { short_url, video_info }, name } = data; if (!video_info) { throw new VideoHelperError("There's no video open right now"); } if (!short_url) { throw new VideoHelperError("Access to the video is limited"); } const title = this.clearTitle(name); const duration = Math.round(video_info.duration / 1e3); return { url: short_url, title, duration }; } catch (err) { Logger.error(`Failed to get yandex disk video data by video ID: ${videoId}, because ${err.message}`); return void 0; } } clearTitle(title) { return title.replace(/(\.[^.]+)$/, ""); } getBodyHash(fileHash, sk) { const data = JSON.stringify({ hash: fileHash, sk }); return encodeURIComponent(data); } async fetchList(dirHash, sk) { const body = this.getBodyHash(dirHash, sk); const res = await this.fetch(`${this.API_ORIGIN}/public/api/fetch-list`, { method: "POST", body }); const data = await res.json(); if (Object.hasOwn(data, "error")) { throw new VideoHelperError("Failed to fetch folder list"); } return data.resources; } async getDownloadUrl(fileHash, sk) { const body = this.getBodyHash(fileHash, sk); const res = await this.fetch(`${this.API_ORIGIN}/public/api/download-url`, { method: "POST", body }); const data = await res.json(); if (data.error) { throw new VideoHelperError("Failed to get download url"); } return data.data.url; } async getDiskVideoData(videoId) { try { const prefetchEl = document.getElementById("store-prefetch"); if (!prefetchEl) { throw new VideoHelperError("Failed to get prefetch data"); } const resourcePaths = videoId.split("/").slice(3); if (!resourcePaths.length) { throw new VideoHelperError("Failed to find video file path"); } const data = JSON.parse(prefetchEl.innerText); const { resources, rootResourceId, environment: { sk } } = data; const rootResource = resources[rootResourceId]; const resourcePathsLastIdx = resourcePaths.length - 1; const resourcePath = resourcePaths.filter((_2, idx) => idx !== resourcePathsLastIdx).join("/"); let resourcesList = Object.values(resources); if (resourcePath.includes("/")) { resourcesList = await this.fetchList(`${rootResource.hash}:/${resourcePath}`, sk); } const resource = resourcesList.find((resource2) => resource2.name === resourcePaths[resourcePathsLastIdx]); if (!resource) { throw new VideoHelperError("Failed to find resource"); } if (resource && resource.type === "dir") { throw new VideoHelperError("Path is dir, but expected file"); } const { meta: { short_url, mediatype, videoDuration }, path, name } = resource; if (mediatype !== "video") { throw new VideoHelperError("Resource isn't a video"); } const title = this.clearTitle(name); const duration = Math.round(videoDuration / 1e3); if (short_url) { return { url: short_url, duration, title }; } const downloadUrl = await this.getDownloadUrl(path, sk); return { url: proxyMedia(new URL(downloadUrl)), duration, title }; } catch (err) { Logger.error(`Failed to get yandex disk video data by disk video ID: ${videoId}`, err.message); return void 0; } } async getVideoData(videoId) { if (videoId.startsWith(this.INLINE_PREFIX) || /^\/d\/([^/]+)$/.exec(videoId)) { return { url: this.service?.url + videoId.slice(1) }; } videoId = decodeURIComponent(videoId); if (videoId.startsWith(this.CLIENT_PREFIX)) { return await this.getClientVideoData(videoId); } return await this.getDiskVideoData(videoId); } async getVideoId(url) { if (url.pathname.startsWith(this.CLIENT_PREFIX)) { return url.pathname + url.search; } const fileId = /\/i\/([^/]+)/.exec(url.pathname)?.[0]; if (fileId) { return fileId; } return /\/d\/([^/]+)/.exec(url.pathname) ? url.pathname : void 0; } } class YoukuHelper extends BaseHelper { async getVideoId(url) { return /v_show\/id_[\w=]+/.exec(url.pathname)?.[0]; } } class YoutubeHelper extends BaseHelper { static isMobile() { return /^m\.youtube\.com$/.test(window.location.hostname); } static getPlayer() { if (window.location.pathname.startsWith("/shorts/") && !YoutubeHelper.isMobile()) { return document.querySelector("#shorts-player"); } return document.querySelector("#movie_player"); } static getPlayerResponse() { return YoutubeHelper.getPlayer()?.getPlayerResponse?.call(void 0); } static getPlayerData() { return YoutubeHelper.getPlayer()?.getVideoData?.call(void 0); } static getVolume() { const player2 = YoutubeHelper.getPlayer(); if (player2?.getVolume) { return player2.getVolume() / 100; } return 1; } static setVolume(volume) { const player2 = YoutubeHelper.getPlayer(); if (player2?.setVolume) { player2.setVolume(Math.round(volume * 100)); return true; } return false; } static isMuted() { const player2 = YoutubeHelper.getPlayer(); if (player2?.isMuted) { return player2.isMuted(); } return false; } static videoSeek(video, time) { Logger.log("videoSeek", time); const preTime = YoutubeHelper.getPlayer()?.getProgressState()?.seekableEnd ?? video.currentTime; const finalTime = preTime - time; video.currentTime = finalTime; } static getPoToken() { const player2 = YoutubeHelper.getPlayer(); if (!player2) { return void 0; } const audioTrack = player2.getAudioTrack?.call(void 0); if (!audioTrack?.captionTracks?.length) { return void 0; } const audioTrackWithPoToken = audioTrack.captionTracks.find((captionTrack) => captionTrack.url.includes("&pot=")); if (!audioTrackWithPoToken) { return void 0; } return /&pot=([^&]+)/.exec(audioTrackWithPoToken.url)?.[1]; } static getGlobalConfig() { if (typeof yt !== "undefined") { return yt?.config_; } return typeof ytcfg !== "undefined" ? ytcfg?.data_ : void 0; } static getDeviceParams() { const ytconfig = YoutubeHelper.getGlobalConfig(); if (!ytconfig) { return "c=WEB"; } const innertubeClient = ytconfig.INNERTUBE_CONTEXT?.client; const deviceParams = new URLSearchParams(ytconfig.DEVICE); deviceParams.delete("ceng"); deviceParams.delete("cengver"); deviceParams.set("c", innertubeClient?.clientName ?? ytconfig.INNERTUBE_CLIENT_NAME); deviceParams.set("cver", innertubeClient?.clientVersion ?? ytconfig.INNERTUBE_CLIENT_VERSION); deviceParams.set("cplayer", "UNIPLAYER"); return deviceParams.toString(); } static getSubtitles(userLang) { const response = YoutubeHelper.getPlayerResponse(); const playerCaptions = response?.captions?.playerCaptionsTracklistRenderer; if (!playerCaptions) { return []; } const captionTracks = playerCaptions.captionTracks ?? []; const translationLanguages = playerCaptions.translationLanguages ?? []; const userLangSupported = translationLanguages.find((language) => language.languageCode === userLang); const asrSubtitleItem = captionTracks.find((captionTrack) => captionTrack?.kind === "asr"); const asrLang = asrSubtitleItem?.languageCode ?? "en"; const subtitles = captionTracks.reduce((result, captionTrack) => { if (!("languageCode" in captionTrack)) { return result; } const language = captionTrack.languageCode ? normalizeLang$1(captionTrack.languageCode) : void 0; const url = captionTrack.baseUrl; if (!language || !url) { return result; } const captionUrl = `${url.startsWith("http") ? url : `${window.location.origin}/${url}`}&fmt=json3`; result.push({ source: "youtube", format: "json", language, isAutoGenerated: captionTrack?.kind === "asr", url: captionUrl }); if (userLangSupported && captionTrack.isTranslatable && captionTrack.languageCode === asrLang && userLang !== language) { result.push({ source: "youtube", format: "json", language: userLang, isAutoGenerated: captionTrack?.kind === "asr", translatedFromLanguage: language, url: `${captionUrl}&tlang=${userLang}` }); } return result; }, []); Logger.log("youtube subtitles:", subtitles); return subtitles; } static getLanguage() { if (!YoutubeHelper.isMobile()) { const player2 = YoutubeHelper.getPlayer(); const trackInfo = player2?.getAudioTrack?.call(void 0)?.getLanguageInfo(); if (trackInfo && trackInfo.id !== "und") { return normalizeLang$1(trackInfo.id.split(".")[0]); } } const response = YoutubeHelper.getPlayerResponse(); const autoCaption = response?.captions?.playerCaptionsTracklistRenderer.captionTracks.find((caption) => caption.kind === "asr" && caption.languageCode); return autoCaption ? normalizeLang$1(autoCaption.languageCode) : void 0; } async getVideoData(videoId) { const { title: localizedTitle } = YoutubeHelper.getPlayerData() ?? {}; const { shortDescription: description, isLive: isStream, title } = YoutubeHelper.getPlayerResponse()?.videoDetails ?? {}; const subtitles = YoutubeHelper.getSubtitles(this.language); let detectedLanguage = YoutubeHelper.getLanguage(); if (detectedLanguage && !availableLangs.includes(detectedLanguage)) { detectedLanguage = void 0; } const duration = YoutubeHelper.getPlayer()?.getDuration?.call(void 0) ?? void 0; return { url: this.service?.url + videoId, isStream, title, localizedTitle, detectedLanguage, description, subtitles, duration }; } async getVideoId(url) { if (url.hostname === "youtu.be") { url.search = `?v=${url.pathname.replace("/", "")}`; url.pathname = "/watch"; } if (url.searchParams.has("enablejsapi")) { const videoUrl = YoutubeHelper.getPlayer()?.getVideoUrl(); url = videoUrl ? new URL(videoUrl) : url; } return /(?:watch|embed|shorts|live)\/([^/]+)/.exec(url.pathname)?.[1] ?? url.searchParams.get("v"); } } const zdfPlayPathRe = /^\/play\/([^/?#]+)\/([^/?#]+)\/([^/?#]+)\/?$/i; class ZDFHelper extends BaseHelper { async getVideoId(url) { const match = zdfPlayPathRe.exec(url.pathname); if (!match) { return void 0; } const [, publicationForm, collectionCanonical, videoCanonical] = match; return `${publicationForm}/${collectionCanonical}/${videoCanonical}`; } } const availableHelpers = { [VideoService.mailru]: MailRuHelper, [VideoService.weverse]: WeverseHelper, [VideoService.weibo]: WeiboHelper, [VideoService.kodik]: KodikHelper, [VideoService.patreon]: PatreonHelper, [VideoService.reddit]: RedditHelper, [VideoService.bannedvideo]: BannedVideoHelper, [VideoService.kick]: KickHelper, [VideoService.appledeveloper]: AppleDeveloperHelper, [VideoService.epicgames]: EpicGamesHelper, [VideoService.odysee]: OdyseeHelper, [VideoService.coursehunterLike]: CoursehunterLikeHelper, [VideoService.twitch]: TwitchHelper, [VideoService.sap]: SapHelper, [VideoService.linkedin]: LinkedinHelper, [VideoService.vimeo]: VimeoHelper, [VideoService.yandexdisk]: YandexDiskHelper, [VideoService.vk]: VKHelper, [VideoService.trovo]: TrovoHelper, [VideoService.incestflix]: IncestflixHelper, [VideoService.porntn]: PornTNHelper, [VideoService.googledrive]: GoogleDriveHelper, [VideoService.bilibili]: BilibiliHelper, [VideoService.xvideos]: XVideosHelper, [VideoService.xhamster]: XHamsterHelper, [VideoService.spankbang]: SpankBangHelper, [VideoService.rule34video]: Rule34VideoHelper, [VideoService.picarto]: PicartoHelper, [VideoService.olympicsreplay]: OlympicsReplayHelper, [VideoService.watchpornto]: WatchPornToHelper, [VideoService.archive]: ArchiveHelper, [VideoService.dailymotion]: DailymotionHelper, [VideoService.youku]: YoukuHelper, [VideoService.egghead]: EggheadHelper, [VideoService.newgrounds]: NewgroundsHelper, [VideoService.okru]: OKRuHelper, [VideoService.peertube]: PeertubeHelper, [VideoService.eporner]: EpornerHelper, [VideoService.bitchute]: BitchuteHelper, [VideoService.rutube]: RutubeHelper, [VideoService.facebook]: FacebookHelper, [VideoService.rumble]: RumbleHelper, [VideoService.twitter]: TwitterHelper, [VideoService.pornhub]: PornhubHelper, [VideoService.tiktok]: TikTokHelper, [VideoService.proxitok]: TikTokHelper, [VideoService.nine_gag]: NineGAGHelper, [VideoService.youtube]: YoutubeHelper, [VideoService.invidious]: YoutubeHelper, [VideoService.piped]: YoutubeHelper, [VideoService.zdf]: ZDFHelper, [VideoService.dzen]: DzenHelper, [VideoService.cloudflarestream]: CloudflareStreamHelper, [VideoService.loom]: LoomHelper, [VideoService.rtnews]: RtNewsHelper, [VideoService.bitview]: BitviewHelper, [VideoService.thisvid]: ThisVidHelper, [VideoService.ign]: IgnHelper, [VideoService.bunkr]: BunkrHelper, [VideoService.imdb]: IMDbHelper, [VideoService.telegram]: TelegramHelper, [VideoService.niconico]: NicoNicoHelper, [ExtVideoService.udemy]: UdemyHelper, [ExtVideoService.coursera]: CourseraHelper, [ExtVideoService.douyin]: DouyinHelper, [ExtVideoService.artstation]: ArtstationHelper, [ExtVideoService.kickstarter]: KickstarterHelper, [ExtVideoService.oraclelearn]: OracleLearnHelper, [ExtVideoService.deeplearningai]: DeeplearningAIHelper, [ExtVideoService.netacad]: NetacadHelper }; class VideoHelper { helpersData; constructor(helpersData = {}) { this.helpersData = helpersData; } getHelper(service) { return new availableHelpers[service](this.helpersData); } } function hasHelper(host) { return host in availableHelpers; } function getService() { if (localLinkRe.exec(window.location.href)) { return []; } const hostname = window.location.hostname; const enteredURL = new URL(window.location.href); const isMatches = (match) => { if (match instanceof RegExp) { return match.test(hostname); } else if (typeof match === "string") { return hostname.includes(match); } else if (typeof match === "function") { return match(enteredURL); } return false; }; return sites.filter((e2) => { return !!e2.match && (Array.isArray(e2.match) ? e2.match.some(isMatches) : isMatches(e2.match)) && e2.host && e2.url; }); } async function getVideoID(service, opts = {}) { const url = new URL(window.location.href); const serviceHost = service.host; if (hasHelper(serviceHost)) { const helper = new VideoHelper(opts).getHelper(serviceHost); return await helper.getVideoId(url); } return serviceHost === VideoService.custom ? url.href : void 0; } async function getVideoData(service, opts = {}) { const currentUrl = new URL(window.location.href); const videoId = await getVideoID(service, opts); if (!videoId) { throw new VideoDataError(`Entered unsupported link: "${service.host}"`); } const origin = currentUrl.origin; if ([ VideoService.peertube, VideoService.coursehunterLike, VideoService.cloudflarestream ].includes(service.host)) { service.url = origin; } if (service.rawResult) { return { url: videoId, videoId, host: service.host, duration: void 0 }; } if (!service.needExtraData) { if (service.host === VideoService.vk) { return { url: buildVkVideoUrl(videoId, currentUrl), videoId, host: service.host, duration: void 0 }; } return { url: service.url + videoId, videoId, host: service.host, duration: void 0 }; } if (!hasHelper(service.host)) { throw new VideoDataError(`No helper is available for "${service.host}"`); } const helper = new VideoHelper({ ...opts, service, origin }).getHelper(service.host); const result = await helper.getVideoData(videoId); if (!result) { throw new VideoDataError(`Failed to get video raw url for ${service.host}`); } return { ...result, url: service.host === VideoService.vk ? buildVkVideoUrl(videoId, currentUrl) : result.url, videoId, host: service.host }; } const defaultFetch = (input, init2) => { const f2 = globalThis.fetch; if (typeof f2 !== "function") { throw new Error("chaimu: global fetch() is not available in this runtime. Please provide `fetchFn` in the Chaimu constructor options."); } return f2(input, init2); }; const config = { version: "1.0.6", debug: false, fetchFn: defaultFetch }; const debug$1 = { log: (...text) => { if (!config.debug) return; console.log(`%c✦ chaimu.js v${config.version} ✦`, "background: #000; color: #fff; padding: 0 8px", ...text); } }; const videoLipSyncEvents = [ "playing", "ratechange", "play", "waiting", "pause", "seeked" ]; const AUDIO_CONTEXT_UNLOCK_EVENTS = [ "pointerdown", "touchend", "keydown" ]; const STREAMING_CONTENT_LENGTH_THRESHOLD = 1e6; const DEFAULT_MEDIA_FETCH_TIMEOUT_MS = 30 * 60 * 1e3; const RANGE_STREAM_CHUNK_SIZE_BYTES = 512 * 1024; const SEEK_PREROLL_SECONDS = 3; const RANGE_STREAM_TARGET_BUFFER_AHEAD_SECONDS = 90; const RANGE_STREAM_TARGET_BUFFER_BEHIND_SECONDS = 30; const RANGE_STREAM_EVICT_MARGIN_SECONDS = 5; const RANGE_STREAM_IDLE_WAIT_MS = 200; const MP3_SEEK_TABLE_PROBE_BYTES = 128 * 1024; const CODEC_SYNC_SCAN_LIMIT_BYTES = 4096; const RANGE_READY_TIMEOUT_MS = 5e3; const BUFFER_WAIT_TIMEOUT_MS = 15e3; function sleep$1(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function withTimeout(promise, ms, message) { let timeoutId; const timeout2 = new Promise((_2, reject) => { timeoutId = setTimeout(() => reject(new Error(message)), ms); }); try { return await Promise.race([promise, timeout2]); } finally { if (timeoutId !== void 0) clearTimeout(timeoutId); } } function isQuotaExceededError(err) { if (!err || typeof err !== "object") return false; return err.name === "QuotaExceededError"; } function readSyncSafeInt(b0, b1, b2, b3) { return (b0 & 127) << 21 | (b1 & 127) << 14 | (b2 & 127) << 7 | b3 & 127; } function parseId3TagSize(bytes) { if (bytes.length < 10) return 0; if (bytes[0] !== 73 || bytes[1] !== 68 || bytes[2] !== 51) return 0; const size = readSyncSafeInt(bytes[6], bytes[7], bytes[8], bytes[9]); return 10 + size; } function isLikelyMp3FrameHeader(bytes, i2) { if (i2 + 3 >= bytes.length) return false; if (bytes[i2] !== 255) return false; if ((bytes[i2 + 1] & 224) !== 224) return false; const verBits = bytes[i2 + 1] >> 3 & 3; const layerBits = bytes[i2 + 1] >> 1 & 3; if (verBits === 1) return false; if (layerBits === 0) return false; const bitrateIndex = bytes[i2 + 2] >> 4 & 15; const sampleRateIndex = bytes[i2 + 2] >> 2 & 3; if (bitrateIndex === 15) return false; if (sampleRateIndex === 3) return false; return true; } function findMp3FrameSync(bytes, start = 0, maxScan = CODEC_SYNC_SCAN_LIMIT_BYTES) { const limit = Math.min(bytes.length - 4, start + maxScan); for (let i2 = start; i2 < limit; i2++) { if (isLikelyMp3FrameHeader(bytes, i2)) return i2; } return start; } function isLikelyAdtsHeader(bytes, i2) { if (i2 + 3 >= bytes.length) return false; if (bytes[i2] !== 255) return false; if ((bytes[i2 + 1] & 240) !== 240) return false; if ((bytes[i2 + 1] & 6) !== 0) return false; const sfIndex = bytes[i2 + 2] >> 2 & 15; if (sfIndex === 15) return false; return true; } function findAdtsSync(bytes, start = 0, maxScan = CODEC_SYNC_SCAN_LIMIT_BYTES) { const limit = Math.min(bytes.length - 4, start + maxScan); for (let i2 = start; i2 < limit; i2++) { if (isLikelyAdtsHeader(bytes, i2)) return i2; } return start; } function parseMp3SeekTable(probe) { const audioStart = parseId3TagSize(probe); const firstFrame = findMp3FrameSync(probe, audioStart, 16 * 1024); if (!isLikelyMp3FrameHeader(probe, firstFrame)) { return { audioStart }; } const verBits = probe[firstFrame + 1] >> 3 & 3; const layerBits = probe[firstFrame + 1] >> 1 & 3; if (layerBits !== 1) return { audioStart }; const channelMode = probe[firstFrame + 3] >> 6 & 3; const isMono = channelMode === 3; let sideInfoSize = 0; if (verBits === 3) { sideInfoSize = isMono ? 17 : 32; } else { sideInfoSize = isMono ? 9 : 17; } const xingOffset = firstFrame + 4 + sideInfoSize; if (xingOffset + 16 >= probe.length) return { audioStart }; const tag = String.fromCharCode(probe[xingOffset], probe[xingOffset + 1], probe[xingOffset + 2], probe[xingOffset + 3]); if (tag !== "Xing" && tag !== "Info") return { audioStart }; const flags = probe[xingOffset + 4] << 24 | probe[xingOffset + 5] << 16 | probe[xingOffset + 6] << 8 | probe[xingOffset + 7]; let p2 = xingOffset + 8; if (flags & 1) p2 += 4; if (flags & 2) p2 += 4; let toc; if (flags & 4) { if (p2 + 100 <= probe.length) toc = probe.slice(p2, p2 + 100); p2 += 100; } return { audioStart, toc }; } const SYNC_DRIFT_SECONDS = 0.075; const SYNC_LOOP_INTERVAL_MS = 250; const BUFFER_MARGIN_SECONDS = 0.05; function isBrowser() { return typeof window !== "undefined" && typeof document !== "undefined"; } function clamp$3(value, min, max) { return Math.min(max, Math.max(min, value)); } function getMimeType(response, fallback = "audio/mpeg") { const header = response.headers.get("Content-Type")?.trim(); if (!header) return fallback; const parts = header.split(";").map((p2) => p2.trim()).filter(Boolean); const mime = parts[0] ?? ""; if (!mime) return fallback; const codecs = parts.find((p2) => p2.toLowerCase().startsWith("codecs=")); return codecs ? `${mime}; ${codecs}` : mime; } function parseContentRange(value) { if (!value) return void 0; const match = value.match(/bytes\s+(\d+)-(\d+)\/(\d+|\*)/i); if (!match) return void 0; const start = Number(match[1]); const end = Number(match[2]); const sizeRaw = match[3]; const size = sizeRaw === "*" ? Number.NaN : Number(sizeRaw); if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) return void 0; if (!Number.isFinite(size) || size <= 0 || end >= size) return void 0; return { start, end, size }; } function attachAutoplayUnlock(audioContext) { if (!isBrowser()) return; const resume = () => { void audioContext.resume().then(() => { if (audioContext.state !== "running") return; for (const event of AUDIO_CONTEXT_UNLOCK_EVENTS) { document.removeEventListener(event, resume, true); } }).catch((err) => { debug$1.log("[AudioContext] resume() failed", err); }); }; for (const event of AUDIO_CONTEXT_UNLOCK_EVENTS) { document.addEventListener(event, resume, { passive: true, capture: true }); } } function initAudioContext() { if (!isBrowser()) return void 0; const w2 = window; const Ctor = w2.AudioContext ?? w2.webkitAudioContext; if (!Ctor) return void 0; const ctx = new Ctor(); if (ctx.state === "suspended") attachAutoplayUnlock(ctx); return ctx; } async function ensureAudioContextRunning(audioContext) { if (!audioContext) return; if (audioContext.state === "running") return; if (audioContext.state === "closed") return; try { await audioContext.resume(); } catch (err) { debug$1.log("[AudioContext] resume() failed", err); } } function setPreservesPitch(media, value) { const anyMedia = media; if ("preservesPitch" in anyMedia) anyMedia.preservesPitch = value; if ("mozPreservesPitch" in anyMedia) anyMedia.mozPreservesPitch = value; if ("webkitPreservesPitch" in anyMedia) anyMedia.webkitPreservesPitch = value; } function isTimeBuffered(media, timeSeconds, marginSeconds = BUFFER_MARGIN_SECONDS) { const ranges = media.buffered; if (!ranges || ranges.length === 0) return false; for (let i2 = 0; i2 < ranges.length; i2++) { const start = ranges.start(i2); const end = ranges.end(i2); if (timeSeconds >= start && timeSeconds <= end - marginSeconds) return true; } return false; } function getBufferedRange(media, timeSeconds) { const buffered = media.buffered; for (let i2 = 0; i2 < buffered.length; i2++) { const start = buffered.start(i2); const end = buffered.end(i2); if (timeSeconds >= start && timeSeconds <= end) return { start, end }; } return void 0; } function syncMediaToVideo(media, video, driftThresholdSeconds = SYNC_DRIFT_SECONDS) { const drift = Math.abs(media.currentTime - video.currentTime); if (Number.isFinite(drift) && drift > driftThresholdSeconds) { media.currentTime = video.currentTime; } if (media.playbackRate !== video.playbackRate) { media.playbackRate = video.playbackRate; } } function syncMediaToVideoIfBuffered(media, video, driftThresholdSeconds = SYNC_DRIFT_SECONDS) { const drift = Math.abs(media.currentTime - video.currentTime); if (Number.isFinite(drift) && drift > driftThresholdSeconds) { if (isTimeBuffered(media, video.currentTime)) { media.currentTime = video.currentTime; } } if (media.playbackRate !== video.playbackRate) { media.playbackRate = video.playbackRate; } } function hardSyncMediaToVideo(media, video) { media.currentTime = video.currentTime; if (media.playbackRate !== video.playbackRate) { media.playbackRate = video.playbackRate; } } class BasePlayer { static playerName = "BasePlayer"; chaimu; fetchFn; fetchOpts; _src; analyser; analyserSource; analyserData; syncIntervalId; videoFrameCallbackId; lastSyncMs = 0; constructor(chaimu, src) { this.chaimu = chaimu; this._src = src; this.fetchFn = chaimu.fetchFn; this.fetchOpts = chaimu.fetchOpts; } async init() { return this; } async clear() { this.stopLipSyncLoop(); this.disconnectAnalyser(); return this; } getMediaElement() { return void 0; } getAudioRms() { const analyser = this.analyser; if (!analyser) return void 0; const data = this.analyserData; if (!data || data.length !== analyser.fftSize) this.analyserData = new Uint8Array(analyser.fftSize); const buf = this.analyserData; if (!buf) return void 0; analyser.getByteTimeDomainData(buf); let sum = 0; for (let i2 = 0; i2 < buf.length; i2++) { const v2 = (buf[i2] - 128) / 128; sum += v2 * v2; } return Math.sqrt(sum / buf.length); } attachAnalyserToNode(node) { if (!isBrowser()) return; const ctx = this.chaimu.audioContext; if (!ctx) return; if (this.analyser && this.analyserSource === node) return; this.disconnectAnalyser(); const analyser = ctx.createAnalyser(); analyser.fftSize = 512; analyser.smoothingTimeConstant = 0.8; try { node.connect(analyser); this.analyser = analyser; this.analyserSource = node; this.analyserData = new Uint8Array(analyser.fftSize); } catch (err) { debug$1.log("[BasePlayer] failed to attach analyser", err); this.disconnectAnalyser(); } } disconnectAnalyser() { try { if (this.analyserSource && this.analyser) { try { this.analyserSource.disconnect(this.analyser); } catch { } } try { this.analyser?.disconnect(); } catch { } } finally { this.analyser = void 0; this.analyserSource = void 0; this.analyserData = void 0; } } lipSync(_mode = false) { return this; } onLipSyncTick() { } startLipSyncLoop() { if (!isBrowser()) return; if (this.syncIntervalId !== void 0 || this.videoFrameCallbackId !== void 0) return; const video = this.chaimu.video; if (typeof video?.requestVideoFrameCallback === "function") { const tick = (now2) => { if (now2 - this.lastSyncMs >= SYNC_LOOP_INTERVAL_MS) { this.lastSyncMs = now2; try { this.onLipSyncTick(); } catch (err) { debug$1.log("[BasePlayer] lip-sync tick error", err); } } this.videoFrameCallbackId = video.requestVideoFrameCallback?.(tick); }; this.videoFrameCallbackId = video.requestVideoFrameCallback(tick); return; } this.syncIntervalId = window.setInterval(() => { try { this.onLipSyncTick(); } catch (err) { debug$1.log("[BasePlayer] lip-sync tick error", err); } }, SYNC_LOOP_INTERVAL_MS); } stopLipSyncLoop() { if (!isBrowser()) return; if (this.syncIntervalId !== void 0) { window.clearInterval(this.syncIntervalId); this.syncIntervalId = void 0; } if (this.videoFrameCallbackId !== void 0) { const video = this.chaimu.video; video.cancelVideoFrameCallback?.(this.videoFrameCallbackId); this.videoFrameCallbackId = void 0; } } handleVideoEvent = (event) => { debug$1.log(`handle video ${event.type}`); this.lipSync(event.type); return this; }; removeVideoEvents() { for (const e2 of videoLipSyncEvents) { this.chaimu.video?.removeEventListener(e2, this.handleVideoEvent); } return this; } addVideoEvents() { for (const e2 of videoLipSyncEvents) { this.chaimu.video?.addEventListener(e2, this.handleVideoEvent); } return this; } async play() { return this; } async pause() { return this; } get name() { return this.constructor.playerName ?? this.constructor.name; } set src(url) { this._src = url; } get src() { return this._src; } get currentSrc() { return this._src; } set volume(_value) { } get volume() { return 0; } get playbackRate() { return 0; } set playbackRate(_value) { } get currentTime() { return 0; } } class AudioPlayer extends BasePlayer { static playerName = "AudioPlayer"; audio; gainNode; audioSource; volumeValue = 1; constructor(chaimu, src) { super(chaimu, src); this.recreateAudioElement(); this.rebuildWebAudioGraph(); } recreateAudioElement() { const audio = this._src ? new Audio(this._src) : new Audio(); audio.crossOrigin = "anonymous"; audio.preload = "auto"; setPreservesPitch(audio, true); audio.volume = clamp$3(this.volumeValue, 0, 1); this.audio = audio; } disconnectWebAudioGraph() { this.audioSource?.disconnect(); this.gainNode?.disconnect(); this.audioSource = void 0; this.gainNode = void 0; this.disconnectAnalyser(); } rebuildWebAudioGraph() { this.disconnectWebAudioGraph(); const ctx = this.chaimu.audioContext; if (!ctx) return; const gain = ctx.createGain(); gain.gain.value = Math.max(0, this.volumeValue); const source = ctx.createMediaElementSource(this.audio); source.connect(gain); gain.connect(ctx.destination); this.attachAnalyserToNode(gain); this.gainNode = gain; this.audioSource = source; } async init() { const ctx = this.chaimu.audioContext; if (ctx && !this.audioSource) { this.recreateAudioElement(); this.rebuildWebAudioGraph(); } return this; } onPlayError = (err) => { debug$1.log("[AudioPlayer] play() failed", err); }; onLipSyncTick() { const video = this.chaimu.video; if (!video) return; if (video.paused) return; if (this.audio.paused) return; syncMediaToVideo(this.audio, video); } lipSync(mode = false) { const video = this.chaimu.video; if (!video) return this; syncMediaToVideo(this.audio, video); if (!mode) return this; switch (mode) { case "play": case "playing": case "seeked": { if (!video.paused) void this.play().catch(this.onPlayError); break; } case "pause": case "waiting": { void this.pause(); break; } } return this; } async clear() { this.stopLipSyncLoop(); this.audio.pause(); this.audio.src = ""; this.audio.removeAttribute("src"); this.audio.load(); this.disconnectWebAudioGraph(); return this; } async play() { const ctx = this.chaimu.audioContext; if (ctx) await ensureAudioContextRunning(ctx); await this.audio.play().catch(this.onPlayError); this.startLipSyncLoop(); return this; } async pause() { this.audio.pause(); this.stopLipSyncLoop(); return this; } set src(url) { this._src = url; if (!url) { void this.clear(); return; } this.audio.src = url; this.audio.load(); } get src() { return this._src; } get currentSrc() { return this.audio.currentSrc; } getMediaElement() { return this.audio; } set volume(value) { this.volumeValue = Math.max(0, value); if (this.gainNode) { this.gainNode.gain.value = this.volumeValue; } else { this.audio.volume = clamp$3(this.volumeValue, 0, 1); } } get volume() { return this.gainNode ? this.gainNode.gain.value : this.audio.volume; } get playbackRate() { return this.audio.playbackRate; } set playbackRate(value) { this.audio.playbackRate = value; } get currentTime() { return this.audio.currentTime; } } class ChaimuPlayer extends BasePlayer { static playerName = "ChaimuPlayer"; audioElement; mediaElementSource; gainNode; mediaUrl; mediaSource; sourceBuffer; streamingPromise; rangeTotalSizeBytes; rangeMimeType; mp3SeekTable; rangeSeekId = 0; rangeReadyPromise; resolveRangeReady; rejectRangeReady; streamAbortController; streamAbortCleanup; initPromise; clearPromise; startToken = 0; isStarting = false; hasPendingStart = false; volumeValue = 1; async fetchAudio() { if (!this._src) throw new Error("No audio source provided"); const ctx = this.chaimu.audioContext; if (!ctx) throw new Error("No audio context available"); debug$1.log(`[ChaimuPlayer] Fetching audio from ${this._src}...`); await this.stopStreaming(); if (await this.trySetupRangeStreamingAudio()) { return this; } const response = await this.fetchFn(this._src, this.buildAudioFetchOpts()); if (!this.isFetchResponseOk(response)) { throw new Error(`Failed to fetch audio: ${response.status} ${response.statusText}`); } await this.loadBufferedAudio(response); return this; } initGainNode() { const ctx = this.chaimu.audioContext; if (!ctx) return; this.disconnectAudioNodes(); const gain = ctx.createGain(); gain.gain.value = Math.max(0, this.volumeValue); this.gainNode = gain; } disconnectAudioNodes() { this.mediaElementSource?.disconnect(); this.gainNode?.disconnect(); this.mediaElementSource = void 0; this.gainNode = void 0; this.disconnectAnalyser(); } async init() { if (this.initPromise) return this.initPromise; this.initPromise = (async () => { await this.fetchAudio(); this.initGainNode(); this.createAudioElement(); return this; })().finally(() => { this.initPromise = void 0; }); return this.initPromise; } createAudioElement() { const ctx = this.chaimu.audioContext; if (!ctx) throw new Error("No audio context available"); if (!this.mediaUrl) throw new Error("No media URL available"); if (!this.gainNode) throw new Error("Audio graph is not initialized"); const audio = new Audio(this.mediaUrl); audio.crossOrigin = "anonymous"; audio.preload = "auto"; setPreservesPitch(audio, true); audio.volume = clamp$3(this.volumeValue, 0, 1); this.audioElement = audio; try { audio.load(); } catch (e2) { debug$1.log("[ChaimuPlayer] audio.load() failed", e2); } this.mediaElementSource = ctx.createMediaElementSource(audio); this.mediaElementSource.connect(this.gainNode); this.gainNode.connect(ctx.destination); this.attachAnalyserToNode(this.gainNode); } onLipSyncTick() { const video = this.chaimu.video; const audio = this.audioElement; if (!video || !audio) return; if (video.paused || audio.paused) return; syncMediaToVideoIfBuffered(audio, video); } lipSync(mode = false) { const video = this.chaimu.video; const audio = this.audioElement; if (!video || !audio) return this; if (!mode) return this; switch (mode) { case "play": case "seeked": { if (!video.paused) void this.start(); break; } case "playing": { if (!video.paused && audio.paused) { void this.start(); } break; } case "ratechange": { if (audio.playbackRate !== video.playbackRate) { audio.playbackRate = video.playbackRate; } break; } case "pause": case "waiting": { void this.pause(); break; } } return this; } async clear() { if (this.clearPromise) return this.clearPromise; this.clearPromise = (async () => { debug$1.log("[ChaimuPlayer] clearing audio graph"); this.stopLipSyncLoop(); await this.pause(); await this.stopStreaming(); if (this.audioElement) { this.audioElement.pause(); this.audioElement.src = ""; this.audioElement.removeAttribute("src"); this.audioElement.load(); this.audioElement = void 0; } if (this.mediaUrl) { URL.revokeObjectURL(this.mediaUrl); this.mediaUrl = void 0; } this.disconnectAudioNodes(); return this; })().finally(() => { this.clearPromise = void 0; }); return this.clearPromise; } async start() { if (this.isStarting) { this.hasPendingStart = true; this.startToken++; return this; } this.isStarting = true; const token = ++this.startToken; try { const ctx = this.chaimu.audioContext; if (!ctx) throw new Error("No audio context available"); const audio = this.audioElement; if (!audio) throw new Error("Audio element is missing"); if (this.clearPromise) await this.clearPromise; await ensureAudioContextRunning(ctx); try { audio.load(); } catch { } const video = this.chaimu.video; if (video) { audio.pause(); try { audio.load(); } catch { } await this.ensureRangeStreamingForTime(video.currentTime, token); if (token !== this.startToken) return this; await this.waitUntilBuffered(video.currentTime, token); if (token !== this.startToken) return this; hardSyncMediaToVideo(audio, video); } if (token !== this.startToken) return this; if (this.hasPendingStart) return this; if (video?.paused) return this; await audio.play().catch((err) => debug$1.log("[ChaimuPlayer] audio.play() failed", err)); this.startLipSyncLoop(); return this; } finally { this.isStarting = false; if (this.hasPendingStart) { this.hasPendingStart = false; void this.start(); } } } async pause() { this.startToken++; this.hasPendingStart = false; this.audioElement?.pause(); this.stopLipSyncLoop(); return this; } async play() { return this.start(); } set src(url) { this._src = url; } get src() { return this._src; } get currentSrc() { return this._src; } getMediaElement() { return this.audioElement; } set volume(value) { this.volumeValue = Math.max(0, value); if (this.gainNode) { this.gainNode.gain.value = this.volumeValue; } if (this.audioElement) { this.audioElement.volume = clamp$3(this.volumeValue, 0, 1); } } get volume() { return this.gainNode ? this.gainNode.gain.value : this.volumeValue; } set playbackRate(value) { if (this.audioElement) this.audioElement.playbackRate = value; } get playbackRate() { return this.audioElement?.playbackRate ?? this.chaimu.video?.playbackRate ?? 1; } get currentTime() { return this.chaimu.video?.currentTime ?? 0; } async loadBufferedAudio(response) { let data; try { data = await response.arrayBuffer(); } catch (error2) { throw new Error(`Failed to read audio buffer: ${error2.message}`); } const mimeType = getMimeType(response); if (typeof Blob === "undefined" || typeof URL === "undefined") { throw new Error("Blob/URL APIs are not available in this runtime"); } this.setMediaUrl(URL.createObjectURL(new Blob([data], { type: mimeType }))); } buildAudioFetchOpts(overrides) { const base = { ...this.fetchOpts ?? {} }; if (base.timeout == null) { base.timeout = DEFAULT_MEDIA_FETCH_TIMEOUT_MS; } if (overrides?.signal) { base.signal = overrides.signal; } if (overrides?.headers) { const merged = new Headers(base.headers); const extra = new Headers(overrides.headers); extra.forEach((value, key) => { merged.set(key, value); }); base.headers = merged; } return base; } isFetchResponseOk(response) { if (response.ok) return true; if (response.status === 0 && (response.body || response.headers.has("Content-Type"))) { return true; } return false; } createStreamAbortController() { this.streamAbortCleanup?.(); this.streamAbortCleanup = void 0; this.streamAbortController?.abort(); const controller = new AbortController(); this.streamAbortController = controller; const externalSignal = this.fetchOpts?.signal; if (externalSignal) { const onAbort = () => controller.abort(); if (externalSignal.aborted) { controller.abort(); } else { externalSignal.addEventListener("abort", onAbort, { once: true }); this.streamAbortCleanup = () => externalSignal.removeEventListener("abort", onAbort); } } return controller; } async trySetupRangeStreamingAudio() { if (typeof MediaSource === "undefined" || typeof URL === "undefined") return false; if (!this._src) throw new Error("[ChaimuPlayer] src is not set"); const probeEnd = MP3_SEEK_TABLE_PROBE_BYTES - 1; const rangeHeaders = new Headers(this.fetchOpts?.headers); rangeHeaders.set("Range", `bytes=0-${probeEnd}`); let rangeResponse; try { rangeResponse = await this.fetchFn(this._src, { ...this.fetchOpts, headers: rangeHeaders, timeout: DEFAULT_MEDIA_FETCH_TIMEOUT_MS }); } catch (e2) { debug$1.log("[ChaimuPlayer] Range probe request failed", e2); return false; } if (!rangeResponse.ok || rangeResponse.status !== 206) { return false; } const contentRange = rangeResponse.headers.get("Content-Range"); const range = parseContentRange(contentRange); if (!range?.size) return false; const totalSizeBytes = range.size; if (totalSizeBytes < STREAMING_CONTENT_LENGTH_THRESHOLD) { return false; } const mimeType = getMimeType(rangeResponse); if (!this.isSeekableRangeStreamingMimeType(mimeType)) { return false; } try { if (typeof MediaSource.isTypeSupported === "function" && !MediaSource.isTypeSupported(mimeType)) { return false; } } catch { return false; } let probeBytes; try { probeBytes = new Uint8Array(await rangeResponse.arrayBuffer()); } catch { probeBytes = void 0; } this.rangeTotalSizeBytes = totalSizeBytes; this.rangeMimeType = mimeType; if (mimeType.toLowerCase().includes("audio/mpeg") && probeBytes) { this.mp3SeekTable = parseMp3SeekTable(probeBytes); } else { this.mp3SeekTable = void 0; } const mediaSource = new MediaSource(); this.mediaSource = mediaSource; this.rangeReadyPromise = new Promise((resolve, reject) => { this.resolveRangeReady = resolve; this.rejectRangeReady = reject; }); mediaSource.addEventListener("sourceopen", () => { void this.setupSeekableRangeStreamingAudio().catch((e2) => { debug$1.log("[ChaimuPlayer] Failed to setup seekable range streaming audio", e2); this.rejectRangeReady?.(e2); void this.fallbackToBufferedAudio(e2); }); }, { once: true }); mediaSource.addEventListener("error", () => { const err = new Error("[ChaimuPlayer] MediaSource error"); this.rejectRangeReady?.(err); void this.fallbackToBufferedAudio(err); }); this.setMediaUrl(URL.createObjectURL(mediaSource)); return true; } isSeekableRangeStreamingMimeType(mimeType) { const lower = mimeType.toLowerCase(); return lower.includes("audio/mpeg") || lower.includes("audio/mp3") || lower.includes("audio/aac") || lower.includes("audio/adts"); } getVideoDurationSeconds() { const duration = this.chaimu.video?.duration; if (duration == null) return void 0; if (!Number.isFinite(duration) || duration <= 0) return void 0; return duration; } timeToByteOffset(timeSeconds) { const total = this.rangeTotalSizeBytes; const duration = this.getVideoDurationSeconds(); if (!total || !duration) return 0; const t2 = clamp$3(timeSeconds, 0, duration); if (this.mp3SeekTable?.toc && this.mp3SeekTable.toc.length === 100) { const percent = t2 / duration * 100; const i2 = clamp$3(Math.floor(percent), 0, 99); const frac = percent - i2; const v1 = this.mp3SeekTable.toc[i2]; const v2 = this.mp3SeekTable.toc[Math.min(99, i2 + 1)]; const v3 = v1 + (v2 - v1) * frac; const audioStart2 = clamp$3(this.mp3SeekTable.audioStart ?? 0, 0, total); const audioBytes2 = Math.max(0, total - audioStart2); const offset2 = audioStart2 + Math.floor(v3 / 256 * audioBytes2); return clamp$3(offset2, 0, Math.max(0, total - 1)); } const audioStart = clamp$3(this.mp3SeekTable?.audioStart ?? 0, 0, total); const audioBytes = Math.max(0, total - audioStart); const offset = audioStart + Math.floor(t2 / duration * audioBytes); return clamp$3(offset, 0, Math.max(0, total - 1)); } alignChunkForMimeType(chunk) { const mime = (this.rangeMimeType ?? "").toLowerCase(); if (mime.includes("audio/mpeg") || mime.includes("audio/mp3")) { const i2 = findMp3FrameSync(chunk, 0, CODEC_SYNC_SCAN_LIMIT_BYTES); return i2 > 0 ? chunk.slice(i2) : chunk; } if (mime.includes("audio/aac") || mime.includes("audio/adts")) { const i2 = findAdtsSync(chunk, 0, CODEC_SYNC_SCAN_LIMIT_BYTES); return i2 > 0 ? chunk.slice(i2) : chunk; } return chunk; } getPlayheadSeconds() { return this.chaimu.video?.currentTime ?? this.audioElement?.currentTime ?? 0; } async fetchRangeBytes(src, startByte, endByte, abortSignal) { const headers = new Headers(this.fetchOpts?.headers); headers.set("Range", `bytes=${startByte}-${endByte}`); const response = await this.fetchFn(src, { ...this.fetchOpts, signal: abortSignal, headers, timeout: DEFAULT_MEDIA_FETCH_TIMEOUT_MS }); if (!this.isFetchResponseOk(response) || response.status !== 206) { throw new Error(`[ChaimuPlayer] Range request failed: ${response.status} ${response.statusText}`.trim()); } const cr = parseContentRange(response.headers.get("Content-Range")); if (cr && cr.start !== startByte) { debug$1.log("[ChaimuPlayer] Content-Range mismatch", { expected: startByte, got: cr.start }); } return new Uint8Array(await response.arrayBuffer()); } async evictBufferBehindPlayhead() { const sb = this.sourceBuffer; if (!sb) return false; const playhead = this.getPlayheadSeconds(); const removeEnd = playhead - RANGE_STREAM_TARGET_BUFFER_BEHIND_SECONDS - RANGE_STREAM_EVICT_MARGIN_SECONDS; if (removeEnd <= 0) return false; const buffered = sb.buffered; if (buffered.length === 0) return false; const start = buffered.start(0); const end = buffered.end(buffered.length - 1); const clampedEnd = Math.min(removeEnd, end); if (clampedEnd <= start) return false; try { await this.waitForUpdate(sb); sb.remove(start, clampedEnd); await this.waitForUpdate(sb); return true; } catch (e2) { debug$1.log("[ChaimuPlayer] Failed to evict buffer", e2); return false; } } async setupSeekableRangeStreamingAudio() { const mediaSource = this.mediaSource; const mimeType = this.rangeMimeType; const totalSize = this.rangeTotalSizeBytes; const src = this._src; if (!mediaSource || !mimeType || !totalSize || !src) { throw new Error("[ChaimuPlayer] Range streaming is not configured"); } let sourceBuffer; try { sourceBuffer = mediaSource.addSourceBuffer(mimeType); } catch (e2) { const reason = e2 instanceof Error ? e2.message : String(e2); throw new Error(`[ChaimuPlayer] Failed to add SourceBuffer: ${reason}`); } try { sourceBuffer.mode = "sequence"; } catch { } this.sourceBuffer = sourceBuffer; const initialTime = this.getPlayheadSeconds(); const seekId = ++this.rangeSeekId; await this.seekRangeStreamingToTime(initialTime, seekId, this.startToken); this.resolveRangeReady?.(); } async ensureRangeStreamingForTime(targetTimeSeconds, token) { const audio = this.audioElement; if (!audio) return; if (!this.mediaSource || !this.rangeTotalSizeBytes || !this.rangeMimeType) return; if (this.rangeReadyPromise) { try { try { audio.load(); } catch { } await withTimeout(this.rangeReadyPromise, RANGE_READY_TIMEOUT_MS, "[ChaimuPlayer] Timed out waiting for MediaSource to initialize"); } catch (e2) { debug$1.log("[ChaimuPlayer] Range streaming setup failed, falling back", e2); await this.fallbackToBufferedAudio(e2); return; } } if (token !== this.startToken) return; if (isTimeBuffered(audio, targetTimeSeconds)) return; const seekId = ++this.rangeSeekId; await this.seekRangeStreamingToTime(targetTimeSeconds, seekId, token).catch(async (e2) => { debug$1.log("[ChaimuPlayer] Range seek failed, falling back", e2); await this.fallbackToBufferedAudio(e2); }); } async seekRangeStreamingToTime(targetTimeSeconds, seekId, token) { const mediaSource = this.mediaSource; const sb = this.sourceBuffer; const total = this.rangeTotalSizeBytes; const mimeType = this.rangeMimeType; const src = this._src; if (!mediaSource || !sb || !total || !mimeType || !src) return; if (token !== this.startToken) return; const streamAbortController = this.createStreamAbortController(); const abortSignal = streamAbortController.signal; const duration = this.getVideoDurationSeconds() ?? Math.max(targetTimeSeconds + 1, 1); const startTime = clamp$3(targetTimeSeconds - SEEK_PREROLL_SECONDS, 0, duration); const startByte = this.timeToByteOffset(startTime); debug$1.log(`[ChaimuPlayer] Range-seek t=${targetTimeSeconds.toFixed(3)}s start=${startTime.toFixed(3)}s byte=${startByte}/${total}`); if (seekId !== this.rangeSeekId) return; await this.waitForUpdate(sb); if (seekId !== this.rangeSeekId || token !== this.startToken) return; try { sb.abort(); } catch { } try { if (sb.buffered.length > 0) { const removeStart = sb.buffered.start(0); const removeEnd = sb.buffered.end(sb.buffered.length - 1); sb.remove(removeStart, removeEnd); await this.waitForUpdate(sb); } } catch (e2) { debug$1.log("[ChaimuPlayer] Failed to clear SourceBuffer", e2); } if (seekId !== this.rangeSeekId || token !== this.startToken) return; try { sb.timestampOffset = startTime; } catch (e2) { debug$1.log("[ChaimuPlayer] Failed to set timestampOffset", e2); } const firstEnd = Math.min(startByte + RANGE_STREAM_CHUNK_SIZE_BYTES - 1, total - 1); const firstChunk = await this.fetchRangeBytes(src, startByte, firstEnd, abortSignal); if (abortSignal.aborted || seekId !== this.rangeSeekId || token !== this.startToken) return; const alignedFirstChunk = this.alignChunkForMimeType(firstChunk); await this.appendChunk(mediaSource, sb, alignedFirstChunk); if (abortSignal.aborted || seekId !== this.rangeSeekId || token !== this.startToken) return; const nextOffset = firstEnd + 1; this.streamingPromise = this.runRangeStreamingLoop(seekId, nextOffset, abortSignal).catch((e2) => { if (abortSignal.aborted) return; debug$1.log("[ChaimuPlayer] Range streaming loop failed", e2); void this.fallbackToBufferedAudio(e2); }); } async runRangeStreamingLoop(seekId, startOffset, abortSignal) { const mediaSource = this.mediaSource; const sb = this.sourceBuffer; const total = this.rangeTotalSizeBytes; const audio = this.audioElement; const src = this._src; if (!mediaSource || !sb || !total || !audio || !src) return; let offset = startOffset; while (offset < total) { if (abortSignal.aborted) return; if (seekId !== this.rangeSeekId) return; if (mediaSource.readyState !== "open") return; const playhead = this.getPlayheadSeconds(); const bufferedRange = getBufferedRange(audio, playhead); const bufferAhead = bufferedRange ? bufferedRange.end - playhead : 0; if (bufferAhead >= RANGE_STREAM_TARGET_BUFFER_AHEAD_SECONDS) { await sleep$1(RANGE_STREAM_IDLE_WAIT_MS); continue; } if (bufferedRange) { const keepBehindStart = playhead - RANGE_STREAM_TARGET_BUFFER_BEHIND_SECONDS; const removeEnd = keepBehindStart - RANGE_STREAM_EVICT_MARGIN_SECONDS; if (bufferedRange.start < removeEnd) { try { await this.waitForUpdate(sb); sb.remove(bufferedRange.start, removeEnd); await this.waitForUpdate(sb); } catch (e2) { debug$1.log("[ChaimuPlayer] Buffer eviction failed", e2); } } } const end = Math.min(offset + RANGE_STREAM_CHUNK_SIZE_BYTES - 1, total - 1); const chunk = await this.fetchRangeBytes(src, offset, end, abortSignal); if (abortSignal.aborted || seekId !== this.rangeSeekId) return; await this.appendChunk(mediaSource, sb, chunk); offset = end + 1; } } async fallbackToBufferedAudio(reason) { debug$1.log("[ChaimuPlayer] Falling back to fully-buffered audio", reason); if (!this._src) return; await this.stopStreaming(); const response = await this.fetchFn(this._src, { ...this.fetchOpts, timeout: DEFAULT_MEDIA_FETCH_TIMEOUT_MS }); if (!this.isFetchResponseOk(response)) { throw new Error(`[ChaimuPlayer] Failed to fetch audio (fallback): ${response.status} ${response.statusText}`.trim()); } await this.loadBufferedAudio(response); } async appendChunk(mediaSource, sourceBuffer, chunk) { await this.waitForUpdate(sourceBuffer); if (mediaSource.readyState !== "open") return; const buffer = this.toArrayBuffer(chunk); try { sourceBuffer.appendBuffer(buffer); } catch (e2) { if (isQuotaExceededError(e2)) { const evicted = await this.evictBufferBehindPlayhead(); if (evicted && mediaSource.readyState === "open") { await this.waitForUpdate(sourceBuffer); sourceBuffer.appendBuffer(buffer); } else { throw e2; } } else { throw e2; } } await this.waitForUpdate(sourceBuffer); } async waitForUpdate(sourceBuffer) { if (!sourceBuffer.updating) return; await new Promise((resolve, reject) => { let settled = false; const cleanup = () => { sourceBuffer.removeEventListener("error", onError); sourceBuffer.removeEventListener("updateend", onUpdateEnd); }; const onError = () => { if (settled) return; settled = true; cleanup(); reject(new Error("SourceBuffer update failed")); }; const onUpdateEnd = () => { if (settled) return; settled = true; cleanup(); resolve(); }; sourceBuffer.addEventListener("error", onError, { once: true }); sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true }); if (!sourceBuffer.updating) { onUpdateEnd(); } }); } async stopStreaming() { this.streamAbortCleanup?.(); this.streamAbortCleanup = void 0; this.streamAbortController?.abort(); this.streamAbortController = void 0; const streamingPromise = this.streamingPromise; this.streamingPromise = void 0; if (streamingPromise) { try { await streamingPromise; } catch (error2) { debug$1.log("[ChaimuPlayer] stopStreaming wait failed", error2); } } if (this.mediaSource) { if (this.mediaSource.readyState === "open") { try { this.mediaSource.endOfStream(); } catch { } } this.mediaSource = void 0; } this.sourceBuffer = void 0; this.rangeTotalSizeBytes = void 0; this.rangeMimeType = void 0; this.mp3SeekTable = void 0; this.rangeSeekId = 0; this.rangeReadyPromise = void 0; this.resolveRangeReady = void 0; this.rejectRangeReady = void 0; } setMediaUrl(url) { if (this.mediaUrl && this.mediaUrl !== url) { try { URL.revokeObjectURL(this.mediaUrl); } catch (e2) { debug$1.log("[ChaimuPlayer] Failed to revoke object URL", e2); } } this.mediaUrl = url; if (this.audioElement) { try { this.audioElement.src = url; this.audioElement.load(); } catch (e2) { debug$1.log("[ChaimuPlayer] Failed to update audio element src", e2); } } } toArrayBuffer(chunk) { const buffer = chunk.buffer; if (chunk.byteOffset === 0 && chunk.byteLength === buffer.byteLength) return buffer; return buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength); } async waitUntilBuffered(targetTimeSeconds, token) { const audio = this.audioElement; if (!audio) return; if (isTimeBuffered(audio, targetTimeSeconds)) return; await new Promise((resolve) => { if (!isBrowser()) { resolve(); return; } let done = false; let intervalId; let timeoutId; const cleanup = () => { if (intervalId !== void 0) window.clearInterval(intervalId); if (timeoutId !== void 0) window.clearTimeout(timeoutId); audio.removeEventListener("progress", onAny); audio.removeEventListener("canplay", onAny); audio.removeEventListener("loadedmetadata", onAny); audio.removeEventListener("durationchange", onAny); audio.removeEventListener("error", onError); sb?.removeEventListener("updateend", onAny); }; const finish = () => { if (done) return; done = true; cleanup(); resolve(); }; const check = () => { if (token !== this.startToken) return finish(); if (isTimeBuffered(audio, targetTimeSeconds)) return finish(); }; intervalId = window.setInterval(check, 200); timeoutId = window.setTimeout(() => { debug$1.log("[ChaimuPlayer] Buffer wait timed out; continuing", { targetTimeSeconds }); finish(); }, BUFFER_WAIT_TIMEOUT_MS); const sb = this.sourceBuffer; const streamingPromise = this.streamingPromise; if (streamingPromise) { void streamingPromise.catch(() => finish()); } const onAny = () => check(); const onError = () => finish(); audio.addEventListener("progress", onAny, { passive: true }); audio.addEventListener("canplay", onAny, { passive: true }); audio.addEventListener("loadedmetadata", onAny, { passive: true }); audio.addEventListener("durationchange", onAny, { passive: true }); audio.addEventListener("error", onError, { once: true }); sb?.addEventListener("updateend", onAny, { passive: true }); check(); }); } } class Chaimu { _debug = false; audioContext; player; video; fetchFn; fetchOpts; constructor({ url, video, debug: debug2 = false, fetchFn = config.fetchFn, fetchOpts = {}, preferAudio = false }) { this.video = video; this._debug = config.debug = debug2; this.fetchFn = fetchFn; this.fetchOpts = fetchOpts; this.audioContext = initAudioContext(); const normalizedUrl = url instanceof URL ? url.toString() : url; const canUseChaimuPlayer = Boolean(this.audioContext) && !preferAudio && typeof MediaSource !== "undefined"; this.player = canUseChaimuPlayer ? new ChaimuPlayer(this, normalizedUrl) : new AudioPlayer(this, normalizedUrl); } async init() { await this.player.init(); if (!this.video.paused) { this.player.lipSync("play"); } this.player.addVideoEvents(); } async destroy() { this.player.removeVideoEvents(); await this.player.clear(); } set debug(value) { this._debug = config.debug = value; } get debug() { return this._debug; } } const noop = () => { }; const log = noop; const warn = noop; const error = noop; const debug = { log, warn, error }; function getErrorMessage(error2) { if (!error2) return ""; if (typeof error2 === "string") return error2; const anyErr = error2; return anyErr?.data?.message || anyErr?.error?.message || anyErr?.message || (typeof anyErr?.toString === "function" ? anyErr.toString() : "") || String(anyErr); } function isAbortError(err) { const anyErr = err; return typeof DOMException !== "undefined" && anyErr instanceof DOMException && anyErr.name === "AbortError" || anyErr instanceof Error && anyErr.name === "AbortError" || anyErr?.message === "AbortError"; } function makeAbortError(message = "Aborted") { try { return new DOMException(message, "AbortError"); } catch { const err = new Error(message); err.name = "AbortError"; return err; } } const IFRAME_HASH = "vot_iframe"; const isIframe = () => globalThis.self !== globalThis.top; const generateMessageId = () => typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? `main-world-bridge-${crypto.randomUUID()}` : `main-world-bridge-${Date.now()}-${Math.random().toString(36).slice(2)}`; const SERVICE_IFRAME_READY_TIMEOUT_MS = 15e3; const hasServiceIframe = (iframeId) => document.getElementById(iframeId); const isBridgeMessageLike = (value) => { if (!value || typeof value !== "object") { return false; } const data = value; return typeof data.messageType === "string" && typeof data.messageDirection === "string"; }; const normalizedHref = (url) => { try { return new URL(url, globalThis.location.href).href; } catch { return null; } }; const iframeSrcMatches = (iframe, expectedSrc) => { const expected = normalizedHref(expectedSrc); const current = normalizedHref(iframe.src); if (!expected || !current) { return iframe.getAttribute("src") === expectedSrc; } return current === expected; }; const isLiveMatchingServiceIframe = (iframe, expectedSrc) => { if (!iframe?.isConnected || !iframe.contentWindow) { return false; } return iframeSrcMatches(iframe, expectedSrc); }; async function setupServiceIframe(src, id, service) { if (!document.body) { await new Promise((resolve) => { if (document.readyState === "loading") { globalThis.addEventListener("DOMContentLoaded", () => resolve(), { once: true }); } else { resolve(); } }); } const iframe = document.createElement("iframe"); iframe.style.position = "fixed"; iframe.style.left = "0"; iframe.style.top = "0"; iframe.style.width = "1px"; iframe.style.height = "1px"; iframe.style.opacity = "0"; iframe.style.pointerEvents = "none"; iframe.style.border = "0"; iframe.style.zIndex = "-1"; iframe.setAttribute("allow", "autoplay; encrypted-media"); iframe.id = id; iframe.src = `${src}#${IFRAME_HASH}`; document.body.appendChild(iframe); const expectedSource = iframe.contentWindow; const readyMessageType = `say-${service}-iframe-is-ready`; try { await new Promise((resolve, reject) => { const handleMessage = (event) => { if (event.source !== expectedSource) { return; } if (!isBridgeMessageLike(event.data)) { return; } if (event.data.messageType !== readyMessageType || event.data.messageDirection !== "response") { return; } cleanup(); resolve(); }; const timeoutId = globalThis.setTimeout(() => { cleanup(); reject(new Error("Service iframe did not have time to be ready")); }, SERVICE_IFRAME_READY_TIMEOUT_MS); const cleanup = () => { globalThis.clearTimeout(timeoutId); globalThis.removeEventListener("message", handleMessage); }; globalThis.addEventListener("message", handleMessage); }); } catch (error2) { iframe.remove(); throw error2; } return iframe; } async function ensureServiceIframe(iframe, src, iframeId, service) { if (src.includes("#")) { throw new Error( "The src parameter should not contain a hash (#) character." ); } const expectedSrc = `${src}#${IFRAME_HASH}`; const iframeFromDom = hasServiceIframe(iframeId); if (isLiveMatchingServiceIframe(iframeFromDom, expectedSrc)) { return iframeFromDom; } if (iframeFromDom) { iframeFromDom.remove(); } return setupServiceIframe(src, iframeId, service); } function initIframeService(service, onmessage) { globalThis.addEventListener("message", onmessage); globalThis.parent.postMessage( { messageType: `say-${service}-iframe-is-ready`, messageDirection: "response" }, "*" ); } function requestDataFromMainWorldWithId(messageType, payload, options) { const messageId = generateMessageId(); const signal = options?.signal; const promise = new Promise((resolve, reject) => { let settled = false; let handleMessage = null; let onAbort = null; const cleanup = () => { if (handleMessage) { globalThis.removeEventListener("message", handleMessage); } if (signal && onAbort) { signal.removeEventListener("abort", onAbort); } }; const settle = (fn) => { if (settled) return; settled = true; cleanup(); fn(); }; onAbort = () => { settle(() => reject(makeAbortError())); }; handleMessage = (event) => { if (event.source !== globalThis.window) { return; } if (!isBridgeMessageLike(event.data)) { return; } const data = event.data; if (data?.messageId === messageId && data.messageType === messageType && data.messageDirection === "response") { settle(() => data.error ? reject(data.error) : resolve(data.payload)); } }; if (signal?.aborted) { onAbort(); return; } globalThis.addEventListener("message", handleMessage); signal?.addEventListener("abort", onAbort, { once: true }); globalThis.postMessage( { messageId, messageType, messageDirection: "request", ...payload !== void 0 && { payload } }, "*" ); }); return { messageId, promise }; } const IFRAME_ID = "vot_iframe_player"; const IFRAME_SERVICE = "service"; const IFRAME_HOST = "www.youtube.com"; const MIN_CHUNK_RANGES_PART_SIZE = votConfig.minChunkSize; const MIN_CONTENT_LENGTH_MULTIPLIER = 0.9; const CHUNK_STEPS = [6e4, 8e4, 15e4, 33e4, 46e4]; const MIN_ARRAY_BUFFER_LENGTH = 15e3; const ACCEPTABLE_LENGTH_DIFF = 0.9; const getRequestUrl = (request) => typeof request === "string" ? request : request.url; function serializeRequestInit(request) { const body = new Uint8Array([120, 0]); if (typeof request === "string") { return { body, cache: "no-store", credentials: "include", 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 }; } function deserializeRequestInit(request) { const { headersEntries, body, ...options } = request; const headers = new Headers(headersEntries); const deserialized = { ...options, headers }; if (body && body.byteLength > 0) { const bytes = new Uint8Array(body.byteLength); bytes.set(body); deserialized.body = bytes.buffer; } return deserialized; } function serializeResponse(response) { const { ok, redirected, status, statusText, type, url } = response; return { ok, redirected, status, statusText, type, url }; } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function timeout(ms, message = "Operation timed out") { return new Promise( (_2, reject) => setTimeout(() => reject(new Error(message)), ms) ); } async function waitForCondition(condition, timeoutMs, throwOnTimeout = false) { const deadline = Date.now() + timeoutMs; while (!condition()) { if (Date.now() >= deadline) { if (throwOnTimeout) { throw new Error(`Wait for condition reached timeout of ${timeoutMs}`); } return; } await sleep(100); } } let lastMessageId = ""; const getAdaptiveFormats = () => YoutubeHelper.getPlayerResponse()?.streamingData?.adaptiveFormats; async function isEncodedRequest(url, request) { if (!url.includes("googlevideo.com/videoplayback") || typeof request === "string") { return false; } try { const reader = request.clone().body?.getReader(); if (!reader) { return false; } let totalLength = 0; while (true) { const { done, value } = await reader.read(); if (done) { break; } totalLength += value.length; if (totalLength > 2) { return true; } } } catch { } return false; } function selectBestAudioFormat() { const allFormats = getAdaptiveFormats(); if (!allFormats?.length) { const reason = !allFormats ? "Cannot get adaptive formats" : "Empty adaptive formats"; throw new Error(`Audio downloader. WEB API. ${reason}`); } const audioFormats = allFormats.filter( ({ audioQuality, mimeType }) => audioQuality || mimeType?.includes("audio") ); if (!audioFormats.length) { throw new Error("Audio downloader. WEB API. No audio adaptive formats"); } const itag251Sorted = audioFormats.filter(({ itag }) => itag === 251).sort( ({ contentLength: a2 }, { contentLength: b2 }) => a2 && b2 ? Number.parseInt(a2, 10) - Number.parseInt(b2, 10) : -1 ); return itag251Sorted.at(-1) ?? audioFormats[0]; } const waitForPlayer = async () => { await waitForCondition(() => Boolean(YoutubeHelper.getPlayer()), 1e4); return YoutubeHelper.getPlayer(); }; const loadVideo = async (data) => { const player2 = await waitForPlayer(); if (data.messageId !== lastMessageId) { throw new Error( "Audio downloader. Download started for another video while getting player" ); } if (!player2?.loadVideoById) { throw new Error( "Audio downloader. There is no player.loadVideoById in iframe" ); } player2.loadVideoById(data.payload.videoId); player2.pauseVideo?.(); player2.mute?.(); setTimeout(() => { if (data.messageId !== lastMessageId) { console.error( "Audio Downloader. Download started for another video while waiting to repause video" ); return; } if (!player2) { console.error( "[Critical] Audio Downloader. Player not found in iframe after timeout" ); return; } player2.pauseVideo?.(); }, 1e3); }; async function getDownloadAudioData(data) { try { lastMessageId = data.messageId; debug.log("getDownloadAudioData", data); const originalFetch = unsafeWindow.fetch; unsafeWindow.fetch = async (input, init2) => { if (input instanceof URL) { input = input.toString(); } const requestUrl = getRequestUrl(input); if (await isEncodedRequest(requestUrl, input)) { globalThis.parent.postMessage( { ...data, messageDirection: "response", error: "Audio downloader. Detected encoded request." }, "*" ); unsafeWindow.fetch = originalFetch; return originalFetch(input, init2); } const response = await originalFetch(input, init2); if (data.messageId !== lastMessageId) { unsafeWindow.fetch = originalFetch; return response; } if (requestUrl.includes("&itag=251&")) { unsafeWindow.fetch = originalFetch; globalThis.parent.postMessage( { ...data, messageDirection: "response", payload: { requestInfo: requestUrl, requestInit: init2 || serializeRequestInit(input), adaptiveFormat: selectBestAudioFormat(), itag: 251 } }, "*" ); } return response; }; await loadVideo(data); } catch (error2) { globalThis.parent.postMessage( { ...data, messageDirection: "response", error: error2 }, "*" ); } } const handleIframeMessage = async ({ data }) => { if (data?.messageDirection !== "request") { return; } try { if (data.messageType === "get-download-audio-data-in-iframe") { await getDownloadAudioData( data.payload ); } else { debug.log(`NOT IMPLEMENTED: ${data.messageType}`, data.payload); } } catch (error2) { console.error("[VOT] Main world bridge", { error: error2 }); } }; function initAudioDownloaderIframe() { return initIframeService(IFRAME_SERVICE, handleIframeMessage); } const MAIN_BOOT_KEY = "__VOT_MAIN_BOOT_STATE__"; function isBootstrapStatus(value) { return value === "idle" || value === "booting" || value === "booted" || value === "failed"; } function isBootstrapState(value) { if (!value || typeof value !== "object") return false; const candidate = value; return isBootstrapStatus(candidate.status); } function getOrCreateBootState(bootKey = MAIN_BOOT_KEY) { const scope = globalThis; const existing = scope[bootKey]; if (isBootstrapState(existing)) { return existing; } const created = { status: "idle", promise: null, error: null }; scope[bootKey] = created; return created; } const workerHost = "api.browser.yandex.ru"; const m3u8ProxyHost = "media-proxy.toil.cc/v1/proxy/m3u8"; const proxyWorkerHost = "vot-worker.toil.cc"; const votBackendUrl = "https://vot.toil.cc/v1"; const foswlyTranslateUrl = "https://translate.toil.cc/v2"; const detectRustServerUrl = "https://rust-server-531j.onrender.com/detect"; const authServerUrl = "https://t2mc.toil.cc"; const avatarServerUrl = "https://avatars.mds.yandex.net/get-yapic"; const repoPath = "ilyhalight/voice-over-translation"; const contentUrl = `https://raw.githubusercontent.com/${repoPath}`; const repositoryUrl = `https://github.com/${repoPath}`; const defaultAutoVolume = 15; const maxAudioVolume = 900; const minLongWaitingCount = 5; const defaultTranslationService = "yandexbrowser"; const defaultDetectService = "rust-server"; const nonProxyExtensions = ["Tampermonkey", "Violentmonkey"]; const proxyOnlyCountries = ["UA", "LV", "LT"]; const defaultAutoHideDelay = 1e3; const actualCompatVersion = "2025-05-09"; const storageKeys = [ "autoTranslate", "autoSubtitles", "dontTranslateLanguages", "enabledDontTranslateLanguages", "enabledAutoVolume", "enabledSmartDucking", "autoVolume", "buttonPos", "showVideoSlider", "syncVolume", "downloadWithName", "sendNotifyOnComplete", "subtitlesMaxLength", "subtitlesSmartLayout", "highlightWords", "subtitlesFontSize", "subtitlesOpacity", "subtitlesDownloadFormat", "responseLanguage", "defaultVolume", "onlyBypassMediaCSP", "newAudioPlayer", "showPiPButton", "translateAPIErrors", "translationService", "detectService", "translationHotkey", "subtitlesHotkey", "m3u8ProxyHost", "proxyWorkerHost", "translateProxyEnabled", "translateProxyEnabledDefault", "audioBooster", "useLivelyVoice", "autoHideButtonDelay", "useAudioDownload", "compatVersion", "localePhrases", "localeLang", "localeHash", "localeUpdatedAt", "localeLangOverride", "account" ]; function throwIfAborted(signal) { const maybeThrow = signal.throwIfAborted; if (typeof maybeThrow === "function") { try { maybeThrow.call(signal); return; } catch (e2) { if (signal.aborted || isAbortError(e2)) { throw makeAbortError(); } throw e2 instanceof Error ? e2 : new Error(String(e2)); } } if (signal.aborted) { throw makeAbortError(); } } function createTimeoutSignal(timeoutMs, external) { const hasTimeout = typeof AbortSignal !== "undefined" && "timeout" in AbortSignal; const hasAny = typeof AbortSignal !== "undefined" && "any" in AbortSignal; let timedOut = false; const hasEffectiveTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0; if (!hasEffectiveTimeout) { if (external) { return { signal: external, didTimeout: () => false, cleanup: () => { } }; } const controller2 = new AbortController(); return { signal: controller2.signal, didTimeout: () => false, cleanup: () => { } }; } if (hasTimeout && hasAny) { const timeoutSignal = AbortSignal.timeout( timeoutMs ); const signal = AbortSignal.any( external ? [external, timeoutSignal] : [timeoutSignal] ); const id2 = setTimeout(() => { timedOut = true; }, timeoutMs); return { signal, didTimeout: () => timedOut, cleanup: () => clearTimeout(id2) }; } const controller = new AbortController(); const onExternalAbort = () => controller.abort(external?.reason); if (external) { if (external.aborted) { controller.abort(external.reason); } else { external.addEventListener("abort", onExternalAbort, { once: true }); } } const id = setTimeout(() => { timedOut = true; controller.abort(makeAbortError("Timeout")); }, timeoutMs); return { signal: controller.signal, didTimeout: () => timedOut, cleanup: () => { clearTimeout(id); external?.removeEventListener("abort", onExternalAbort); } }; } function getNavigatorLang() { return navigator.language?.substring(0, 2).toLowerCase() || "en"; } const slavicLangs = new Set([ "uk", "be", "bg", "mk", "sr", "bs", "hr", "sl", "pl", "sk", "cs" ]); function resolveCalculatedResLang(baseLang2) { if (availableTTS.includes(baseLang2)) { return baseLang2; } if (slavicLangs.has(baseLang2)) { return "ru"; } return "en"; } const lang = getNavigatorLang(); const calculatedResLang = resolveCalculatedResLang(lang); function stableStringify(value) { const seen2 = new WeakSet(); return JSON.stringify(value, (_key, val) => { if (val && typeof val === "object") { const obj = val; if (seen2.has(obj)) { return "[Circular]"; } seen2.add(obj); if (Array.isArray(val)) return val; const sorted = {}; const entries = Object.entries(val).sort( ([leftKey], [rightKey]) => leftKey.localeCompare(rightKey) ); for (const [key, entryValue] of entries) { sorted[key] = entryValue; } return sorted; } return val; }); } function fnv1a32ToKeyPart(str) { let hash = 2166136261; let i2 = 0; while (i2 < str.length) { const codePoint = str.codePointAt(i2) ?? 0; hash ^= codePoint; hash = Math.imul(hash, 16777619); i2 += codePoint > 65535 ? 2 : 1; } return (hash >>> 0).toString(36); } const isPiPAvailable = () => "pictureInPictureEnabled" in document && Boolean(document.pictureInPictureEnabled); function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a2 = document.createElement("a"); a2.href = url; a2.download = filename; document.body.appendChild(a2); a2.click(); a2.remove(); revokeObjectUrlLater(url); } const DEFAULT_OBJECT_URL_REVOKE_DELAY_MS = 3e4; function revokeObjectUrlLater(url, delayMs = DEFAULT_OBJECT_URL_REVOKE_DELAY_MS) { globalThis.setTimeout(() => URL.revokeObjectURL(url), delayMs); } function clearFileName(filename) { const name = filename.trim(); if (!name) return ( new Date()).toISOString().slice(0, 10); return name.replace(/^https?:\/\//, "").replaceAll(/[\\/:*?"'<>|]/g, "-"); } const getTimestamp = () => Math.floor(Date.now() / 1e3); const getHeaders = (headers) => headers ? Object.fromEntries(new Headers(headers).entries()) : {}; const clamp$2 = (value, min = 0, max = 100) => Math.min(Math.max(value, min), max); function toFlatObj(data) { const out = {}; const stack = Object.entries(data); while (stack.length) { const entry = stack.pop(); if (!entry) continue; const [key, val] = entry; if (val === void 0) continue; const isPlainObject = val !== null && typeof val === "object" && !Array.isArray(val); if (!isPlainObject) { out[key] = val; continue; } for (const [k2, v2] of Object.entries(val)) { stack.push([`${key}.${k2}`, v2]); } } return out; } async function exitFullscreen() { const doc = document; if (!doc.fullscreenElement && !doc.webkitFullscreenElement) return; if (doc.exitFullscreen) return doc.exitFullscreen(); return doc.webkitExitFullscreen?.(); } const isProxyOnlyExtension = ( !(typeof IS_EXTENSION !== "undefined" && IS_EXTENSION) && !!GM_info?.scriptHandler && !nonProxyExtensions.includes(GM_info.scriptHandler) ); const isSupportGM4 = typeof GM !== "undefined"; const isUnsafeWindowAllowed = typeof unsafeWindow !== "undefined"; const isSupportGMXhr = typeof GM_xmlhttpRequest !== "undefined"; const DEFAULT_TIMEOUT = 15e3; const YANDEX_TRANSLATE_HOST = "api.browser.yandex.ru"; const YANDEX_MIN_TIMEOUT = 3e4; 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; const toUrlString = (u2) => { if (typeof u2 === "string") { return u2; } if (u2 instanceof URL) { return u2.href; } return u2.url; }; function isNoFallbackError(err) { return !!err && typeof err === "object" && "__gmFetchNoFallback" in err; } function markNoFallbackError(err) { if (!err || typeof err !== "object") { return; } err.__gmFetchNoFallback = true; } function isYandexTranslateRequest(urlStr) { try { return new URL(urlStr).hostname === YANDEX_TRANSLATE_HOST; } catch { return urlStr.includes(YANDEX_TRANSLATE_HOST); } } const forceGmXhr = (urlStr) => isYandexTranslateRequest(urlStr); function getEffectiveTimeoutMs(urlStr, timeoutMs) { if (!isYandexTranslateRequest(urlStr)) return timeoutMs; return Math.max(timeoutMs, YANDEX_MIN_TIMEOUT); } function makeRequestId() { gmFetchRequestSeq += 1; return `gm_fetch_${Date.now()}_${gmFetchRequestSeq}`; } function isRetryableError(err, signal) { if (err?.__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(urlStr, status, attempt, maxAttempts) { return isYandexTranslateRequest(urlStr) && attempt < maxAttempts && RETRYABLE_HTTP_STATUSES.has(status); } function shouldRetryRequestError(urlStr, err, attempt, maxAttempts, signal) { return isYandexTranslateRequest(urlStr) && attempt < maxAttempts && isRetryableError(err, signal); } function getBodyDebugInfo(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?.constructor?.name; return typeof ctorName === "string" ? ctorName : null; } catch { return null; } })(); const keys = body && typeof body === "object" ? Object.keys(body).slice( 0, BODY_OBJECT_KEYS_DEBUG_LIMIT ) : void 0; return { type: typeof body, size: -1, objectTag, ctor, keys }; } function getHeaderDebugInfo(headers) { if (!headers) { return { headerCount: 0, headerNames: [], contentType: null }; } const names = []; let contentType = null; const addHeader = (name, value) => { 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: [], contentType: null }; } return { headerCount: names.length, headerNames: names.slice(0, HEADER_DEBUG_LIMIT), contentType }; } function parseRawHeaders(raw = "") { const out = {}; for (const line of raw.split(/\r?\n/)) { const m2 = /^([\w!#$%&'*+.^`|~-]+)\s*:\s*(.*)$/.exec(line); if (m2) out[m2[1].toLowerCase()] = m2[2]; } return out; } async function nativeFetchWithTimeout(url, init2, timeoutMs, trace) { const { signal, didTimeout, cleanup } = createTimeoutSignal( timeoutMs, init2.signal ); try { debug.log("[VOT][GM_fetch][native] start", { url: toUrlString(url), method: init2.method ?? "GET", timeoutMs, requestId: trace?.requestId, attempt: trace?.attempt, effectiveTimeoutMs: trace?.effectiveTimeoutMs, credentials: init2.credentials ?? "same-origin", mode: init2.mode ?? "cors", ...getHeaderDebugInfo(init2.headers), ...getBodyDebugInfo(init2.body) }); return await fetch(url, { ...init2, signal }); } catch (e2) { debug.error("[VOT][GM_fetch][native] error", { url: toUrlString(url), message: e2 instanceof Error ? e2.message : String(e2), requestId: trace?.requestId, attempt: trace?.attempt, effectiveTimeoutMs: trace?.effectiveTimeoutMs, isAbortError: isAbortError(e2), signalAborted: !!init2.signal?.aborted, didTimeout: didTimeout() }); if (isAbortError(e2) && !didTimeout() && init2.signal?.aborted) { markNoFallbackError(e2); } throw e2; } finally { cleanup(); } } function gmXhrFetch(urlStr, init2, timeoutMs, fetchErrForAbortReuse, trace) { const headers = getHeaders(init2.headers); const method = init2.method ?? "GET"; const supportsReadableStream = typeof ReadableStream !== "undefined"; const useProgressStream = supportsReadableStream && !forceGmXhr(urlStr); const credentials = init2.credentials; const withCredentials = credentials === "include" ? true : void 0; const anonymous = credentials === "omit" ? true : void 0; return new Promise((resolve, reject) => { let responseResolved = false; let req; let streamCtrl; let seenBytes = 0; const cleanup = () => init2.signal?.removeEventListener("abort", onAbort); const resolveResponse = (body, status, statusText, rawHeaders) => { 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) => { 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 : makeAbortError(); fail(abortErr); }; if (init2.signal) { if (init2.signal.aborted) return onAbort(); init2.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(init2.body) }); if (typeof init2.body === "string" && /^\[object [^\]]+\]$/.test(init2.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: init2.body }); } const stream = useProgressStream ? new ReadableStream({ start(controller) { streamCtrl = controller; }, cancel() { req?.abort?.(); } }) : null; const pushNewBytes = (evtOrResp) => { if (!useProgressStream || !streamCtrl) return; const chunk = evtOrResp?.chunk; if (chunk && typeof chunk !== "string") { const u82 = new Uint8Array(chunk); if (u82.byteLength) { streamCtrl.enqueue(u82); seenBytes += u82.byteLength; } return; } const resp = evtOrResp?.response ?? evtOrResp; if (!resp || typeof resp === "string") return; const u8 = new Uint8Array(resp); if (u8.byteLength <= seenBytes) return; streamCtrl.enqueue(u8.slice(seenBytes)); seenBytes = u8.byteLength; }; const gmXhr = typeof GM_xmlhttpRequest !== "undefined" ? GM_xmlhttpRequest : globalThis.GM_xmlhttpRequest; if (typeof gmXhr !== "function") { throw new TypeError("GM_xmlhttpRequest is not available"); } req = gmXhr({ method, url: urlStr, headers, data: init2.body, withCredentials, anonymous, timeout: timeoutMs, responseType: "arraybuffer", onprogress: useProgressStream ? (p2) => { const progress = p2; 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 ); } pushNewBytes(progress); } : void 0, onload: (r2) => { cleanup(); debug.log("[VOT][GM_fetch][xhr] onload", { url: urlStr, method, requestId: trace?.requestId, attempt: trace?.attempt, effectiveTimeoutMs: trace?.effectiveTimeoutMs, status: r2.status, statusText: r2.statusText, responseType: "arraybuffer", responseSize: r2.response && typeof r2.response !== "string" ? r2.response.byteLength ?? null : null }); if (useProgressStream) { if (!responseResolved) { if (!stream) return; resolveResponse(stream, r2.status, r2.statusText, r2.responseHeaders); } pushNewBytes(r2); streamCtrl?.close(); return; } resolveResponse( r2.response && typeof r2.response !== "string" ? r2.response : null, r2.status, r2.statusText, r2.responseHeaders ); }, onerror: fail, ontimeout: () => { debug.error("[VOT][GM_fetch][xhr] timeout", { url: urlStr, method, timeoutMs, requestId: trace?.requestId, attempt: trace?.attempt, effectiveTimeoutMs: trace?.effectiveTimeoutMs }); 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()); } }); }); } async function GM_fetch(url, opts = {}) { const { timeout: timeout2 = DEFAULT_TIMEOUT, ...init2 } = opts; const urlStr = toUrlString(url); const shouldForce = isSupportGMXhr && forceGmXhr(urlStr); const effectiveTimeoutMs = getEffectiveTimeoutMs(urlStr, timeout2); const requestId = makeRequestId(); const maxAttempts = isYandexTranslateRequest(urlStr) ? YANDEX_MAX_ATTEMPTS : 1; const requestMeta = { url: urlStr, method: init2.method ?? "GET", timeoutMs: timeout2, effectiveTimeoutMs, requestId, shouldForceGmXhr: shouldForce, canUseGmXhr: isSupportGMXhr, ...getHeaderDebugInfo(init2.headers), ...getBodyDebugInfo(init2.body) }; let lastError; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const trace = { requestId, attempt, effectiveTimeoutMs }; try { if (!shouldForce) { const res = await nativeFetchWithTimeout( url, init2, effectiveTimeoutMs, trace ); debug.log("[VOT][GM_fetch] native response", { ...requestMeta, attempt, status: res.status, statusText: res.statusText, type: res.type, redirected: res.redirected }); 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, init2, effectiveTimeoutMs, void 0, 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 (e2) { if (isNoFallbackError(e2) && e2.__gmFetchNoFallback) throw e2; if (!isSupportGMXhr) { if (shouldRetryRequestError(urlStr, e2, attempt, maxAttempts, init2.signal)) { debug.warn("[VOT][GM_fetch] retrying after native error", { ...requestMeta, attempt, reason: e2 instanceof Error ? e2.message : String(e2) }); lastError = e2; continue; } throw e2; } debug.warn("[VOT][GM_fetch] fallback to GM_xmlhttpRequest", { ...requestMeta, attempt, reason: e2 instanceof Error ? e2.message : String(e2) }); try { const res = await gmXhrFetch( urlStr, init2, effectiveTimeoutMs, e2, 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, init2.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_; } } } throw lastError ?? new Error("GM_fetch failed"); } const compatMay2025Data = { numToBool: [ ["autoTranslate"], ["dontTranslateYourLang", "enabledDontTranslateLanguages"], ["autoSetVolumeYandexStyle", "enabledAutoVolume"], ["showVideoSlider"], ["syncVolume"], ["downloadWithName"], ["sendNotifyOnComplete"], ["highlightWords"], ["onlyBypassMediaCSP"], ["newAudioPlayer"], ["showPiPButton"], ["translateAPIErrors"], ["audioBooster"], ["useNewModel", "useLivelyVoice"] ], number: [["autoVolume"]], array: [["dontTranslateLanguage", "dontTranslateLanguages"]], string: [ ["hotkeyButton", "translationHotkey"], ["locale-lang-override", "localeLangOverride"], ["locale-lang", "localeLang"] ] }; function getCompatCategory(key, value, convertData) { if (typeof value === "number") { return convertData?.number.some((item) => item[0] === key) ? "number" : "numToBool"; } else if (Array.isArray(value)) { return "array"; } else if (typeof value === "string" || value === null) { return "string"; } return void 0; } function convertByCompatCategory(category, value) { if (["string", "array", "number"].includes(category)) { return value; } return !!value; } async function updateConfig(data) { if (data.compatVersion === actualCompatVersion) { return data; } const oldKeys = Object.values(compatMay2025Data).flat().reduce((result, key) => { if (key[1]) { result[key[0]] = void 0; } return result; }, {}); const oldData = await votStorage.getValues(oldKeys); const existsOldData = Object.fromEntries( Object.entries(oldData).filter(([_2, value]) => value !== void 0) ); const allData = { ...data, ...existsOldData }; const allDataKeys = Object.keys(allData).reduce( (result, key) => { result[key] = void 0; return result; }, {} ); const realValues = await votStorage.getValues(allDataKeys); const newData = data; for (const [key, value] of Object.entries(allData)) { const category = getCompatCategory(key, value, compatMay2025Data); if (!category) { continue; } const compatItem = compatMay2025Data[category].find( (item) => item[0] === key ); if (!compatItem) { continue; } const newKey = compatItem[1] ?? key; if (realValues[key] === void 0) { continue; } let newValue = convertByCompatCategory(category, value); if (key === "autoVolume" && value < 1) { newValue = Math.round(value * 100); } newData[newKey] = newValue; if (existsOldData[key] !== void 0) { await votStorage.delete(key); } await votStorage.set(newKey, newValue); } return { ...newData, compatVersion: actualCompatVersion }; } class VOTStorage { supportGM = false; supportGMPromises = false; supportGMGetValues = false; supportResolved = false; resolveSupport() { if (this.supportResolved) return; this.supportResolved = true; this.supportGM = typeof GM_getValue === "function"; this.supportGMPromises = isSupportGM4 && typeof GM?.getValue === "function"; this.supportGMGetValues = isSupportGM4 && typeof GM?.getValues === "function"; debug.log( `[VOT Storage] GM Promises: ${this.supportGMPromises} | GM: ${this.supportGM}` ); } get isSupportOnlyLS() { this.resolveSupport(); return !this.supportGM && !this.supportGMPromises; } syncGet(name, def) { this.resolveSupport(); if (this.supportGM) { return GM_getValue(name, def); } const val = globalThis.localStorage.getItem(name); if (!val) { return def; } try { return JSON.parse(val); } catch { return def; } } async get(name, def) { this.resolveSupport(); if (this.supportGMPromises) { return await GM.getValue(name, def); } return this.syncGet(name, def); } async getValues(data) { this.resolveSupport(); if (this.supportGMGetValues) { return await GM.getValues(data); } return Object.fromEntries( await Promise.all( Object.entries(data).map( async ([key, value]) => { const val = await this.get( key, value ); return [key, val]; } ) ) ); } syncSet(name, value) { this.resolveSupport(); if (this.supportGM) { return GM_setValue(name, value); } return globalThis.localStorage.setItem(name, JSON.stringify(value)); } async set(name, value) { this.resolveSupport(); if (this.supportGMPromises) { return await GM.setValue(name, value); } return this.syncSet(name, value); } syncDelete(name) { this.resolveSupport(); if (this.supportGM) { return GM_deleteValue(name); } return globalThis.localStorage.removeItem(name); } async delete(name) { this.resolveSupport(); if (this.supportGMPromises) { return await GM.deleteValue(name); } return this.syncDelete(name); } syncList() { this.resolveSupport(); if (this.supportGM) { return GM_listValues(); } return storageKeys; } async list() { this.resolveSupport(); if (this.supportGMPromises) { return await GM.listValues(); } return this.syncList(); } } const VOT_STORAGE_GLOBAL_KEY = "__VOT_STORAGE_SINGLETON__"; const votStorage = (() => { const scope = globalThis; const existing = scope[VOT_STORAGE_GLOBAL_KEY]; if (existing instanceof VOTStorage) { return existing; } const created = new VOTStorage(); scope[VOT_STORAGE_GLOBAL_KEY] = created; return created; })(); async function handleAuthCallbackPage() { const { access_token: token, expires_in: expiresIn } = Object.fromEntries( new URLSearchParams(globalThis.location.hash.slice(1)) ); if (!token || !expiresIn) { throw new Error("[VOT] Invalid token response"); } const numExpiresIn = Number.parseInt(expiresIn, 10); if (Number.isNaN(numExpiresIn)) { throw new TypeError("[VOT] Invalid expires_in value"); } await votStorage.set("account", { token, expires: Date.now() + numExpiresIn * 1e3, username: void 0, avatarId: void 0 }); } async function handleProfilePage() { const { avatar_id: avatarId, username } = _userData; if (!avatarId || !username) { throw new Error("[VOT] Invalid user data"); } const data = await votStorage.get("account"); if (!data) { throw new Error("[VOT] No account data found"); } await votStorage.set("account", { ...data, username, avatarId }); } async function initAuth() { if (globalThis.location.pathname === "/auth/callback") { return await handleAuthCallbackPage(); } if (globalThis.location.pathname === "/my/profile") { return await handleProfilePage(); } } const recommended = "recommended"; const translateVideo = "Translate video"; const disableTranslate = "Turn off"; const translationSettings = "Translation settings"; const subtitlesSettings = "Subtitles settings"; const subtitlesSmartLayout = "Smart subtitle layout"; const resetSettings = "Reset settings"; const videoBeingTranslated = "The video is being translated"; const videoLanguage = "Video language"; const translationLanguage = "Translation language"; const translationTake = "The translation will take"; const translationTakeMoreThanHour = "The translation will take more than an hour"; const translationTakeAboutMinute = "The translation will take about a minute"; const translationTakeFewMinutes = "The translation will take a few minutes"; const translationTakeApproximatelyMinutes = "The translation will take approximately {0} minutes"; const translationTakeApproximatelyMinute = "The translation will take approximately {0} minutes"; const requestTranslationFailed = "Failed to request video translation"; const audioNotReceived = "Audio link not received"; const VOTFailedDownloadAudio = "Failed to download audio"; const audioFormatNotSupported = "The audio format is not supported"; const VOTAutoTranslate = "Translate on open"; const VOTAutoSubtitles = "Subtitles on open"; const VOTDontTranslateYourLang = "Don't translate from my language"; const VOTVolume = "Video volume:"; const VOTVolumeTranslation = "Translation volume:"; const VOTAutoSetVolume = "Reduce video volume to"; const VOTShowVideoSlider = "Video volume slider"; const VOTSyncVolume = "Link translation and video volume"; const VOTDisableFromYourLang = "You have disabled the translation of the video in your language"; const VOTVideoIsTooLong = "Video is too long"; const VOTNoVideoIDFound = "No video ID found"; const VOTSubtitles = "Subtitles"; const VOTSubtitlesDisabled = "Disabled"; const VOTSubtitlesMaxLength = "Subtitles max length"; const VOTHighlightWords = "Highlight words"; const VOTTranslatedFrom = "translated from"; const VOTAutogenerated = "autogenerated"; const VOTSettings = "VOT Settings"; const VOTMenuLanguage = "Menu language"; const VOTAuthors = "Authors"; const VOTVersion = "Version"; const VOTLoader = "Loader"; const VOTBrowser = "Browser"; const VOTShowPiPButton = "Show PiP button"; const langs = { "auto": "Auto", "af": "Afrikaans", "ak": "Akan", "sq": "Albanian", "am": "Amharic", "ar": "Arabic", "hy": "Armenian", "as": "Assamese", "ay": "Aymara", "az": "Azerbaijani", "bn": "Bangla", "eu": "Basque", "be": "Belarusian", "bho": "Bhojpuri", "bs": "Bosnian", "bg": "Bulgarian", "my": "Burmese", "ca": "Catalan", "ceb": "Cebuano", "zh": "Chinese", "zh-Hans": "Chinese (Simplified)", "zh-Hant": "Chinese (Traditional)", "co": "Corsican", "hr": "Croatian", "cs": "Czech", "da": "Danish", "dv": "Divehi", "nl": "Dutch", "en": "English", "eo": "Esperanto", "et": "Estonian", "ee": "Ewe", "fil": "Filipino", "fi": "Finnish", "fr": "French", "gl": "Galician", "lg": "Ganda", "ka": "Georgian", "de": "German", "el": "Greek", "gn": "Guarani", "gu": "Gujarati", "ht": "Haitian Creole", "ha": "Hausa", "haw": "Hawaiian", "iw": "Hebrew", "hi": "Hindi", "hmn": "Hmong", "hu": "Hungarian", "is": "Icelandic", "ig": "Igbo", "id": "Indonesian", "ga": "Irish", "it": "Italian", "ja": "Japanese", "jv": "Javanese", "kn": "Kannada", "kk": "Kazakh", "km": "Khmer", "rw": "Kinyarwanda", "ko": "Korean", "kri": "Krio", "ku": "Kurdish", "ky": "Kyrgyz", "lo": "Lao", "la": "Latin", "lv": "Latvian", "ln": "Lingala", "lt": "Lithuanian", "lb": "Luxembourgish", "mk": "Macedonian", "mg": "Malagasy", "ms": "Malay", "ml": "Malayalam", "mt": "Maltese", "mi": "Māori", "mr": "Marathi", "mn": "Mongolian", "ne": "Nepali", "nso": "Northern Sotho", "no": "Norwegian", "ny": "Nyanja", "or": "Odia", "om": "Oromo", "ps": "Pashto", "fa": "Persian", "pl": "Polish", "pt": "Portuguese", "pa": "Punjabi", "qu": "Quechua", "ro": "Romanian", "ru": "Russian", "sm": "Samoan", "sa": "Sanskrit", "gd": "Scottish Gaelic", "sr": "Serbian", "sn": "Shona", "sd": "Sindhi", "si": "Sinhala", "sk": "Slovak", "sl": "Slovenian", "so": "Somali", "st": "Southern Sotho", "es": "Spanish", "su": "Sundanese", "sw": "Swahili", "sv": "Swedish", "tg": "Tajik", "ta": "Tamil", "tt": "Tatar", "te": "Telugu", "th": "Thai", "ti": "Tigrinya", "ts": "Tsonga", "tr": "Turkish", "tk": "Turkmen", "uk": "Ukrainian", "ur": "Urdu", "ug": "Uyghur", "uz": "Uzbek", "vi": "Vietnamese", "cy": "Welsh", "fy": "Western Frisian", "xh": "Xhosa", "yi": "Yiddish", "yo": "Yoruba", "zu": "Zulu" }; const streamNoConnectionToServer = "There is no connection to the server"; const searchField = "Search..."; const VOTTranslateAPIErrors = "Translate errors from the API"; const VOTDetectService = "Language detection service"; const VOTProxyWorkerHost = "Enter the proxy worker address"; const VOTM3u8ProxyHost = "Enter the address of the m3u8 proxy worker"; const proxySettings = "Proxy Settings"; const translationTakeApproximatelyMinute2 = "The translation will take approximately {0} minutes"; const VOTAudioBooster = "Extended translation volume increase"; const VOTSubtitlesDesign = "Subtitles design"; const VOTSubtitlesFontSize = "Font size of subtitles"; const VOTSubtitlesOpacity = "Transparency of the subtitle background"; const VOTSubtitlesDownloadFormat = "The format for downloading subtitles"; const VOTDownloadWithName = "Download files with the video name"; const VOTUpdateLocaleFiles = "Update localization files"; const VOTLocaleHash = "Locale hash"; const VOTUpdatedAt = "Updated at"; const VOTNeedWebAudioAPI = "To enable this, you must have a Web Audio API"; const VOTMediaCSPEnabledOnSite = "Media CSP is enabled on this site"; const VOTOnlyBypassMediaCSP = "Use it only for bypassing Media CSP"; const VOTNewAudioPlayer = "Use the new audio player"; const VOTUseNewModel = "Use an experimental variation of Yandex voices for some videos"; const TranslationDelayed = "The translation is slightly delayed"; const VOTTranslationCompletedNotify = "The translation on the {0} has been completed!"; const VOTSendNotifyOnComplete = "Send a notification that the video has been translated"; const VOTBugReport = "Report a bug"; const VOTTranslateProxyDisabled = "Disabled"; const VOTTranslateProxyEnabled = "Enabled"; const VOTTranslateProxyEverything = "Proxy everything"; const VOTTranslateProxyStatus = "Proxying mode"; const VOTTranslatedBy = "Translated by {0}"; const VOTStreamNotAvailable = "Translate stream isn't available"; const VOTTranslationTextService = "Text translation service"; const VOTNotAffectToVoice = "Doesn't affect the translation of text in voice over"; const DontTranslateSelectedLanguages = "Don't translate from selected languages"; const showVideoVolumeSlider = "Display the video volume slider"; const hotkeysSettings = "Hotkeys settings"; const None = "None"; const VOTUseLivelyVoice = "Use lively voices. Speakers sound like native Russians."; const miscSettings = "Misc settings"; const services = { "yandexbrowser": "Yandex Browser", "msedge": "Microsoft Edge", "rust-server": "Rust Server" }; const aboutExtension = "About extension"; const appearance = "Appearance"; const buttonPosition = "Button position in the player"; const position = { "left": "Left", "right": "Right", "top": "Top", "default": "Default" }; const secs = "secs"; const autoHideButtonDelay = "Delay before hiding the translate button"; const notFound = "not found"; const minButtonPositionContainer = "The button position only changes in players larger than 600 pixels."; const VOTTranslateProxyStatusDefault = "Completely disabling proxying in your country may break the extension"; const PressTheKeyCombination = "Press the key combination..."; const VOTUseAudioDownload = "Use audio download"; const VOTUseAudioDownloadWarning = "Disabling audio downloads may affect the functionality of the extension"; const VOTAccountRequired = "You need to log in to use this feature"; const VOTMyAccount = "My account"; const VOTLogin = "Login"; const VOTLogout = "Logout"; const VOTRefresh = "Refresh"; const VOTYandexToken = "Enter the Yandex OAuth Token"; const VOTYandexTokenInfo = "You can manually set the account token in this field. Please note that we don't check its validity before sending a translate request"; const VOTLoginViaToken = "Login via token"; const smartDucking = "Adaptive volume"; const rawDefaultLocale = { recommended, translateVideo, disableTranslate, translationSettings, subtitlesSettings, subtitlesSmartLayout, resetSettings, videoBeingTranslated, videoLanguage, translationLanguage, translationTake, translationTakeMoreThanHour, translationTakeAboutMinute, translationTakeFewMinutes, translationTakeApproximatelyMinutes, translationTakeApproximatelyMinute, requestTranslationFailed, audioNotReceived, VOTFailedDownloadAudio, audioFormatNotSupported, VOTAutoTranslate, VOTAutoSubtitles, VOTDontTranslateYourLang, VOTVolume, VOTVolumeTranslation, VOTAutoSetVolume, VOTShowVideoSlider, VOTSyncVolume, VOTDisableFromYourLang, VOTVideoIsTooLong, VOTNoVideoIDFound, VOTSubtitles, VOTSubtitlesDisabled, VOTSubtitlesMaxLength, VOTHighlightWords, VOTTranslatedFrom, VOTAutogenerated, VOTSettings, VOTMenuLanguage, VOTAuthors, VOTVersion, VOTLoader, VOTBrowser, VOTShowPiPButton, langs, streamNoConnectionToServer, searchField, VOTTranslateAPIErrors, VOTDetectService, VOTProxyWorkerHost, VOTM3u8ProxyHost, proxySettings, translationTakeApproximatelyMinute2, VOTAudioBooster, VOTSubtitlesDesign, VOTSubtitlesFontSize, VOTSubtitlesOpacity, VOTSubtitlesDownloadFormat, VOTDownloadWithName, VOTUpdateLocaleFiles, VOTLocaleHash, VOTUpdatedAt, VOTNeedWebAudioAPI, VOTMediaCSPEnabledOnSite, VOTOnlyBypassMediaCSP, VOTNewAudioPlayer, VOTUseNewModel, TranslationDelayed, VOTTranslationCompletedNotify, VOTSendNotifyOnComplete, VOTBugReport, VOTTranslateProxyDisabled, VOTTranslateProxyEnabled, VOTTranslateProxyEverything, VOTTranslateProxyStatus, VOTTranslatedBy, VOTStreamNotAvailable, VOTTranslationTextService, VOTNotAffectToVoice, DontTranslateSelectedLanguages, showVideoVolumeSlider, hotkeysSettings, None, VOTUseLivelyVoice, miscSettings, services, aboutExtension, appearance, buttonPosition, position, secs, autoHideButtonDelay, notFound, minButtonPositionContainer, VOTTranslateProxyStatusDefault, PressTheKeyCombination, VOTUseAudioDownload, VOTUseAudioDownloadWarning, VOTAccountRequired, VOTMyAccount, VOTLogin, VOTLogout, VOTRefresh, VOTYandexToken, VOTYandexTokenInfo, VOTLoginViaToken, smartDucking }; var define_AVAILABLE_LOCALES_default = ["auto", "en", "ru", "af", "am", "ar", "az", "bg", "bn", "bs", "ca", "cs", "cy", "da", "de", "el", "es", "et", "eu", "fa", "fi", "fr", "gl", "hi", "hr", "hu", "hy", "id", "it", "ja", "jv", "kk", "km", "kn", "ko", "lo", "mk", "ml", "mn", "ms", "mt", "my", "ne", "nl", "pa", "pl", "pt", "ro", "si", "sk", "sl", "sq", "sr", "su", "sv", "sw", "tr", "uk", "ur", "uz", "vi", "zh", "zu"]; class LocalizationProvider { storageKeys = [ "localePhrases", "localeLang", "localeHash", "localeUpdatedAt", "localeLangOverride" ]; lang; locale; defaultLocale = toFlatObj(rawDefaultLocale); cacheTTL = 7200; localesUrl = `${contentUrl}/${"master"}/src/localization/locales`; hashesUrl = `${contentUrl}/${"master"}/src/localization/hashes.json`; _langOverride = "auto"; constructor() { this.lang = this.getLang(); this.locale = {}; } async init() { this._langOverride = await votStorage.get( "localeLangOverride", "auto" ); this.lang = this.getLang(); const phrases = await votStorage.get("localePhrases", ""); this.setLocaleFromJsonString(phrases); return this; } get langOverride() { return this._langOverride; } getLang() { return this.langOverride !== "auto" ? this.langOverride : lang; } getAvailableLangs() { return define_AVAILABLE_LOCALES_default.includes("auto") ? define_AVAILABLE_LOCALES_default : ["auto", ...define_AVAILABLE_LOCALES_default]; } async reset() { for (const key of this.storageKeys) { await votStorage.delete(key); } return this; } buildUrl(baseUrl, path, force = false) { const query = force ? `?timestamp=${getTimestamp()}` : ""; return `${baseUrl}${path}${query}`; } async changeLang(newLang) { const oldLang = this.langOverride; if (oldLang === newLang) { return false; } await votStorage.set("localeLangOverride", newLang); this._langOverride = newLang; this.lang = this.getLang(); await this.update(true); return true; } async checkUpdates(force = false) { try { const res = await GM_fetch(this.buildUrl(this.hashesUrl, "", force)); if (!res.ok) throw res.status; const hashes = await res.json(); return await votStorage.get("localeHash") !== hashes[this.lang] ? hashes[this.lang] : false; } catch (err) { console.error( "[VOT] [localizationProvider] Failed to get locales hash:", err ); return false; } } async update(force = false) { const localeUpdatedAt = await votStorage.get("localeUpdatedAt", 0); if (!force && localeUpdatedAt + this.cacheTTL > getTimestamp() && await votStorage.get("localeLang") === this.lang) { return this; } const hash = await this.checkUpdates(force); await votStorage.set("localeUpdatedAt", getTimestamp()); if (!hash) { return this; } try { const res = await GM_fetch( this.buildUrl(this.localesUrl, `/${this.lang}.json`, force) ); if (!res.ok) throw res.status; const text = await res.text(); await votStorage.set("localePhrases", text); await votStorage.set("localeHash", hash); await votStorage.set("localeLang", this.lang); this.setLocaleFromJsonString(text); } catch (err) { console.error("[VOT] [localizationProvider] Failed to get locale:", err); this.setLocaleFromJsonString(await votStorage.get("localePhrases", "")); } return this; } setLocaleFromJsonString(json) { try { const locale = JSON.parse(json) || {}; this.locale = toFlatObj(locale); } catch (err) { console.error("[VOT] [localizationProvider]", err); this.locale = {}; } return this; } getFromLocale(locale, key) { return locale?.[key] ?? this.warnMissingKey(locale, key); } warnMissingKey(locale, key) { console.warn( "[VOT] [localizationProvider] locale", locale, "doesn't contain key", key ); return void 0; } getDefault(key) { return this.getFromLocale(this.defaultLocale, key) ?? key; } get(key) { return this.getFromLocale(this.locale, key) ?? this.getDefault(key); } getLangLabel(lang2) { const key = `langs.${lang2}`; if (key in this.defaultLocale) { const label = this.get(key); if (label) { return label; } } return typeof lang2 === "string" ? lang2.toUpperCase() : ""; } } const localizationProvider = new LocalizationProvider(); let localizationProviderReadyPromise = null; function ensureLocalizationProviderReady() { localizationProviderReadyPromise ??= localizationProvider.init(); return localizationProviderReadyPromise; } function initIframeInteractor() { const configs = { "https://www.dailymotion.com": { targetOrigin: "https://geo.dailymotion.com", dataFilter: (data) => typeof data === "object" && data !== null && "type" in data && data.type === "getDailymotionVideoId", extractVideoId: (url) => { const match = /\/video\/(\w+)/.exec(url.pathname); return match?.[1] ?? null; }, responseFormatter: (videoId) => ({ type: "dailymotionVideoId", videoId }) }, "https://dev.epicgames.com": { targetOrigin: "https://dev.epicgames.com", dataFilter: (data) => typeof data === "string" && data.startsWith("getVideoId:"), extractVideoId: (url) => url.pathname.split("/").at(-2) ?? null, responseFormatter: (videoId, data) => `${typeof data === "string" ? data : ""}:${videoId}` } }; const currentConfig = Object.entries(configs).find( ([origin]) => globalThis.location.origin === origin && (origin !== "https://dev.epicgames.com" || globalThis.location.pathname.includes("/community/learning/")) )?.[1]; if (!currentConfig) return; globalThis.addEventListener("message", (event) => { try { if (event.origin !== currentConfig.targetOrigin) return; if (!currentConfig.dataFilter(event.data)) return; const videoId = currentConfig.extractVideoId( new URL(globalThis.location.href) ); if (!videoId) return; const response = currentConfig.responseFormatter(videoId, event.data); if (event.source && "postMessage" in event.source) { event.source.postMessage( response, currentConfig.targetOrigin ); } } catch (error2) { console.error("Iframe communication error:", error2); } }); } let runtimeActivated = false; let runtimeActivationPromise = null; let iframeInteractorBound = false; async function ensureRuntimeActivated(reason, logBootstrap2) { if (runtimeActivated) return; if (runtimeActivationPromise !== null) { await runtimeActivationPromise; return; } runtimeActivationPromise = (async () => { logBootstrap2("Activating runtime", { reason }); if (globalThis.location.origin === authServerUrl) { await initAuth(); runtimeActivated = true; return; } await ensureLocalizationProviderReady(); if (!isIframe()) { await localizationProvider.update(); } debug.log(`Selected menu language: ${localizationProvider.lang}`); if (!iframeInteractorBound) { iframeInteractorBound = true; initIframeInteractor(); } runtimeActivated = true; })(); try { await runtimeActivationPromise; } finally { runtimeActivationPromise = null; } } let observerListenersBound = false; function bindObserverListeners(options) { if (observerListenersBound) return; observerListenersBound = true; const { videoObserver: videoObserver2, videosWrappers: videosWrappers2, ensureRuntimeActivated: ensureRuntimeActivated2, getServicesCached: getServicesCached2, findContainer: findContainer2, createVideoHandler } = options; const initializingVideos = new WeakSet(); const containerOwners = new WeakMap(); const videoContainers = new WeakMap(); const pendingVideoByContainer = new WeakMap(); const clearContainerOwner = (video) => { const container = videoContainers.get(video); if (container && containerOwners.get(container) === video) { containerOwners.delete(container); } videoContainers.delete(video); return container ?? void 0; }; const clearPendingVideo = (container) => { if (!container) { return; } pendingVideoByContainer.delete(container); }; const promotePendingVideo = async (container) => { if (!container) { return; } const pendingVideo = pendingVideoByContainer.get(container); if (!pendingVideo) { return; } pendingVideoByContainer.delete(container); if (!pendingVideo.isConnected || videosWrappers2.has(pendingVideo) || initializingVideos.has(pendingVideo)) { return; } await handleVideoAdded(pendingVideo); }; const handleVideoAdded = async (video) => { if (videosWrappers2.has(video) || initializingVideos.has(video)) return; initializingVideos.add(video); try { try { await ensureRuntimeActivated2("video-detected"); } catch (err) { console.error("[VOT] Failed to activate runtime", err); return; } let container = null; const site = getServicesCached2().find((candidate) => { container = findContainer2(candidate, video); return Boolean(container); }); if (!site || !container) { return; } const activeVideoForContainer = containerOwners.get(container); if (activeVideoForContainer && activeVideoForContainer !== video) { if (activeVideoForContainer.isConnected) { pendingVideoByContainer.set(container, video); return; } try { await videosWrappers2.get(activeVideoForContainer)?.release(); } catch (err) { console.error("[VOT] Failed to release stale videoHandler", err); } videosWrappers2.delete(activeVideoForContainer); clearContainerOwner(activeVideoForContainer); } if (["peertube", "directlink"].includes(site.host)) { site.url = globalThis.location.origin; } const videoHandler = createVideoHandler(video, container, site); videosWrappers2.set(video, videoHandler); videoContainers.set(video, container); containerOwners.set(container, video); try { await videoHandler.init(); if (videosWrappers2.get(video) !== videoHandler) { return; } try { await videoHandler.setCanPlay(); } catch (err) { console.error("[VOT] Failed to get video data", err); } } catch (err) { if (videosWrappers2.get(video) === videoHandler) { videosWrappers2.delete(video); const container2 = clearContainerOwner(video); clearPendingVideo(container2); await promotePendingVideo(container2); } throw err; } } catch (err) { console.error("[VOT] Failed to initialize videoHandler", err); } finally { initializingVideos.delete(video); } }; videoObserver2.onVideoAdded.addListener(handleVideoAdded); videoObserver2.onVideoRemoved.addListener(async (video) => { const container = clearContainerOwner(video); if (videosWrappers2.has(video)) { await videosWrappers2.get(video)?.release(); videosWrappers2.delete(video); } initializingVideos.delete(video); if (container && pendingVideoByContainer.get(container) === video) { clearPendingVideo(container); } await promotePendingVideo(container); }); } function shouldSkipIframeBootstrap(input) { if (!input.isIframe) return false; if (input.hash.includes(input.iframeHash)) return false; return input.href === "about:blank" || input.href.startsWith("about:srcdoc") || input.origin === "null"; } function resolveBootstrapMode(input) { if (input.isIframe && input.hash.includes(input.iframeHash)) { return "iframe-helper"; } if (shouldSkipIframeBootstrap(input)) { return "skip"; } if (input.isIframe) { return "iframe-lazy"; } return "top-full"; } const YANDEX_TTL_MS = 2 * 60 * 60 * 1e3; class CacheManager { cache = new Map(); lastCleanupAt = 0; clear() { this.cache.clear(); this.lastCleanupAt = 0; } getTranslation(key) { const entry = this.cache.get(key); if (!entry) return void 0; const exp = entry.translationExpiresAt; if (exp !== void 0 && exp <= Date.now()) { entry.translation = void 0; entry.translationExpiresAt = void 0; this.evictIfEmpty(key, entry); return void 0; } return entry.translation; } setTranslation(key, translation) { this.maybeCleanup(); const entry = this.getOrCreateEntry(key); entry.translation = translation; entry.translationExpiresAt = Date.now() + YANDEX_TTL_MS; } getSubtitles(key) { const entry = this.cache.get(key); if (!entry) return void 0; const exp = entry.subtitlesExpiresAt; if (exp !== void 0 && exp <= Date.now()) { entry.subtitles = void 0; entry.subtitlesExpiresAt = void 0; this.evictIfEmpty(key, entry); return void 0; } return entry.subtitles; } setSubtitles(key, subtitles) { this.maybeCleanup(); const entry = this.getOrCreateEntry(key); entry.subtitles = subtitles; entry.subtitlesExpiresAt = Date.now() + YANDEX_TTL_MS; } deleteSubtitles(key) { const entry = this.cache.get(key); if (!entry) return; entry.subtitles = void 0; entry.subtitlesExpiresAt = void 0; this.evictIfEmpty(key, entry); } evictIfEmpty(key, entry) { if (entry.translation === void 0 && entry.subtitles === void 0) { this.cache.delete(key); } } maybeCleanup() { const now2 = Date.now(); if (now2 - this.lastCleanupAt < 6e4) return; this.lastCleanupAt = now2; for (const [key, entry] of this.cache) { if (entry.translationExpiresAt !== void 0 && entry.translationExpiresAt <= now2) { entry.translation = void 0; entry.translationExpiresAt = void 0; } if (entry.subtitlesExpiresAt !== void 0 && entry.subtitlesExpiresAt <= now2) { entry.subtitles = void 0; entry.subtitlesExpiresAt = void 0; } this.evictIfEmpty(key, entry); } } getOrCreateEntry(key) { const existing = this.cache.get(key); if (existing) return existing; const entry = {}; this.cache.set(key, entry); return entry; } } function getComposableParent(node) { if (!node) return null; if (typeof ShadowRoot !== "undefined" && node instanceof ShadowRoot) { return node.host; } return node.parentNode ?? null; } function containsCrossShadow(container, target) { let node = target; while (node) { if (node === container) return true; node = getComposableParent(node); } return false; } function closestCrossShadow(element, selector) { if (!element || !selector) return null; const origin = element instanceof Document ? null : element; const walk = (current) => { if (!current) return null; if (current instanceof Document) { if (origin) { const matches = current.querySelectorAll(selector); return Array.from(matches).find( (match) => containsCrossShadow(match, origin) ) ?? null; } return current.querySelector(selector); } const closest = current.closest(selector); if (closest) return closest; const root = current.getRootNode(); if (root instanceof ShadowRoot) { return walk(root.host); } if (root instanceof Document) { return walk(root); } if (root !== current) { const parent = getComposableParent(root); if (parent && parent !== current && parent instanceof Element) { return walk(parent); } } return null; }; return walk(element); } function resolveInteractiveMount(base, { maxPointerEventsHops = 30, maxPositionedHops = 10, preferPositioned = true } = {}) { let el = base; let peHops = 0; while (el?.parentElement && peHops < maxPointerEventsHops) { const pe = getComputedStyle(el).pointerEvents; const parentPe = getComputedStyle(el.parentElement).pointerEvents; if (pe === "none" || parentPe === "none") { el = el.parentElement; peHops++; continue; } break; } if (!preferPositioned) { return el ?? base; } let positioned = el; let hops = 0; while (positioned?.parentElement && hops < maxPositionedHops) { const pos = getComputedStyle(positioned).position; if (pos !== "static") break; positioned = positioned.parentElement; hops++; } return positioned ?? el ?? base; } function findConnectedContainerBySelector(video, selector) { if (!selector) { return null; } const matched = closestCrossShadow(video, selector); if (matched instanceof HTMLElement && matched.isConnected && containsCrossShadow(matched, video)) { return matched; } return null; } class EventImpl { listeners = new Set(); get size() { return this.listeners.size; } addListener(handler) { this.listeners.add(handler); return this; } removeListener(handler) { this.listeners.delete(handler); return this; } dispatch(...args) { for (const handler of this.listeners) { try { handler(...args); } catch (exception) { console.error("[VOT]", exception); } } } clear() { this.listeners.clear(); } } const serviceIframe = null; function generateChunkRanges(contentLength, minChunkSize) { const chunkRanges = []; let stepIndex = 0; let start = 0; let end = Math.min(CHUNK_STEPS[stepIndex], contentLength); while (end < contentLength) { chunkRanges.push({ start, end, mustExist: end < minChunkSize }); if (stepIndex < CHUNK_STEPS.length - 1) { stepIndex++; } start = end + 1; end += CHUNK_STEPS[stepIndex]; } chunkRanges.push({ start, end: contentLength, mustExist: false }); return chunkRanges; } function getChunkRangesPartsFromContentLength(contentLength) { if (contentLength < 1) { throw new Error( "Audio downloader. WEB API. contentLength must be at least 1" ); } const minChunkSize = Math.round( contentLength * MIN_CONTENT_LENGTH_MULTIPLIER ); const chunkRanges = generateChunkRanges(contentLength, minChunkSize); const chunkRangeParts = []; let currentPart = []; let currentPartSize = 0; for (const chunkRange of chunkRanges) { currentPart.push(chunkRange); currentPartSize += chunkRange.end - chunkRange.start; if (currentPartSize >= MIN_CHUNK_RANGES_PART_SIZE || chunkRange.end === contentLength) { chunkRangeParts.push(currentPart); currentPart = []; currentPartSize = 0; } } return chunkRangeParts; } function parseContentLength({ contentLength }) { if (typeof contentLength !== "string") { throw new TypeError( `Audio downloader. WEB API. Content length (${contentLength}) is not a string` ); } const parsed = Number.parseInt(contentLength, 10); if (!Number.isFinite(parsed)) { throw new TypeError( `Audio downloader. WEB API. Parsed content length is not finite (${parsed})` ); } return parsed; } function getChunkRangesPartsFromAdaptiveFormat(format) { const contentLength = parseContentLength(format); const chunkParts = getChunkRangesPartsFromContentLength(contentLength); if (!chunkParts.length) { throw new Error("Audio downloader. WEB API. No chunk parts generated"); } return chunkParts; } function getChunkRangesFromContentLength(contentLength) { if (contentLength < 1) { throw new Error( "Audio downloader. WEB API. contentLength cannot be less than 1" ); } const minChunkSize = Math.round( contentLength * MIN_CONTENT_LENGTH_MULTIPLIER ); return generateChunkRanges(contentLength, minChunkSize); } function getChunkRangesFromAdaptiveFormat(adaptiveFormat) { const contentLength = parseContentLength(adaptiveFormat); const chunkRanges = getChunkRangesFromContentLength(contentLength); if (!chunkRanges.length) { throw new Error("Audio downloader. WEB API. Empty chunk ranges"); } return chunkRanges; } function mergeBuffers(buffers) { const totalLength = buffers.reduce( (total, buffer) => total + buffer.byteLength, 0 ); const merged = new Uint8Array(totalLength); let offset = 0; for (const buffer of buffers) { merged.set(new Uint8Array(buffer), offset); offset += buffer.byteLength; } return merged; } async function sendRequestToIframe(messageType, data) { const { videoId } = data.payload; const iframeUrl = `https://${IFRAME_HOST}/embed/${videoId}?autoplay=0&mute=1`; try { const iframe = await ensureServiceIframe( serviceIframe, iframeUrl, IFRAME_ID, IFRAME_SERVICE ); if (!hasServiceIframe(IFRAME_ID)) { throw new Error("Audio downloader. WEB API. Service iframe deleted"); } iframe.contentWindow?.postMessage( { messageId: generateMessageId(), messageType, messageDirection: "request", payload: data, error: data.error }, "*" ); } catch (err) { data.error = err; data.messageDirection = "response"; globalThis.postMessage(data, "*"); } } function makeFileId(downloadType, itag, fileSize) { return JSON.stringify({ downloadType, itag, minChunkSize: MIN_CHUNK_RANGES_PART_SIZE, fileSize }); } const GET_AUDIO_DATA_ERROR_MESSAGE = "Audio downloader. WEB API. Can not get getGeneratingAudioUrlsDataFromIframe due to timeout"; const INCORRECT_FETCH_MEDIA_MESSAGE = "Audio downloader. WEB API. Incorrect response on fetch media url"; const CANT_FETCH_MEDIA_MESSAGE = "Audio downloader. WEB API. Can not fetch media url"; const CANT_GET_ARRAY_BUFFER_MESSAGE = "Audio downloader. WEB API. Can not get array buffer from media url"; const textDecoder = new TextDecoder("ascii"); let mediaQuaryIndex = 1; function patchMediaUrl(url, { start, end }) { const modifiedUrl = new URL(url); modifiedUrl.searchParams.set("range", `${start}-${end}`); modifiedUrl.searchParams.set("rn", String(mediaQuaryIndex++)); modifiedUrl.searchParams.delete("ump"); return modifiedUrl.toString(); } function isChunkLengthAcceptable(buffer, { start, end }) { const rangeLength = end - start; if (rangeLength > MIN_ARRAY_BUFFER_LENGTH && buffer.byteLength < MIN_ARRAY_BUFFER_LENGTH) { return false; } return Math.min(rangeLength, buffer.byteLength) / Math.max(rangeLength, buffer.byteLength) > ACCEPTABLE_LENGTH_DIFF; } const getUrlFromArrayBuffer = (buffer) => { return /https:\/\/.*$/.exec(textDecoder.decode(buffer))?.[0]; }; const STRATEGY_TYPE = AudioDownloadType.WEB_API_GET_ALL_GENERATING_URLS_DATA_FROM_IFRAME; function isSerializedRequestInitData(value) { return Boolean(value) && typeof value === "object" && "headersEntries" in value; } function normalizeRequestInit(requestInit, fallbackInit) { if (!requestInit) { return fallbackInit; } return isSerializedRequestInitData(requestInit) ? deserializeRequestInit(requestInit) : requestInit; } const getDownloadAudioDataInMainWorld = (payload, signal) => requestDataFromMainWorldWithId("get-download-audio-data-in-main-world", payload, { signal }).promise; async function getGeneratingAudioUrlsDataFromIframe(videoId, signal) { try { return await Promise.race([ getDownloadAudioDataInMainWorld({ videoId }, signal), timeout(2e4, GET_AUDIO_DATA_ERROR_MESSAGE) ]); } catch (err) { const isTimeout = err instanceof Error && err.message === GET_AUDIO_DATA_ERROR_MESSAGE; throw new Error( isTimeout ? GET_AUDIO_DATA_ERROR_MESSAGE : "Audio downloader. WEB API. Failed to get audio data" ); } } async function fetchMediaWithMeta({ mediaUrl, chunkRange, requestInit, signal, isUrlChanged = false }) { const patchedUrl = patchMediaUrl(mediaUrl, chunkRange); let response; try { response = await GM_fetch(patchedUrl, { ...requestInit, signal }); if (!response.ok) { const errorDetails = serializeResponse(response); console.error(INCORRECT_FETCH_MEDIA_MESSAGE, errorDetails); throw new Error(INCORRECT_FETCH_MEDIA_MESSAGE); } } catch (err) { if (err instanceof Error && err.message === INCORRECT_FETCH_MEDIA_MESSAGE) { throw err; } console.error(CANT_FETCH_MEDIA_MESSAGE, { mediaUrl: patchedUrl, error: err }); throw new Error(CANT_FETCH_MEDIA_MESSAGE); } let arrayBuffer; try { arrayBuffer = await response.arrayBuffer(); } catch (err) { console.error(CANT_GET_ARRAY_BUFFER_MESSAGE, { mediaUrl: patchedUrl, error: err }); throw new Error(CANT_GET_ARRAY_BUFFER_MESSAGE); } debug.log( "isChunkLengthAcceptable", isChunkLengthAcceptable(arrayBuffer, chunkRange), arrayBuffer.byteLength, chunkRange ); if (isChunkLengthAcceptable(arrayBuffer, chunkRange)) { return { media: arrayBuffer, url: isUrlChanged ? mediaUrl : null, isAcceptableLast: false }; } const redirectedUrl = getUrlFromArrayBuffer(arrayBuffer); if (redirectedUrl) { return fetchMediaWithMeta({ mediaUrl: redirectedUrl, chunkRange, requestInit, signal, isUrlChanged: true }); } if (!chunkRange.mustExist) { return { media: arrayBuffer, url: null, isAcceptableLast: true }; } throw new Error( `Audio downloader. WEB API. Can not get redirected media url ${patchedUrl}` ); } async function fetchMediaWithMetaByChunkRanges(mediaUrl, requestInit, chunkRanges, signal) { let currentUrl = mediaUrl; const mediaBuffers = []; let isAcceptableLast = false; for (const chunkRange of chunkRanges) { const result = await fetchMediaWithMeta({ mediaUrl: currentUrl, chunkRange, requestInit, signal }); if (result.url) { currentUrl = result.url; } mediaBuffers.push(result.media); isAcceptableLast = result.isAcceptableLast; if (isAcceptableLast) { break; } } return { media: mergeBuffers(mediaBuffers), url: currentUrl, isAcceptableLast }; } async function getAudioFromWebApiWithReplacedFetch({ videoId, returnByParts = false, signal }) { const { requestInit, requestInfo, adaptiveFormat, itag } = await getGeneratingAudioUrlsDataFromIframe(videoId, signal); if (!requestInfo) { throw new Error("Audio downloader. WEB API. Can not get requestInfo"); } let mediaUrl = getRequestUrl(requestInfo); const serializedInit = serializeRequestInit(requestInfo); const fallbackInit = deserializeRequestInit(serializedInit); const finalRequestInit = normalizeRequestInit(requestInit, fallbackInit); return { fileId: makeFileId(STRATEGY_TYPE, itag, adaptiveFormat.contentLength), mediaPartsLength: returnByParts ? getChunkRangesPartsFromAdaptiveFormat(adaptiveFormat).length : 1, async *getMediaBuffers() { if (returnByParts) { const chunkParts = getChunkRangesPartsFromAdaptiveFormat(adaptiveFormat); for (const part of chunkParts) { const { media, url, isAcceptableLast } = await fetchMediaWithMetaByChunkRanges( mediaUrl, finalRequestInit, part, signal ); if (url) { mediaUrl = url; } yield media; if (isAcceptableLast) { break; } } } else { const chunkRanges = getChunkRangesFromAdaptiveFormat(adaptiveFormat); const { media } = await fetchMediaWithMetaByChunkRanges( mediaUrl, finalRequestInit, chunkRanges, signal ); yield media; } } }; } const strategies = { [AudioDownloadType.WEB_API_GET_ALL_GENERATING_URLS_DATA_FROM_IFRAME]: getAudioFromWebApiWithReplacedFetch }; async function handleCommonAudioDownloadRequest({ audioDownloader, translationId, videoId, signal }) { const audioData = await strategies[audioDownloader.strategy]({ videoId, returnByParts: true, signal }); if (!audioData) { throw new Error("Audio downloader. Can not get audio data"); } debug.log("Audio downloader. Url found", { audioDownloadType: audioDownloader.strategy }); const { getMediaBuffers, mediaPartsLength, fileId } = audioData; if (mediaPartsLength < 2) { const { value } = await getMediaBuffers().next(); if (!value) { throw new Error("Audio downloader. Empty audio"); } audioDownloader.onDownloadedAudio.dispatch(translationId, { videoId, fileId, audioData: value }); return; } let index = 0; for await (const audioChunk of getMediaBuffers()) { if (!audioChunk) { throw new Error("Audio downloader. Empty audio"); } audioDownloader.onDownloadedPartialAudio.dispatch(translationId, { videoId, fileId, audioData: audioChunk, version: 1, index, amount: mediaPartsLength }); index++; } } async function mainWorldMessageHandler(event) { const { data, source } = event; try { if (data?.messageType !== "get-download-audio-data-in-main-world") { return; } if (data.messageDirection === "response") { if (source !== globalThis.window) { globalThis.postMessage(data, "*"); } return; } if (data.messageDirection !== "request") { return; } await sendRequestToIframe( "get-download-audio-data-in-iframe", data ); } catch (error2) { console.error("[VOT] Main world bridge", { error: error2 }); } } class AudioDownloader { onDownloadedAudio = new EventImpl(); onDownloadedPartialAudio = new EventImpl(); onDownloadAudioError = new EventImpl(); strategy; constructor(strategy = AudioDownloadType.WEB_API_GET_ALL_GENERATING_URLS_DATA_FROM_IFRAME) { this.strategy = strategy; } async runAudioDownload(videoId, translationId, signal) { globalThis.addEventListener("message", mainWorldMessageHandler); try { await handleCommonAudioDownloadRequest({ audioDownloader: this, translationId, videoId, signal }); debug.log("Audio downloader. Audio download finished", { videoId }); } catch (err) { console.error("Audio downloader. Failed to download audio", err); this.onDownloadAudioError.dispatch(videoId); } globalThis.removeEventListener("message", mainWorldMessageHandler); } addEventListener(type, listener) { 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, listener) { 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; } } const MAX_SECS_FRACTION = 0.66; function formatTranslationEta(secs2, getMessage) { let minutes = Math.floor(secs2 / 60); const seconds = Math.floor(secs2 % 60); const fraction = seconds / 60; if (fraction >= MAX_SECS_FRACTION) { minutes += 1; } if (minutes >= 60) { return getMessage("translationTakeMoreThanHour"); } if (minutes <= 1) { return getMessage("translationTakeAboutMinute"); } const minutesStr = String(minutes); if (minutes !== 11 && minutes % 10 === 1) { return getMessage("translationTakeApproximatelyMinute2").replace( "{0}", minutesStr ); } if (![12, 13, 14].includes(minutes) && [2, 3, 4].includes(minutes % 10)) { return getMessage("translationTakeApproximatelyMinute").replace( "{0}", minutesStr ); } return getMessage("translationTakeApproximatelyMinutes").replace( "{0}", minutesStr ); } class VOTLocalizedError extends Error { name = "VOTLocalizedError"; unlocalizedMessage; localizedMessage; constructor(message) { super(localizationProvider.getDefault(message)); this.unlocalizedMessage = message; this.localizedMessage = localizationProvider.get(message); } } function normalizeTranslationHelp(translationHelp) { return translationHelp ?? null; } async function requestTranslationAudio(requester, options) { const response = await requester.translateVideoImpl( options.videoData, options.requestLang, options.responseLang, normalizeTranslationHelp(options.translationHelp), !options.useAudioDownload, options.signal ); if (!response?.url) { return null; } return { url: response.url, usedLivelyVoice: Boolean(response.usedLivelyVoice) }; } function buildTranslationCacheValue(options) { return { videoId: options.videoId, from: options.requestLang, to: options.responseLang, url: options.downloadTranslationUrl ?? options.fallbackUrl, useLivelyVoice: options.usedLivelyVoice }; } async function updateTranslationAndSchedule(options) { if (options.isActionStale(options.actionContext)) { return false; } await options.updateTranslation(options.url, options.actionContext); if (options.isActionStale(options.actionContext)) { return false; } options.scheduleTranslationRefresh(); return true; } async function requestAndApplyTranslation(options) { const translateRes = await requestTranslationAudio(options.requester, { videoData: options.request.videoData, requestLang: options.request.requestLang, responseLang: options.request.responseLang, translationHelp: options.request.translationHelp, useAudioDownload: options.request.useAudioDownload, signal: options.request.signal }); if (!translateRes) { return null; } const updated = await updateTranslationAndSchedule({ url: translateRes.url, actionContext: options.actionContext, isActionStale: options.isActionStale, updateTranslation: options.updateTranslation, scheduleTranslationRefresh: options.scheduleTranslationRefresh }); if (!updated || options.isActionStale(options.actionContext)) { return null; } return translateRes; } function setTranslationCacheValue(options) { options.setTranslation( options.cacheKey, buildTranslationCacheValue({ videoId: options.videoId, requestLang: options.requestLang, responseLang: options.responseLang, fallbackUrl: options.fallbackUrl, downloadTranslationUrl: options.downloadTranslationUrl, usedLivelyVoice: options.usedLivelyVoice }) ); } function notifyTranslationFailureIfNeeded(options) { if (options.aborted || !options.translateApiErrorsEnabled || !options.hadAsyncWait) { return options.hadAsyncWait; } options.notify({ videoId: options.videoId, message: options.error }); return false; } function asVotClientErrorShape(value) { if (!value || typeof value !== "object") { return null; } const candidate = value; const data = candidate.data && typeof candidate.data === "object" ? candidate.data : void 0; return { name: candidate.name, message: candidate.message, data }; } function getServerErrorMessage(value) { const err = asVotClientErrorShape(value); const message = err?.data?.message; return typeof message === "string" && message.length > 0 ? message : void 0; } function mapVotClientErrorForUi(error2) { const err = asVotClientErrorShape(error2); if (!err) { return error2; } if (err.name !== "VOTJSError") { return error2; } const message = typeof err.message === "string" ? err.message : ""; const hasServerMessage = typeof err.data?.message === "string" && err.data.message.length > 0; if (message === "Yandex couldn't translate video" && !hasServerMessage) { return new VOTLocalizedError("requestTranslationFailed"); } if (message === "Failed to request video translation") { return new VOTLocalizedError("requestTranslationFailed"); } if (message === "Audio link wasn't received" || message === "Audio link wasn't received from VOT response") { return new VOTLocalizedError("audioNotReceived"); } return error2; } class VOTTranslationHandler { videoHandler; audioDownloader; downloading; downloadWaiters = new Set(); requestedFailAudio = new Set(); constructor(videoHandler) { this.videoHandler = videoHandler; this.audioDownloader = new AudioDownloader(); this.downloading = false; this.audioDownloader.addEventListener("downloadedAudio", this.onDownloadedAudio).addEventListener("downloadedPartialAudio", this.onDownloadedPartialAudio).addEventListener("downloadAudioError", this.onDownloadAudioError); } onDownloadedAudio = async (translationId, data) => { if (!this.downloading) { return; } const { videoId, fileId, audioData } = data; const videoUrl = this.getCanonicalUrl(videoId); try { await this.videoHandler.votClient.requestVtransAudio( videoUrl, translationId, { audioFile: audioData, fileId } ); } catch { } this.finishDownloadSuccess(); }; onDownloadedPartialAudio = async (translationId, data) => { if (!this.downloading) { return; } const { audioData, fileId, videoId, amount, version, index } = data; const videoUrl = this.getCanonicalUrl(videoId); try { await this.videoHandler.votClient.requestVtransAudio( videoUrl, translationId, { audioFile: audioData, chunkId: index }, { audioPartsLength: amount, fileId, version } ); } catch { this.finishDownloadFailure( new Error("Audio downloader failed while uploading chunk") ); return; } if (index === amount - 1) { this.finishDownloadSuccess(); } }; onDownloadAudioError = async (videoId) => { if (!this.downloading) { return; } const videoUrl = this.getCanonicalUrl(videoId); const shouldUseFallback = this.videoHandler.site.host === "youtube" && Boolean(this.videoHandler.data?.useAudioDownload); if (!shouldUseFallback) { this.finishDownloadFailure( new VOTLocalizedError("VOTFailedDownloadAudio") ); return; } try { if (this.requestedFailAudio.has(videoUrl)) { debug.log("fail-audio-js request already sent for this video"); } else { debug.log("Sending fail-audio-js request"); await this.videoHandler.votClient.requestVtransFailAudio(videoUrl); this.requestedFailAudio.add(videoUrl); } this.finishDownloadSuccess(); } catch (error2) { this.finishDownloadFailure( new VOTLocalizedError("VOTFailedDownloadAudio") ); } }; finishDownloadSuccess() { this.downloading = false; this.resolveDownloadWaiters(); } finishDownloadFailure(error2) { this.downloading = false; this.rejectDownloadWaiters(error2); } getCanonicalUrl(videoId) { return `https://youtu.be/${videoId}`; } isLivelyVoiceUnavailableError(value) { const msg = getErrorMessage(value); return !!msg && msg.toLowerCase().includes("обычная озвучка"); } scheduleRetry(fn, delayMs, signal) { return new Promise((resolve, reject) => { let timeoutId = null; const cleanup = () => { if (timeoutId !== null) { clearTimeout(timeoutId); } signal.removeEventListener("abort", onAbort); }; const onAbort = () => { cleanup(); reject(makeAbortError()); }; signal.addEventListener("abort", onAbort, { once: true }); if (signal.aborted) { onAbort(); return; } timeoutId = setTimeout(() => { if (signal.aborted) { onAbort(); return; } cleanup(); void fn().then(resolve, reject); }, delayMs); if (timeoutId !== null) { this.videoHandler.autoRetry = timeoutId; } }); } async translateVideoImpl(videoData, requestLang, responseLang, translationHelp = null, shouldSendFailedAudio = false, signal = new AbortController().signal, disableLivelyVoice = false) { clearTimeout(this.videoHandler.autoRetry); this.finishDownloadSuccess(); const requestLangForApi = this.videoHandler.getRequestLangForTranslation( requestLang, responseLang ); let livelyDisabled = disableLivelyVoice; try { throwIfAborted(signal); const livelyVoiceAllowed = this.videoHandler.isLivelyVoiceAllowed( requestLangForApi, responseLang ); let useLivelyVoice = !livelyDisabled && livelyVoiceAllowed && Boolean(this.videoHandler.data?.useLivelyVoice); let res; for (let attempt = 0; attempt < 2; attempt++) { try { res = await this.videoHandler.votClient.translateVideo({ videoData, requestLang: requestLangForApi, responseLang, translationHelp, extraOpts: { useLivelyVoice, videoTitle: this.videoHandler.videoData?.title }, shouldSendFailedAudio }); } catch (err) { if (useLivelyVoice && this.isLivelyVoiceUnavailableError(err)) { debug.log( "[translateVideoImpl] Lively voices are unavailable. Falling back to standard translation.", err ); livelyDisabled = true; useLivelyVoice = false; continue; } throw err; } if (useLivelyVoice && this.isLivelyVoiceUnavailableError(res)) { debug.log( "[translateVideoImpl] Server responded that lively voices are unavailable. Falling back to standard translation.", res ); livelyDisabled = true; useLivelyVoice = false; res = void 0; continue; } break; } if (!res) { throw new Error("Failed to get translation response"); } debug.log("Translate video result", res); throwIfAborted(signal); if (res.translated && res.remainingTime < 1) { debug.log("Video translation finished with this data: ", res); return { ...res, usedLivelyVoice: useLivelyVoice }; } const message = res.message ?? localizationProvider.get("translationTakeFewMinutes"); await this.videoHandler.updateTranslationErrorMsg( res.remainingTime > 0 ? formatTranslationEta( res.remainingTime, (key) => localizationProvider.get(key) ) : message, signal ); if (res.status === VideoTranslationStatus.AUDIO_REQUESTED && this.videoHandler.isYouTubeHosts()) { this.videoHandler.hadAsyncWait = true; debug.log("Start audio download"); this.downloading = true; await this.audioDownloader.runAudioDownload( videoData.videoId, res.translationId, signal ); debug.log("waiting downloading finish"); await this.waitForAudioDownloadCompletion(signal, 15e3); return await this.translateVideoImpl( videoData, requestLang, responseLang, translationHelp, true, signal, livelyDisabled ); } } catch (err) { if (isAbortError(err)) { return null; } const uiError = mapVotClientErrorForUi(err); await this.videoHandler.updateTranslationErrorMsg( getServerErrorMessage(uiError) ?? uiError, signal ); this.videoHandler.hadAsyncWait = notifyTranslationFailureIfNeeded({ aborted: Boolean( this.videoHandler.actionsAbortController?.signal?.aborted ), translateApiErrorsEnabled: Boolean( this.videoHandler.data?.translateAPIErrors ), hadAsyncWait: this.videoHandler.hadAsyncWait, videoId: videoData.videoId, error: err, notify: (params) => this.videoHandler.notifier.translationFailed(params) }); console.error("[VOT]", err); return null; } this.videoHandler.hadAsyncWait = true; return this.scheduleRetry( () => this.translateVideoImpl( videoData, requestLang, responseLang, translationHelp, shouldSendFailedAudio, signal, livelyDisabled ), 2e4, signal ); } waitForAudioDownloadCompletion(signal, timeoutMs) { if (!this.downloading) { return Promise.resolve(); } return new Promise((resolve, reject) => { let entry; const onAbort = () => { cleanup(); reject(makeAbortError()); }; const timeoutId = setTimeout(() => { cleanup(); resolve(); }, timeoutMs); const cleanup = () => { clearTimeout(timeoutId); signal.removeEventListener("abort", onAbort); this.downloadWaiters.delete(entry); }; entry = { resolve: () => { cleanup(); resolve(); }, reject: (error2) => { cleanup(); reject(error2); } }; this.downloadWaiters.add(entry); if (signal.aborted) { onAbort(); return; } signal.addEventListener("abort", onAbort, { once: true }); }); } resolveDownloadWaiters() { this.forEachDownloadWaiter((waiter) => waiter.resolve()); } rejectDownloadWaiters(error2) { this.forEachDownloadWaiter((waiter) => waiter.reject(error2)); } forEachDownloadWaiter(handler) { if (!this.downloadWaiters.size) { return; } const waiters = Array.from(this.downloadWaiters); this.downloadWaiters.clear(); for (const waiter of waiters) { handler(waiter); } } } class TranslationOrchestrator { state = { status: "idle" }; deps; constructor(deps) { this.deps = deps; } get currentState() { return this.state; } setState(next) { this.state = next; } reset() { this.setState({ status: "idle" }); } async runAutoTranslationIfEligible() { if (this.state.status !== "idle") { return; } if (!(this.deps.isFirstPlay() && this.deps.isAutoTranslateEnabled() && this.deps.getVideoId())) { return; } if (this.deps.isMobileYouTubeMuted?.()) { this.setState({ status: "deferred", reason: "muted" }); this.deps.setMuteWatcher?.(() => { this.setState({ status: "idle" }); void this.runAutoTranslationIfEligible(); }); return; } this.setState({ status: "pending", reason: "auto" }); try { this.deps.setFirstPlay(false); await this.deps.scheduleAutoTranslate(); this.reset(); } catch (err) { this.setState({ status: "error", message: err }); throw err; } } } function resetLifecycleTranslation(host, options = {}) { const { requireVideoData = false, clearVideoData = false } = options; if (requireVideoData && !host.videoData) { return; } if (clearVideoData) { host.videoData = void 0; } host.stopTranslation(); host.resetSubtitlesWidget(); } function hideLifecycleOverlay(overlayView, options = {}) { const { hideMenu = false } = options; if (overlayView?.votButton?.container) { overlayView.votButton.container.hidden = true; } if (hideMenu && overlayView?.votMenu) { overlayView.votMenu.hidden = true; } } function resetAndHideLifecycle(host, overlayView, options = {}) { const { requireVideoData, clearVideoData, hideMenu } = options; resetLifecycleTranslation(host, { requireVideoData, clearVideoData }); hideLifecycleOverlay(overlayView, { hideMenu }); } class VideoLifecycleController { host; lifecycleGeneration = 0; lastSetCanPlaySourceKey = ""; setCanPlayRequested = false; setCanPlayLoopPromise; inFlightVideoDataRequest; constructor(host) { this.host = host; } isStale(generation) { return generation !== this.lifecycleGeneration; } resetActions(reason) { if (typeof this.host.resetActionsAbortController === "function") { this.host.resetActionsAbortController(reason); return; } this.host.actionsAbortController?.abort(reason); } invalidateActiveSession(reason) { if (this.lifecycleGeneration === 0) return; this.lifecycleGeneration += 1; this.resetActions(`[VideoLifecycle] ${reason}`); debug.log( `[VideoLifecycle] cancelled active session (active: ${this.lifecycleGeneration})`, { reason } ); } startSession(reason) { this.lifecycleGeneration += 1; const sessionId = this.lifecycleGeneration; this.resetActions(`[VideoLifecycle][session:${sessionId}] ${reason}`); return sessionId; } shouldAbortHandleSrcChanged(callId, stage) { if (!this.isStale(callId)) { return false; } debug.log( `[VideoLifecycle][session:${callId}] handleSrcChanged aborted at ${stage} (active: ${this.lifecycleGeneration})` ); return true; } teardown() { this.setCanPlayRequested = false; this.invalidateActiveSession("teardown"); this.inFlightVideoDataRequest = void 0; } getCurrentSourceKey() { const src = this.host.video.currentSrc || this.host.video.src || ""; const hasSrcObject = this.host.video.srcObject ? "1" : "0"; return `${globalThis.location.href}||${src}||${hasSrcObject}`; } resolveContainer() { const { site, video, container } = this.host; if (!site.selector) { return video.parentElement ?? container; } const matched = findConnectedContainerBySelector(video, site.selector); if (matched) { return matched; } if (container.isConnected && containsCrossShadow(container, video)) { return container; } return video.parentElement ?? container; } async setCanPlay() { this.setCanPlayRequested = true; if (this.setCanPlayLoopPromise !== void 0) { this.invalidateActiveSession("setCanPlay superseded by a newer trigger"); return await this.setCanPlayLoopPromise; } const loopPromise = (async () => { while (this.setCanPlayRequested) { this.setCanPlayRequested = false; await this.runSetCanPlayOnce(); } })(); this.setCanPlayLoopPromise = loopPromise; try { await loopPromise; } finally { if (this.setCanPlayLoopPromise === loopPromise) { this.setCanPlayLoopPromise = void 0; } } } async runSetCanPlayOnce() { const sourceKey = this.getCurrentSourceKey(); if (this.host.videoData?.videoId && sourceKey === this.lastSetCanPlaySourceKey) { return; } const currentId = this.startSession(`setCanPlay (source: ${sourceKey})`); this.lastSetCanPlaySourceKey = sourceKey; await this.handleSrcChanged(currentId, sourceKey); 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)) { return; } await autoSubtitlesPromise; if (this.isStale(currentId)) { return; } } async runAutoSubtitlesIfEnabled(sessionId) { if (!this.host.data.autoSubtitles || !this.host.videoData?.videoId) { return; } try { await this.host.enableSubtitlesForCurrentLangPair(); } catch (err) { } } async getVideoDataSingleFlight(sourceKey) { const inFlight = this.inFlightVideoDataRequest; if (inFlight?.sourceKey === 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 = void 0; } } } async handleSrcChanged(callId, expectedSourceKey) { const sessionId = typeof callId === "number" ? callId : this.startSession("manual handleSrcChanged"); const sourceKey = typeof expectedSourceKey === "string" && expectedSourceKey.length > 0 ? expectedSourceKey : this.getCurrentSourceKey(); if (typeof callId !== "number") { this.lastSetCanPlaySourceKey = sourceKey; } if (this.shouldAbortHandleSrcChanged(sessionId, "before start")) { return; } this.host.firstPlay = true; const overlayView = this.host.uiManager.votOverlayView; resetAndHideLifecycle(this.host, overlayView, { requireVideoData: true }); const noSrc = !this.host.video.src && !this.host.video.currentSrc && !this.host.video.srcObject; if (noSrc) { hideLifecycleOverlay(overlayView, { hideMenu: true }); } const nextContainer = this.resolveContainer(); if (nextContainer !== this.host.container) { this.host.container = nextContainer; } if (this.shouldSkipYouTubeSource()) { hideLifecycleOverlay(overlayView, { hideMenu: true }); return; } if (this.shouldAbortHandleSrcChanged(sessionId, "before getVideoData")) { return; } overlayView.votButton.container.hidden = false; overlayView.votButton.opacity = 1; this.host.queueOverlayAutoHide?.(); let nextVideoData; try { nextVideoData = typeof sourceKey === "string" && sourceKey ? await this.getVideoDataSingleFlight(sourceKey) : await this.host.getVideoData(); } catch (err) { if (this.shouldAbortHandleSrcChanged(sessionId, "after getVideoData error")) { return; } this.host.videoData = void 0; hideLifecycleOverlay(overlayView, { hideMenu: true }); return; } if (this.shouldAbortHandleSrcChanged(sessionId, "after getVideoData")) { return; } this.host.videoData = nextVideoData; if (!this.host.videoData?.videoId) { hideLifecycleOverlay(overlayView, { hideMenu: true }); return; } const cacheKey = this.host.getSubtitlesCacheKey( this.host.videoData.videoId, this.host.videoData.detectedLanguage, this.host.videoData.responseLanguage ); this.host.subtitles = this.host.cacheManager.getSubtitles(cacheKey) ?? []; await this.host.updateSubtitlesLangSelect(); if (this.shouldAbortHandleSrcChanged(sessionId, "after subtitles update")) { return; } this.host.translateToLang = this.host.data.responseLanguage ?? "ru"; this.host.setSelectMenuValues( this.host.videoData.detectedLanguage, this.host.videoData.responseLanguage ); overlayView.votButton.container.hidden = false; overlayView.votButton.opacity = 1; this.host.queueOverlayAutoHide?.(); } 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; } } const URL_FILTER = /\b(?:https?:\/\/|www\.)\S+/gi; const HASHTAG_FILTER = /#[^\s#]+/g; const YOUTUBE_META_FILTER = /auto-generated\s+by\s+youtube|provided\s+to\s+youtube\s+by|released\s+on/gi; const PAYPAL_FILTER = /paypal?/gi; const ETH_ADDRESS_FILTER = /0x[\da-f]{40}/gi; const BTC_ADDRESS_FILTER = /[13][1-9a-z]{25,34}/gi; const BTC_BECH32_FILTER = /4[\dab][1-9a-z]{93}/gi; const TON_ADDRESS_FILTER = /t[1-9a-z]{33}/gi; const TEXT_FILTERS = [ URL_FILTER, HASHTAG_FILTER, YOUTUBE_META_FILTER, PAYPAL_FILTER, ETH_ADDRESS_FILTER, BTC_ADDRESS_FILTER, BTC_BECH32_FILTER, TON_ADDRESS_FILTER ]; function cleanText(title, description) { const raw = `${title ?? ""} ${description ?? ""}`.trim(); if (!raw) return ""; let cleaned = raw; for (const filter of TEXT_FILTERS) { cleaned = cleaned.replaceAll(filter, ""); } return cleaned.replaceAll(/[\p{P}\p{S}]+/gu, " ").replaceAll(/\s+/g, " ").trim().slice(0, 450); } const SETTINGS_CACHE_TTL_MS = 5e3; let cachedTranslationService = null; let cachedTranslationServiceAt = 0; let cachedDetectService = null; let cachedDetectServiceAt = 0; async function getTranslationServiceCached() { const now2 = Date.now(); if (cachedTranslationService && now2 - cachedTranslationServiceAt < SETTINGS_CACHE_TTL_MS) { return cachedTranslationService; } const service = await votStorage.get( "translationService", defaultTranslationService ); cachedTranslationService = String(service); cachedTranslationServiceAt = now2; return cachedTranslationService; } async function getDetectServiceCached() { const now2 = Date.now(); if (cachedDetectService && now2 - cachedDetectServiceAt < SETTINGS_CACHE_TTL_MS) { return cachedDetectService; } const service = await votStorage.get("detectService", defaultDetectService); cachedDetectService = String(service); cachedDetectServiceAt = now2; return cachedDetectService; } const foswlyServices = ["yandexbrowser", "msedge"]; const FOSWLYTranslateAPI = new class { isFOSWLYError(data) { return Object.hasOwn(data, "error"); } async request(path, opts = {}) { try { const res = await GM_fetch(`${foswlyTranslateUrl}${path}`, { timeout: 3e3, ...opts }); const data = await res.json(); if (this.isFOSWLYError(data)) { throw new Error(data.error); } return data; } catch (err) { console.error( `[VOT] Failed to get data from FOSWLY Translate API, because ${err instanceof Error ? err.message : String(err)}` ); return void 0; } } async translateMultiple(text, lang2, service) { const result = await this.request( "/translate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, lang: lang2, service }) } ); return result ? result.translations : text; } async translate(text, lang2, service) { const result = await this.request( `/translate?${new URLSearchParams({ text, lang: lang2, service })}` ); return result ? result.translations[0] : text; } async detect(text, service) { const result = await this.request( `/detect?${new URLSearchParams({ text, service })}` ); return result ? result.lang : "en"; } }(); const RustServerAPI = { async detect(text) { try { const response = await GM_fetch(detectRustServerUrl, { method: "POST", body: text, timeout: 3e3 }); return await response.text(); } catch (error2) { console.error( `[VOT] Error getting lang from text, because ${error2.message}` ); return "en"; } } }; async function translate(text, fromLang = "", toLang = "ru") { const service = await getTranslationServiceCached(); switch (service) { case "yandexbrowser": case "msedge": { const langPair = fromLang && toLang ? `${fromLang}-${toLang}` : toLang; return Array.isArray(text) ? await FOSWLYTranslateAPI.translateMultiple(text, langPair, service) : await FOSWLYTranslateAPI.translate(text, langPair, service); } default: return text; } } async function detect(text) { const service = await getDetectServiceCached(); switch (service) { case "yandexbrowser": case "msedge": return await FOSWLYTranslateAPI.detect(text, service); case "rust-server": return await RustServerAPI.detect(text); default: return "en"; } } const detectServices = [...foswlyServices, "rust-server"]; const VIDEO_VOLUME_MIN_PERCENT = 0; const VIDEO_VOLUME_MAX_PERCENT = 100; const VIDEO_VOLUME_STEP_01 = 0.01; const EPS = 1e-6; function clampNumber$1(value, min, max) { if (!Number.isFinite(value)) return min; if (max < min) return min; return Math.max(min, Math.min(max, value)); } function clampInt(value, min, max) { return Math.trunc(clampNumber$1(value, min, max)); } function clampPercentInt(value, min = VIDEO_VOLUME_MIN_PERCENT, max = VIDEO_VOLUME_MAX_PERCENT) { if (!Number.isFinite(value)) return min; return clampInt(Math.round(value), min, max); } function volume01ToPercent(volume01) { const v2 = clampNumber$1(volume01, 0, 1); return clampPercentInt(v2 * 100); } function percentToVolume01(percent) { return clampPercentInt(percent) / 100; } function quantizeToStep(value, step, direction) { if (!Number.isFinite(value)) return value; if (!Number.isFinite(step) || step <= 0) return value; const inv = 1 / step; const scaled = value * inv; switch (direction) { case "down": return Math.floor(scaled + EPS) / inv; case "up": return Math.ceil(scaled - EPS) / inv; default: return Math.round(scaled) / inv; } } function snapVolume01(volume01, direction = "nearest", step = VIDEO_VOLUME_STEP_01) { const clamped = clampNumber$1(volume01, 0, 1); const quantized = quantizeToStep(clamped, step, direction); return clampNumber$1(quantized, 0, 1); } function snapVolume01Towards(next, current, desired, step = VIDEO_VOLUME_STEP_01) { const cur = clampNumber$1(current, 0, 1); const des = clampNumber$1(desired, 0, 1); if (des < cur) { const q = snapVolume01(next, "down", step); return Math.max(des, q); } if (des > cur) { const q = snapVolume01(next, "up", step); return Math.min(des, q); } return snapVolume01(next, "nearest", step); } const EXTERNAL_VOLUME_HOSTS = new Set(["youtube", "googledrive"]); const YOUTUBE_LIKE_HOSTS = EXTERNAL_VOLUME_HOSTS; const MUTE_SYNC_DISABLED_HOSTS = new Set(["rutube", "ok"]); const TRANSLATION_DOWNLOAD_HOSTS = new Set([ "youtube", "invidious", "piped" ]); function isExternalVolumeHost(host) { return EXTERNAL_VOLUME_HOSTS.has(host); } function isYouTubeLikeHost(host) { return YOUTUBE_LIKE_HOSTS.has(host); } function isMuteSyncDisabledHost(host) { return MUTE_SYNC_DISABLED_HOSTS.has(host); } function isDesktopYouTubeLikeSite(site) { return isYouTubeLikeHost(site.host) && site.additionalData !== "mobile"; } function isTranslationDownloadHost(host) { return TRANSLATION_DOWNLOAD_HOSTS.has(host); } const FORCED_DETECTED_LANGUAGE_BY_HOST = { rutube: "ru", "ok.ru": "ru", mail_ru: "ru", weverse: "ko", niconico: "ja", youku: "zh", bilibili: "zh", weibo: "zh", zdf: "de" }; const YT_VOLUME_NOW_SELECTOR = ".ytp-volume-panel [aria-valuenow]"; function pickFirstNonEmptyString(...values) { for (const value of values) { if (typeof value !== "string") continue; const trimmed = value.trim(); if (trimmed) { return trimmed; } } return void 0; } function normalizeToRequestLang(value) { if (typeof value !== "string") return void 0; const normalized = value.toLowerCase().split(/[-_]/)[0]; return availableLangs.includes(normalized) ? normalized : void 0; } function isResolvedLanguage(value) { return Boolean(value && value !== "auto"); } function inferLanguageFromSubtitles(subtitles) { if (!Array.isArray(subtitles) || subtitles.length === 0) { return void 0; } const tracks = subtitles; for (const track of tracks) { const translatedFrom = normalizeToRequestLang( track?.translatedFromLanguage ); if (isResolvedLanguage(translatedFrom)) { return translatedFrom; } } for (const track of tracks) { if (track?.translatedFromLanguage) continue; const language = normalizeToRequestLang(track?.language); if (isResolvedLanguage(language)) { return language; } } for (const track of tracks) { const language = normalizeToRequestLang(track?.language); if (isResolvedLanguage(language)) { return language; } } return void 0; } function pickResolvedLanguage(...values) { for (const value of values) { if (isResolvedLanguage(value)) { return value; } } return void 0; } function buildDetectText(title, localizedTitle, description) { const textTitle = pickFirstNonEmptyString( title, localizedTitle, document.title ); const textDescription = typeof description === "string" ? description : void 0; return cleanText(textTitle ?? "", textDescription); } function resolveHostDetectedLanguage(host) { const forcedDetectedLanguage = FORCED_DETECTED_LANGUAGE_BY_HOST[host]; if (forcedDetectedLanguage) { return forcedDetectedLanguage; } if (host === "vk") { const trackLang = document.getElementsByTagName("track")?.[0]?.srclang; const normalizedTrackLang = normalizeToRequestLang(trackLang); if (isResolvedLanguage(normalizedTrackLang)) { return normalizedTrackLang; } } return void 0; } function getAriaValueNowPercent(selector) { const el = document.querySelector(selector); const rawNow = el?.getAttribute("aria-valuenow"); const rawMax = el?.getAttribute("aria-valuemax"); const now2 = rawNow == null ? Number.NaN : Number.parseFloat(rawNow); const max = rawMax == null ? Number.NaN : Number.parseFloat(rawMax); if (!Number.isFinite(now2)) return null; if (Number.isFinite(max) && max > 0) { return clampPercentInt(now2 / max * 100); } return clampPercentInt(now2); } function extractYouTubeVideoId(url) { 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 { } } } if (url.hostname === "youtu.be") { const id = url.pathname.replace(/^\/+/, "").split("/")[0]; return id || void 0; } const vParam = url.searchParams.get("v"); if (vParam) { return vParam; } return /\/(?:shorts|embed|live|v|e)\/([^/?#]+)/.exec(url.pathname)?.[1] ?? void 0; } function selectDetectTextSource(current, fallback) { 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 }; } class VOTVideoManager { videoHandler; detectInFlightByVideoId = new Map(); constructor(videoHandler) { this.videoHandler = videoHandler; } async detectLanguageSingleFlight(videoId, text) { const inFlightDetect = this.detectInFlightByVideoId.get(videoId); if (inFlightDetect !== void 0) { return await inFlightDetect; } const task = (async () => { const language = normalizeToRequestLang(await detect(text)); return pickResolvedLanguage(language); })(); this.detectInFlightByVideoId.set(videoId, task); try { return await task; } finally { if (this.detectInFlightByVideoId.get(videoId) === task) { this.detectInFlightByVideoId.delete(videoId); } } } scheduleLanguageDetection(videoData) { 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((error2) => { }); } getYouTubeVideoDataFast() { 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?.() ?? void 0; 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; 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, localizedTitle, description, detectedLanguage: possibleLanguage, subtitles, isStream = false } = rawVideoData; const possibleRequestLanguage = normalizeToRequestLang(possibleLanguage); const selectedRequestLanguage = normalizeToRequestLang( this.videoHandler.translateFromLang ); const shouldUseLanguageFallbacks = this.videoHandler.site.host !== "youtube" || isResolvedLanguage(possibleRequestLanguage); let detectedLanguage = pickResolvedLanguage( possibleRequestLanguage, shouldUseLanguageFallbacks ? selectedRequestLanguage : void 0, shouldUseLanguageFallbacks ? inferLanguageFromSubtitles(subtitles) : void 0 ); detectedLanguage = pickResolvedLanguage( resolveHostDetectedLanguage(this.videoHandler.site.host), detectedLanguage ); const normalizedDetectedLanguage = detectedLanguage ?? "auto"; const videoData = { translationHelp: translationHelp ?? null, isStream, duration: duration || this.videoHandler.video?.duration || votConfig.defaultDuration, videoId, url, host, detectedLanguage: normalizedDetectedLanguage, responseLanguage: this.videoHandler.translateToLang, subtitles, title, localizedTitle, description, downloadTitle: localizedTitle ?? title ?? videoId }; console.log("[VOT] Detected language:", normalizedDetectedLanguage); this.scheduleLanguageDetection(videoData); return videoData; } videoValidator() { if (!this.videoHandler.videoData || !this.videoHandler.data) { throw new VOTLocalizedError("VOTNoVideoIDFound"); } console.log("[VOT] Video Data: ", this.videoHandler.videoData); if (this.videoHandler.data.enabledDontTranslateLanguages && this.videoHandler.data.dontTranslateLanguages?.includes( this.videoHandler.videoData.detectedLanguage )) { throw new VOTLocalizedError("VOTDisableFromYourLang"); } if (this.videoHandler.videoData.isStream) { throw new VOTLocalizedError("VOTStreamNotAvailable"); } if ( this.videoHandler.videoData.duration > 14400 ) { throw new VOTLocalizedError("VOTVideoIsTooLong"); } return true; } getVideoVolume() { const video = this.videoHandler.video; if (!video) return void 0; if (isExternalVolumeHost(this.videoHandler.site.host)) { const ariaPercent = getAriaValueNowPercent(YT_VOLUME_NOW_SELECTOR); if (ariaPercent != null) { return percentToVolume01(ariaPercent); } const extVolume = YoutubeHelper.getVolume(); if (typeof extVolume === "number" && Number.isFinite(extVolume)) { return snapVolume01(extVolume); } } return snapVolume01(video.volume); } setVideoVolume(volume) { const snapped = snapVolume01(volume); if (!isExternalVolumeHost(this.videoHandler.site.host)) { this.videoHandler.video.volume = snapped; return this; } try { const result = YoutubeHelper.setVolume(snapped); const ok = typeof result === "boolean" && result || typeof result === "number" && Number.isFinite(result); if (ok) return this; } catch { } this.videoHandler.video.volume = snapped; return this; } isMuted() { return isExternalVolumeHost(this.videoHandler.site.host) ? YoutubeHelper.isMuted() : this.videoHandler.video?.muted; } syncVideoVolumeSlider() { const overlayView = this.videoHandler.uiManager.votOverlayView; if (!overlayView?.isInitialized()) return this; const ariaPercent = isExternalVolumeHost(this.videoHandler.site.host) ? getAriaValueNowPercent(YT_VOLUME_NOW_SELECTOR) : null; const volumePercent = this.isMuted() ? 0 : ariaPercent ?? volume01ToPercent(this.getVideoVolume() ?? 0); overlayView.videoVolumeSlider.value = volumePercent; this.videoHandler.onVideoVolumeSliderSynced?.(volumePercent); return this; } setSelectMenuValues(from, to) { if (!this.videoHandler.uiManager.votOverlayView?.isInitialized() || !this.videoHandler.videoData) { return this; } const normalizedFrom = normalizeToRequestLang(from) ?? "auto"; console.log(`[VOT] Set translation from ${normalizedFrom} to ${to}`); this.videoHandler.uiManager.votOverlayView.languagePairSelect.fromSelect.selectTitle = localizationProvider.getLangLabel(normalizedFrom); this.videoHandler.uiManager.votOverlayView.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; } } const t = globalThis, i = (t2) => t2, s = t.trustedTypes, e = s ? s.createPolicy("lit-html", { createHTML: (t2) => t2 }) : void 0, h = "$lit$", o = `lit$${Math.random().toFixed(9).slice(2)}$`, n = "?" + o, r = `<${n}>`, l = document, c = () => l.createComment(""), a = (t2) => null === t2 || "object" != typeof t2 && "function" != typeof t2, u = Array.isArray, d = (t2) => u(t2) || "function" == typeof t2?.[Symbol.iterator], f = "[ \n\f\r]", v = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, _ = /-->/g, m = />/g, p = RegExp(`>|${f}(?:([^\\s"'>=/]+)(${f}*=${f}*(?:[^ \f\r"'\`<>=]|("|')|))|$)`, "g"), g = /'/g, $ = /"/g, y = /^(?:script|style|textarea|title)$/i, x = (t2) => (i2, ...s2) => ({ _$litType$: t2, strings: i2, values: s2 }), b = x(1), w = x(2), E = Symbol.for("lit-noChange"), A = Symbol.for("lit-nothing"), C = new WeakMap(), P = l.createTreeWalker(l, 129); function V(t2, i2) { if (!u(t2) || !t2.hasOwnProperty("raw")) throw Error("invalid template strings array"); return void 0 !== e ? e.createHTML(i2) : i2; } const N = (t2, i2) => { const s2 = t2.length - 1, e2 = []; let n2, l2 = 2 === i2 ? "" : 3 === i2 ? "" : "", c2 = v; for (let i3 = 0; i3 < s2; i3++) { const s3 = t2[i3]; let a2, u2, d2 = -1, f2 = 0; for (; f2 < s3.length && (c2.lastIndex = f2, u2 = c2.exec(s3), null !== u2); ) f2 = c2.lastIndex, c2 === v ? "!--" === u2[1] ? c2 = _ : void 0 !== u2[1] ? c2 = m : void 0 !== u2[2] ? (y.test(u2[2]) && (n2 = RegExp("" === u2[0] ? (c2 = n2 ?? v, d2 = -1) : void 0 === u2[1] ? d2 = -2 : (d2 = c2.lastIndex - u2[2].length, a2 = u2[1], c2 = void 0 === u2[3] ? p : '"' === u2[3] ? $ : g) : c2 === $ || c2 === g ? c2 = p : c2 === _ || c2 === m ? c2 = v : (c2 = p, n2 = void 0); const x2 = c2 === p && t2[i3 + 1].startsWith("/>") ? " " : ""; l2 += c2 === v ? s3 + r : d2 >= 0 ? (e2.push(a2), s3.slice(0, d2) + h + s3.slice(d2) + o + x2) : s3 + o + (-2 === d2 ? i3 : x2); } return [V(t2, l2 + (t2[s2] || "") + (2 === i2 ? "" : 3 === i2 ? "" : "")), e2]; }; class S { constructor({ strings: t2, _$litType$: i2 }, e2) { let r2; this.parts = []; let l2 = 0, a2 = 0; const u2 = t2.length - 1, d2 = this.parts, [f2, v2] = N(t2, i2); if (this.el = S.createElement(f2, e2), P.currentNode = this.el.content, 2 === i2 || 3 === i2) { const t3 = this.el.content.firstChild; t3.replaceWith(...t3.childNodes); } for (; null !== (r2 = P.nextNode()) && d2.length < u2; ) { if (1 === r2.nodeType) { if (r2.hasAttributes()) for (const t3 of r2.getAttributeNames()) if (t3.endsWith(h)) { const i3 = v2[a2++], s2 = r2.getAttribute(t3).split(o), e3 = /([.?@])?(.*)/.exec(i3); d2.push({ type: 1, index: l2, name: e3[2], strings: s2, ctor: "." === e3[1] ? I : "?" === e3[1] ? L : "@" === e3[1] ? z : H }), r2.removeAttribute(t3); } else t3.startsWith(o) && (d2.push({ type: 6, index: l2 }), r2.removeAttribute(t3)); if (y.test(r2.tagName)) { const t3 = r2.textContent.split(o), i3 = t3.length - 1; if (i3 > 0) { r2.textContent = s ? s.emptyScript : ""; for (let s2 = 0; s2 < i3; s2++) r2.append(t3[s2], c()), P.nextNode(), d2.push({ type: 2, index: ++l2 }); r2.append(t3[i3], c()); } } } else if (8 === r2.nodeType) if (r2.data === n) d2.push({ type: 2, index: l2 }); else { let t3 = -1; for (; -1 !== (t3 = r2.data.indexOf(o, t3 + 1)); ) d2.push({ type: 7, index: l2 }), t3 += o.length - 1; } l2++; } } static createElement(t2, i2) { const s2 = l.createElement("template"); return s2.innerHTML = t2, s2; } } function M(t2, i2, s2 = t2, e2) { if (i2 === E) return i2; let h2 = void 0 !== e2 ? s2._$Co?.[e2] : s2._$Cl; const o2 = a(i2) ? void 0 : i2._$litDirective$; return h2?.constructor !== o2 && (h2?._$AO?.(false), void 0 === o2 ? h2 = void 0 : (h2 = new o2(t2), h2._$AT(t2, s2, e2)), void 0 !== e2 ? (s2._$Co ??= [])[e2] = h2 : s2._$Cl = h2), void 0 !== h2 && (i2 = M(t2, h2._$AS(t2, i2.values), h2, e2)), i2; } class R { constructor(t2, i2) { this._$AV = [], this._$AN = void 0, this._$AD = t2, this._$AM = i2; } get parentNode() { return this._$AM.parentNode; } get _$AU() { return this._$AM._$AU; } u(t2) { const { el: { content: i2 }, parts: s2 } = this._$AD, e2 = (t2?.creationScope ?? l).importNode(i2, true); P.currentNode = e2; let h2 = P.nextNode(), o2 = 0, n2 = 0, r2 = s2[0]; for (; void 0 !== r2; ) { if (o2 === r2.index) { let i3; 2 === r2.type ? i3 = new k(h2, h2.nextSibling, this, t2) : 1 === r2.type ? i3 = new r2.ctor(h2, r2.name, r2.strings, this, t2) : 6 === r2.type && (i3 = new Z(h2, this, t2)), this._$AV.push(i3), r2 = s2[++n2]; } o2 !== r2?.index && (h2 = P.nextNode(), o2++); } return P.currentNode = l, e2; } p(t2) { let i2 = 0; for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t2, s2, i2), i2 += s2.strings.length - 2) : s2._$AI(t2[i2])), i2++; } } class k { get _$AU() { return this._$AM?._$AU ?? this._$Cv; } constructor(t2, i2, s2, e2) { this.type = 2, this._$AH = A, this._$AN = void 0, this._$AA = t2, this._$AB = i2, this._$AM = s2, this.options = e2, this._$Cv = e2?.isConnected ?? true; } get parentNode() { let t2 = this._$AA.parentNode; const i2 = this._$AM; return void 0 !== i2 && 11 === t2?.nodeType && (t2 = i2.parentNode), t2; } get startNode() { return this._$AA; } get endNode() { return this._$AB; } _$AI(t2, i2 = this) { t2 = M(this, t2, i2), a(t2) ? t2 === A || null == t2 || "" === t2 ? (this._$AH !== A && this._$AR(), this._$AH = A) : t2 !== this._$AH && t2 !== E && this._(t2) : void 0 !== t2._$litType$ ? this.$(t2) : void 0 !== t2.nodeType ? this.T(t2) : d(t2) ? this.k(t2) : this._(t2); } O(t2) { return this._$AA.parentNode.insertBefore(t2, this._$AB); } T(t2) { this._$AH !== t2 && (this._$AR(), this._$AH = this.O(t2)); } _(t2) { this._$AH !== A && a(this._$AH) ? this._$AA.nextSibling.data = t2 : this.T(l.createTextNode(t2)), this._$AH = t2; } $(t2) { const { values: i2, _$litType$: s2 } = t2, e2 = "number" == typeof s2 ? this._$AC(t2) : (void 0 === s2.el && (s2.el = S.createElement(V(s2.h, s2.h[0]), this.options)), s2); if (this._$AH?._$AD === e2) this._$AH.p(i2); else { const t3 = new R(e2, this), s3 = t3.u(this.options); t3.p(i2), this.T(s3), this._$AH = t3; } } _$AC(t2) { let i2 = C.get(t2.strings); return void 0 === i2 && C.set(t2.strings, i2 = new S(t2)), i2; } k(t2) { u(this._$AH) || (this._$AH = [], this._$AR()); const i2 = this._$AH; let s2, e2 = 0; for (const h2 of t2) e2 === i2.length ? i2.push(s2 = new k(this.O(c()), this.O(c()), this, this.options)) : s2 = i2[e2], s2._$AI(h2), e2++; e2 < i2.length && (this._$AR(s2 && s2._$AB.nextSibling, e2), i2.length = e2); } _$AR(t2 = this._$AA.nextSibling, s2) { for (this._$AP?.(false, true, s2); t2 !== this._$AB; ) { const s3 = i(t2).nextSibling; i(t2).remove(), t2 = s3; } } setConnected(t2) { void 0 === this._$AM && (this._$Cv = t2, this._$AP?.(t2)); } } class H { get tagName() { return this.element.tagName; } get _$AU() { return this._$AM._$AU; } constructor(t2, i2, s2, e2, h2) { this.type = 1, this._$AH = A, this._$AN = void 0, this.element = t2, this.name = i2, this._$AM = e2, this.options = h2, s2.length > 2 || "" !== s2[0] || "" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = A; } _$AI(t2, i2 = this, s2, e2) { const h2 = this.strings; let o2 = false; if (void 0 === h2) t2 = M(this, t2, i2, 0), o2 = !a(t2) || t2 !== this._$AH && t2 !== E, o2 && (this._$AH = t2); else { const e3 = t2; let n2, r2; for (t2 = h2[0], n2 = 0; n2 < h2.length - 1; n2++) r2 = M(this, e3[s2 + n2], i2, n2), r2 === E && (r2 = this._$AH[n2]), o2 ||= !a(r2) || r2 !== this._$AH[n2], r2 === A ? t2 = A : t2 !== A && (t2 += (r2 ?? "") + h2[n2 + 1]), this._$AH[n2] = r2; } o2 && !e2 && this.j(t2); } j(t2) { t2 === A ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t2 ?? ""); } } class I extends H { constructor() { super(...arguments), this.type = 3; } j(t2) { this.element[this.name] = t2 === A ? void 0 : t2; } } class L extends H { constructor() { super(...arguments), this.type = 4; } j(t2) { this.element.toggleAttribute(this.name, !!t2 && t2 !== A); } } class z extends H { constructor(t2, i2, s2, e2, h2) { super(t2, i2, s2, e2, h2), this.type = 5; } _$AI(t2, i2 = this) { if ((t2 = M(this, t2, i2, 0) ?? A) === E) return; const s2 = this._$AH, e2 = t2 === A && s2 !== A || t2.capture !== s2.capture || t2.once !== s2.once || t2.passive !== s2.passive, h2 = t2 !== A && (s2 === A || e2); e2 && this.element.removeEventListener(this.name, this, s2), h2 && this.element.addEventListener(this.name, this, t2), this._$AH = t2; } handleEvent(t2) { "function" == typeof this._$AH ? this._$AH.call(this.options?.host ?? this.element, t2) : this._$AH.handleEvent(t2); } } class Z { constructor(t2, i2, s2) { this.element = t2, this.type = 6, this._$AN = void 0, this._$AM = i2, this.options = s2; } get _$AU() { return this._$AM._$AU; } _$AI(t2) { M(this, t2); } } const B = t.litHtmlPolyfillSupport; B?.(S, k), (t.litHtmlVersions ??= []).push("3.3.2"); const D = (t2, i2, s2) => { const e2 = i2; let h2 = e2._$litPart$; if (void 0 === h2) { const t3 = null; e2._$litPart$ = h2 = new k(i2.insertBefore(c(), t3), t3, void 0, {}); } return h2._$AI(t2), h2; }; const mainScss = '.vot-button{--vot-helper-theme:var(--vot-theme-rgb,var(--vot-primary-rgb,33, 150, 243));--vot-helper-ontheme:var(--vot-ontheme-rgb,var(--vot-onprimary-rgb,255, 255, 255));box-sizing:border-box;vertical-align:middle;text-align:center;text-overflow:ellipsis;cursor:pointer;min-width:64px;height:36px;color:rgb(var(--vot-helper-ontheme));background-color:rgb(var(--vot-helper-theme));box-shadow:var(--vot-shadow-1);transition:box-shadow var(--vot-duration-medium) var(--vot-easing-standard);outline:none;font-size:14px;font-weight:500;line-height:36px;display:inline-block;position:relative;border-radius:var(--vot-radius-s)!important;padding:0 var(--vot-space-4)!important;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;border:none!important}.vot-button:before,.vot-button:after{content:"";opacity:0;position:absolute;inset:0;border-radius:inherit!important}.vot-button:before{background-color:rgb(var(--vot-helper-ontheme));transition:opacity var(--vot-duration-medium) var(--vot-easing-standard)}.vot-button:after{transition:opacity var(--vot-duration-slow) var(--vot-easing-standard),background-size var(--vot-duration-slow) var(--vot-easing-standard);background:radial-gradient(circle,currentColor 1%,#0000 1%) 50%/10000% 10000% no-repeat}.vot-button:hover:before{opacity:.08}.vot-button:active:after{opacity:.32;background-size:100% 100%;transition:background-size}.vot-button:hover,.vot-button:active{box-shadow:var(--vot-shadow-2)}.vot-button[disabled=true]{background-color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.12);color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38);box-shadow:none;cursor:initial}.vot-button[disabled=true]:before,.vot-button[disabled=true]:after{opacity:0}.vot-outlined-button{--vot-helper-theme:var(--vot-theme-rgb,var(--vot-primary-rgb,33, 150, 243));box-sizing:border-box;vertical-align:middle;text-align:center;text-overflow:ellipsis;cursor:pointer;min-width:64px;height:36px;color:rgb(var(--vot-helper-theme));background-color:#0000;outline:none;font-size:14px;font-weight:500;line-height:34px;display:inline-block;position:relative;border-radius:var(--vot-radius-s)!important;padding:0 var(--vot-space-4)!important;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;border:solid 1px var(--vot-border-color)!important;margin:0!important}.vot-outlined-button:before,.vot-outlined-button:after{content:"";opacity:0;position:absolute;inset:0;border-radius:inherit!important}.vot-outlined-button:before{background-color:rgb(var(--vot-helper-theme));transition:opacity var(--vot-duration-medium) var(--vot-easing-standard)}.vot-outlined-button:after{transition:opacity var(--vot-duration-slow) var(--vot-easing-standard),background-size var(--vot-duration-slow) var(--vot-easing-standard);background:radial-gradient(circle,currentColor 1%,#0000 1%) 50%/10000% 10000% no-repeat}.vot-outlined-button:hover:before{opacity:.04}.vot-outlined-button:active:after{opacity:.16;background-size:100% 100%;transition:background-size}.vot-outlined-button[disabled=true]{color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38);cursor:initial;background-color:#0000}.vot-outlined-button[disabled=true]:before,.vot-outlined-button[disabled=true]:after{opacity:0}.vot-text-button{--vot-helper-theme:var(--vot-theme-rgb,var(--vot-primary-rgb,33, 150, 243));box-sizing:border-box;vertical-align:middle;text-align:center;text-overflow:ellipsis;cursor:pointer;min-width:64px;height:36px;color:rgb(var(--vot-helper-theme));background-color:#0000;outline:none;font-size:14px;font-weight:500;line-height:36px;display:inline-block;position:relative;border-radius:var(--vot-radius-s)!important;padding:0 var(--vot-space-2)!important;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;border:none!important;margin:0!important}.vot-text-button:before,.vot-text-button:after{content:"";opacity:0;position:absolute;inset:0;border-radius:inherit!important}.vot-text-button:before{background-color:rgb(var(--vot-helper-theme));transition:opacity var(--vot-duration-medium) var(--vot-easing-standard)}.vot-text-button:after{transition:opacity var(--vot-duration-slow) var(--vot-easing-standard),background-size var(--vot-duration-slow) var(--vot-easing-standard);background:radial-gradient(circle,currentColor 1%,#0000 1%) 50%/10000% 10000% no-repeat}.vot-text-button:hover:before{opacity:.04}.vot-text-button:active:after{opacity:.16;background-size:100% 100%;transition:background-size}.vot-text-button[disabled=true]{color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38);cursor:initial;background-color:#0000}.vot-text-button[disabled=true]:before,.vot-text-button[disabled=true]:after{opacity:0}.vot-icon-button{--vot-helper-onsurface:rgba(var(--vot-onsurface-rgb,0, 0, 0), .87);box-sizing:border-box;vertical-align:middle;text-align:center;text-overflow:ellipsis;cursor:pointer;width:36px;min-width:36px;height:36px;fill:var(--vot-helper-onsurface);color:var(--vot-helper-onsurface);background-color:#0000;outline:none;font-size:14px;font-weight:500;line-height:36px;display:inline-block;position:relative;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;border:none!important;border-radius:50%!important;margin:0!important;padding:0!important}.vot-icon-button:before,.vot-icon-button:after{content:"";opacity:0;position:absolute;inset:0;border-radius:inherit!important}.vot-icon-button:before{background-color:var(--vot-helper-onsurface);transition:opacity var(--vot-duration-medium) var(--vot-easing-standard)}.vot-icon-button:after{transition:opacity var(--vot-duration-slow) var(--vot-easing-standard),background-size var(--vot-duration-slow) var(--vot-easing-standard);background:radial-gradient(circle,currentColor 1%,#0000 1%) 50%/10000% 10000% no-repeat}.vot-icon-button:hover:before{opacity:.04}.vot-icon-button:active:after{opacity:.32;background-size:100% 100%;transition:background-size}.vot-icon-button[disabled=true]{color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38);fill:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38);cursor:initial;background-color:#0000}.vot-icon-button[disabled=true]:before,.vot-icon-button[disabled=true]:after{opacity:0}.vot-icon-button svg{fill:inherit;stroke:inherit;width:24px;height:36px}.vot-hotkey{justify-content:flex-start;align-items:center;gap:var(--vot-space-3,12px);flex-wrap:wrap;display:flex}.vot-hotkey-label{word-break:break-word;max-width:80%}.vot-hotkey-button{--vot-helper-theme:var(--vot-theme-rgb,var(--vot-primary-rgb,33, 150, 243));box-sizing:border-box;vertical-align:middle;text-align:center;text-overflow:ellipsis;cursor:pointer;background-color:#0000;outline:none;width:fit-content;min-width:32px;height:fit-content;font-size:15px;font-weight:400;line-height:1.5;display:inline-block;position:relative;border-radius:var(--vot-radius-s)!important;padding:0 var(--vot-space-2)!important;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;border:solid 1px var(--vot-border-color)!important;margin:0!important}.vot-hotkey-button:before,.vot-hotkey-button:after{content:"";opacity:0;position:absolute;inset:0;border-radius:inherit!important}.vot-hotkey-button:before{background-color:rgb(var(--vot-helper-theme));transition:opacity var(--vot-duration-medium) var(--vot-easing-standard)}.vot-hotkey-button:after{transition:opacity var(--vot-duration-slow) var(--vot-easing-standard),background-size var(--vot-duration-slow) var(--vot-easing-standard);background:radial-gradient(circle,currentColor 1%,#0000 1%) 50%/10000% 10000% no-repeat}.vot-hotkey-button:hover:before{opacity:.04}.vot-hotkey-button:active:after{opacity:.16;background-size:100% 100%;transition:background-size}.vot-hotkey-button[data-status=active]{color:rgb(var(--vot-helper-theme))}.vot-hotkey-button[data-status=active]:before{opacity:.04}.vot-hotkey-button[disabled=true]{color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38);cursor:initial;background-color:#0000}.vot-hotkey-button[disabled=true]:before,.vot-hotkey-button[disabled=true]:after{opacity:0}.vot-textfield{display:inline-block;--vot-helper-theme:rgb(var(--vot-theme-rgb,var(--vot-primary-rgb,33, 150, 243)))!important;--vot-helper-safari1:rgba(var(--vot-onsurface-rgb,0, 0, 0), .38)!important;--vot-helper-safari2:rgba(var(--vot-onsurface-rgb,0, 0, 0), .6)!important;--vot-helper-safari3:rgba(var(--vot-onsurface-rgb,0, 0, 0), .87)!important;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;text-align:start!important;padding-top:6px!important;font-size:16px!important;line-height:1.5!important;position:relative!important}.vot-textfield>:is(input,textarea){box-sizing:border-box!important;border-style:solid!important;border-width:1px!important;border-color:transparent var(--vot-helper-safari2) var(--vot-helper-safari2)!important;width:100%!important;height:inherit!important;color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.87)!important;-webkit-text-fill-color:currentColor!important;font-family:inherit!important;font-size:inherit!important;line-height:inherit!important;caret-color:var(--vot-helper-theme)!important;background-color:#0000!important;border-radius:4px!important;margin:0!important;padding:15px 13px!important;transition:border .2s,box-shadow .2s!important;box-shadow:inset 1px 0 #0000,inset -1px 0 #0000,inset 0 -1px #0000!important}.vot-textfield>:is(input,textarea):not(:focus):not(:is(.vot-show-placeholder,.vot-show-placeholer))::placeholder{color:#0000!important}.vot-textfield>:is(input,textarea):not(:focus):placeholder-shown{border-top-color:var(--vot-helper-safari2)!important}.vot-textfield>:is(input,textarea)+span{font-family:inherit;width:100%!important;max-height:100%!important;color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.6)!important;cursor:text!important;pointer-events:none!important;font-size:75%!important;line-height:15px!important;transition:color .2s,font-size .2s,line-height .2s!important;display:flex!important;position:absolute!important;top:0!important;left:0!important}.vot-textfield>:is(input,textarea):not(:focus):placeholder-shown+span{font-size:inherit!important;line-height:68px!important}.vot-textfield>input+span:before,.vot-textfield>input+span:after,.vot-textfield>textarea+span:before,.vot-textfield>textarea+span:after{content:""!important;box-sizing:border-box!important;border-top:solid 1px var(--vot-helper-safari2)!important;pointer-events:none!important;min-width:10px!important;height:8px!important;margin-top:6px!important;transition:border .2s,box-shadow .2s!important;display:block!important;box-shadow:inset 0 1px #0000!important}.vot-textfield>input+span:before,.vot-textfield>textarea+span:before{border-left:1px solid #0000!important;border-radius:4px 0!important;margin-right:4px!important}.vot-textfield>input+span:after,.vot-textfield>textarea+span:after{border-right:1px solid #0000!important;border-radius:0 4px!important;flex-grow:1!important;margin-left:4px!important}.vot-textfield>input:is(.vot-show-placeholder,.vot-show-placeholer)+span:before,.vot-textfield>textarea:is(.vot-show-placeholder,.vot-show-placeholer)+span:before{margin-right:0!important}.vot-textfield>input:is(.vot-show-placeholder,.vot-show-placeholer)+span:after,.vot-textfield>textarea:is(.vot-show-placeholder,.vot-show-placeholer)+span:after{margin-left:0!important}.vot-textfield>input:not(:focus):placeholder-shown+span:before,.vot-textfield>input:not(:focus):placeholder-shown+span:after,.vot-textfield>textarea:not(:focus):placeholder-shown+span:before,.vot-textfield>textarea:not(:focus):placeholder-shown+span:after{border-top-color:#0000!important}.vot-textfield:hover>input:not(:disabled),.vot-textfield:hover>textarea:not(:disabled){border-color:transparent var(--vot-helper-safari3) var(--vot-helper-safari3)!important}.vot-textfield:hover>input:not(:disabled)+span:before,.vot-textfield:hover>input:not(:disabled)+span:after,.vot-textfield:hover>textarea:not(:disabled)+span:before,.vot-textfield:hover>textarea:not(:disabled)+span:after{border-top-color:var(--vot-helper-safari3)!important}.vot-textfield:hover>input:not(:disabled):not(:focus):placeholder-shown,.vot-textfield:hover>textarea:not(:disabled):not(:focus):placeholder-shown{border-color:var(--vot-helper-safari3)!important}.vot-textfield>input:focus,.vot-textfield>textarea:focus{border-color:transparent var(--vot-helper-theme) var(--vot-helper-theme)!important;box-shadow:inset 1px 0 var(--vot-helper-theme),inset -1px 0 var(--vot-helper-theme),inset 0 -1px var(--vot-helper-theme)!important;outline:none!important}.vot-textfield>input:focus+span,.vot-textfield>textarea:focus+span{color:var(--vot-helper-theme)!important}.vot-textfield>input:focus+span:before,.vot-textfield>input:focus+span:after,.vot-textfield>textarea:focus+span:before,.vot-textfield>textarea:focus+span:after{border-top-color:var(--vot-helper-theme)!important;box-shadow:inset 0 1px var(--vot-helper-theme)!important}.vot-textfield>input:disabled,.vot-textfield>input:disabled+span,.vot-textfield>textarea:disabled,.vot-textfield>textarea:disabled+span{border-color:transparent var(--vot-helper-safari1) var(--vot-helper-safari1)!important;color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38)!important;pointer-events:none!important}.vot-textfield>input:disabled+span:before,.vot-textfield>input:disabled+span:after,.vot-textfield>textarea:disabled+span:before,.vot-textfield>textarea:disabled+span:after,.vot-textfield>input:disabled:placeholder-shown,.vot-textfield>input:disabled:placeholder-shown+span,.vot-textfield>textarea:disabled:placeholder-shown,.vot-textfield>textarea:disabled:placeholder-shown+span{border-top-color:var(--vot-helper-safari1)!important}.vot-textfield>input:disabled:placeholder-shown+span:before,.vot-textfield>input:disabled:placeholder-shown+span:after,.vot-textfield>textarea:disabled:placeholder-shown+span:before,.vot-textfield>textarea:disabled:placeholder-shown+span:after{border-top-color:#0000!important}@media not all and (min-resolution:.001dpcm){@supports ((-webkit-appearance:none)){.vot-textfield>input,.vot-textfield>input+span,.vot-textfield>textarea,.vot-textfield>textarea+span,.vot-textfield>input+span:before,.vot-textfield>input+span:after,.vot-textfield>textarea+span:before,.vot-textfield>textarea+span:after{transition-duration:.1s!important}}}.vot-checkbox{--vot-checkbox-label-offset:30px;--vot-helper-theme:var(--vot-theme-rgb,var(--vot-primary-rgb,33, 150, 243));--vot-helper-ontheme:var(--vot-ontheme-rgb,var(--vot-onprimary-rgb,255, 255, 255));z-index:0;color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.87);text-align:start;font-size:16px;line-height:1.5;display:inline-block;position:relative;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;text-transform:none!important}.vot-checkbox-sub{padding-left:var(--vot-checkbox-label-offset)!important}.vot-checkbox>input{appearance:none;z-index:10000;box-sizing:border-box;opacity:1;cursor:pointer;background:0 0;outline:none;width:18px;height:18px;transition:border-color .2s,background-color .2s;display:block;position:absolute;border:2px solid!important;border-color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.6)!important;border-radius:2px!important;margin:3px 1px!important;padding:0!important}.vot-checkbox>input+span{box-sizing:border-box;width:inherit;cursor:pointer;font-family:inherit;font-weight:400;display:inline-block;position:relative;padding-left:var(--vot-checkbox-label-offset)!important}.vot-checkbox>input+span:before{content:"";background-color:rgb(var(--vot-onsurface-rgb,0, 0, 0));opacity:0;pointer-events:none;width:40px;height:40px;transition:opacity .3s,transform .2s;display:block;position:absolute;top:-8px;left:-10px;transform:scale(1);border-radius:50%!important}.vot-checkbox>input+span:after{content:"";z-index:10000;pointer-events:none;width:10px;height:5px;transition:border-color .2s;display:block;position:absolute;top:3px;left:1px;transform:translate(3px,4px)rotate(-45deg);box-sizing:content-box!important;border:0 solid #0000!important;border-width:0 0 2px 2px!important}.vot-checkbox>input:checked,.vot-checkbox>input:indeterminate{background-color:rgb(var(--vot-helper-theme));border-color:rgb(var(--vot-helper-theme))!important}.vot-checkbox>input:checked+span:before,.vot-checkbox>input:indeterminate+span:before{background-color:rgb(var(--vot-helper-theme))}.vot-checkbox>input:checked+span:after,.vot-checkbox>input:indeterminate+span:after{border-color:rgb(var(--vot-helper-ontheme,255, 255, 255))!important}.vot-checkbox>input:hover{box-shadow:none!important}.vot-checkbox>input:indeterminate+span:after{transform:translate(4px,3px);border-left-width:0!important}.vot-checkbox:hover>input+span:before{opacity:.04}.vot-checkbox:active>input,.vot-checkbox:active:hover>input:not(:disabled){border-color:rgb(var(--vot-helper-theme))!important}.vot-checkbox:active>input:checked{background-color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.6);border-color:#0000!important}.vot-checkbox:active>input+span:before{opacity:1;transition:transform,opacity;transform:scale(0)}.vot-checkbox>input:disabled{cursor:initial;border-color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38)!important}.vot-checkbox>input:disabled:checked,.vot-checkbox>input:disabled:indeterminate{background-color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38);border-color:#0000!important}.vot-checkbox>input:disabled+span{color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38);cursor:initial}.vot-checkbox>input:disabled+span:before{opacity:0;transform:scale(0)}html.vot-keyboard-nav .vot-checkbox>input:focus-visible{box-shadow:var(--vot-focus-ring),var(--vot-focus-ring-offset)!important}@supports not selector(:focus-visible){html.vot-keyboard-nav .vot-checkbox>input:focus{box-shadow:var(--vot-focus-ring),var(--vot-focus-ring-offset)!important}}.vot-slider{flex-direction:column;gap:6px;display:flex;width:100%!important;color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.87)!important;font-family:var(--vot-font-family,"Roboto", "Segoe UI", BlinkMacSystemFont, system-ui, -apple-system)!important;text-align:start!important;font-size:16px!important;line-height:1.5!important}.vot-slider>span{order:1;margin:0!important;display:block!important}.vot-slider .vot-slider-label{flex-wrap:wrap;align-items:baseline;gap:6px;width:100%;display:inline-flex}.vot-slider-label-value{font-variant-numeric:tabular-nums;font-weight:500;margin-left:0!important}.vot-slider .vot-slider-label-text{min-width:0}.vot-slider>input{order:2;appearance:none!important;cursor:pointer!important;background-color:#0000!important;border:none!important;width:100%!important;height:32px!important;margin:0!important;padding:0!important;display:block!important;position:relative!important;top:0!important}.vot-slider>input:hover{box-shadow:none!important}.vot-slider>input:before{content:""!important;width:calc(100% * var(--vot-progress,0))!important;background:rgb(var(--vot-primary-rgb,33, 150, 243))!important;height:2px!important;display:block!important;position:absolute!important;top:calc(50% - 1px)!important}.vot-slider>input:disabled{cursor:default!important;opacity:.38!important}.vot-slider>input:disabled+span{color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38)!important}.vot-slider>input:disabled::-webkit-slider-runnable-track{background-color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38)!important}.vot-slider>input:disabled::-moz-range-track{background-color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38)!important}.vot-slider>input:disabled::-webkit-slider-thumb{background-color:rgb(var(--vot-onsurface-rgb,0, 0, 0))!important;box-shadow:0 0 0 1px rgb(var(--vot-surface-rgb,255, 255, 255))!important;transform:scale(4)!important}.vot-slider>input:disabled::-moz-range-thumb{background-color:rgb(var(--vot-onsurface-rgb,0, 0, 0))!important;box-shadow:0 0 0 1px rgb(var(--vot-surface-rgb,255, 255, 255))!important;transform:scale(4)!important}.vot-slider>input:disabled::-moz-range-progress{background-color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.87)!important}.vot-slider>input:focus{outline:none!important}.vot-slider>input::-webkit-slider-runnable-track{background-color:rgba(var(--vot-primary-rgb,33, 150, 243),.24)!important;border-radius:1px!important;width:100%!important;height:2px!important;margin:15px 0!important}.vot-slider>input::-moz-range-track{background-color:rgba(var(--vot-primary-rgb,33, 150, 243),.24)!important;border-radius:1px!important;width:100%!important;height:2px!important;margin:15px 0!important}.vot-slider>input::-webkit-slider-thumb{appearance:none!important;background-color:rgb(var(--vot-primary-rgb,33, 150, 243))!important;width:2px!important;height:2px!important;box-shadow:none!important;border:none!important;border-radius:50%!important;transition:box-shadow .2s!important;transform:scale(6)!important}.vot-slider>input::-moz-range-thumb{appearance:none!important;background-color:rgb(var(--vot-primary-rgb,33, 150, 243))!important;width:2px!important;height:2px!important;box-shadow:none!important;border:none!important;border-radius:50%!important;transition:box-shadow .2s!important;transform:scale(6)!important}.vot-slider>input::-webkit-slider-thumb{-webkit-appearance:none!important;margin:0!important}.vot-slider>input::-moz-range-progress{background-color:rgb(var(--vot-primary-rgb,33, 150, 243))!important;border-radius:1px!important;height:2px!important}.vot-slider>input:focus:not(:focus-visible)::-webkit-slider-thumb{box-shadow:none!important}.vot-slider>input:focus:not(:focus-visible)::-moz-range-thumb{box-shadow:none!important}html.vot-keyboard-nav .vot-slider>input:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 2px rgba(var(--vot-primary-rgb,33, 150, 243),.24)!important}html.vot-keyboard-nav .vot-slider>input:focus-visible::-moz-range-thumb{box-shadow:0 0 0 2px rgba(var(--vot-primary-rgb,33, 150, 243),.24)!important}@supports not selector(:focus-visible){html.vot-keyboard-nav .vot-slider>input:focus::-webkit-slider-thumb{box-shadow:0 0 0 2px rgba(var(--vot-primary-rgb,33, 150, 243),.24)!important}html.vot-keyboard-nav .vot-slider>input:focus::-moz-range-thumb{box-shadow:0 0 0 2px rgba(var(--vot-primary-rgb,33, 150, 243),.24)!important}}.vot-select{--vot-helper-theme-rgb:var(--vot-onsurface-rgb,0, 0, 0);--vot-helper-theme:rgba(var(--vot-helper-theme-rgb), .87);--vot-helper-safari1:rgba(var(--vot-onsurface-rgb,0, 0, 0), .6);--vot-helper-safari2:rgba(var(--vot-onsurface-rgb,0, 0, 0), .87);font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif);text-align:start;color:var(--vot-helper-theme);fill:var(--vot-helper-theme);justify-content:space-between;align-items:center;font-size:14px;font-weight:400;line-height:1.5;display:flex}.vot-select-outer{cursor:pointer;justify-content:space-between;align-items:center;width:120px;max-width:120px;display:flex;border:1px solid var(--vot-helper-safari1)!important;border-radius:4px!important;padding:0 5px!important;transition:border .2s!important}.vot-select-outer:hover{border-color:var(--vot-helper-safari2)!important}.vot-select-outer[disabled=true]{opacity:.5;cursor:default}.vot-select-outer[disabled=true]:hover{border-color:var(--vot-helper-safari1)!important}.vot-select-title{text-overflow:ellipsis;white-space:nowrap;font-family:inherit;overflow:hidden}.vot-select-arrow-icon{justify-content:center;align-items:center;width:20px;height:32px;display:flex}.vot-select-arrow-icon svg{fill:inherit;stroke:inherit}.vot-select-content-list{flex-direction:column;display:flex}.vot-select-content-list .vot-select-content-item{cursor:pointer;border-radius:8px!important;padding:5px 10px!important}.vot-select-content-list .vot-select-content-item:not([inert]):hover{background-color:#2a2c31}.vot-select-content-list .vot-select-content-item[data-vot-selected=true]{color:rgb(var(--vot-primary-rgb,33, 150, 243));background-color:rgba(var(--vot-primary-rgb,33, 150, 243),.2)}.vot-select-content-list .vot-select-content-item[data-vot-selected=true]:hover{background-color:rgba(var(--vot-primary-rgb,33, 150, 243),.1)!important}.vot-select-content-list .vot-select-content-item[inert]{cursor:default;color:rgba(var(--vot-onsurface-rgb,0, 0, 0),.38)}.vot-header{color:rgba(var(--vot-helper-onsurface-rgb),.87);font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif);text-align:start;font-weight:700;line-height:1.5}.vot-header:not(:first-child){padding-top:8px}.vot-header-level-1{font-size:2em}.vot-header-level-2{font-size:1.5em}.vot-header-level-3{font-size:1.17em}.vot-header-level-4{font-size:1em}.vot-header-level-5{font-size:.83em}.vot-header-level-6{font-size:.67em}.vot-info{color:rgba(var(--vot-helper-onsurface-rgb),.87);font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif);text-align:start;-webkit-user-select:text;user-select:text;font-size:16px;line-height:1.5;display:flex}.vot-info>:not(:first-child){color:rgba(var(--vot-helper-onsurface-rgb),.5);flex:1;margin-left:8px!important}.vot-details{color:rgba(var(--vot-helper-onsurface-rgb),.87);font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif);text-align:start;cursor:pointer;transition:background var(--vot-duration-medium) var(--vot-easing-standard);justify-content:space-between;align-items:center;font-size:16px;line-height:1.5;display:flex;border-radius:.5em!important;margin:-.5em!important;padding:.5em!important}.vot-details-arrow-icon{width:20px;height:32px;fill:rgba(var(--vot-helper-onsurface-rgb),.87);justify-content:center;align-items:center;display:flex;transform:scale(1.25)rotate(-90deg)}.vot-details:hover{background:rgba(var(--vot-onsurface-rgb,0, 0, 0),.06)}.vot-settings-section{border:1px solid var(--vot-border-color);border-radius:var(--vot-radius-l);padding:var(--vot-space-2);background:rgba(var(--vot-helper-onsurface-rgb),.03);flex-direction:column;display:flex}.vot-settings-section>*{margin:0!important}.vot-settings-section>*+*{margin-top:var(--vot-space-2)!important}.vot-settings-section-header{border-radius:var(--vot-radius-m);margin:0!important;padding:.45em .5em!important}.vot-settings-section-header .vot-details-arrow-icon{transition:transform var(--vot-duration-medium) var(--vot-easing-standard)}.vot-settings-section-header[data-open=true] .vot-details-arrow-icon{transform:scale(1.25)rotate(0)}.vot-settings-section-content{--vot-settings-control-width:200px;--vot-settings-row-gap:var(--vot-space-2);padding:0 var(--vot-space-1) var(--vot-space-1);flex-direction:column;display:flex}.vot-settings-section-content>*{margin:0!important}.vot-settings-section-content>*+*{margin-top:var(--vot-settings-row-gap)!important}.vot-settings-section-content>.vot-checkbox,.vot-settings-section-content>.vot-hotkey,.vot-settings-section-content>.vot-textfield,.vot-settings-section-content>.vot-select,.vot-settings-section-content>.vot-slider{padding:var(--vot-space-1);box-sizing:border-box;width:100%!important}.vot-settings-section-content>.vot-textfield{gap:var(--vot-space-1);flex-direction:column;padding-top:0!important;display:flex!important}.vot-settings-section-content>.vot-textfield>span{order:0;width:auto!important;max-height:none!important;color:rgba(var(--vot-helper-onsurface-rgb),.72)!important;cursor:default!important;pointer-events:none!important;font-size:13px!important;line-height:1.2!important;display:block!important;position:static!important}.vot-settings-section-content>.vot-textfield>span:before,.vot-settings-section-content>.vot-textfield>span:after{content:none!important;display:none!important}.vot-settings-section-content>.vot-textfield>input,.vot-settings-section-content>.vot-textfield>textarea{transition:border-color var(--vot-duration-fast) var(--vot-easing-standard),background-color var(--vot-duration-fast) var(--vot-easing-standard);order:1;width:100%!important;height:36px!important;padding:0 var(--vot-space-3)!important;border:1px solid var(--vot-border-color)!important;border-radius:var(--vot-radius-s)!important;background:rgba(var(--vot-helper-onsurface-rgb),.04)!important;color:rgba(var(--vot-helper-onsurface-rgb),.9)!important;-webkit-text-fill-color:currentColor!important;box-shadow:none!important}.vot-settings-section-content>.vot-textfield>textarea{resize:vertical;height:auto!important;min-height:84px!important;padding:var(--vot-space-2) var(--vot-space-3)!important}.vot-settings-section-content>.vot-textfield>input::placeholder,.vot-settings-section-content>.vot-textfield>textarea::placeholder{color:rgba(var(--vot-helper-onsurface-rgb),.55)!important}.vot-settings-section-content>.vot-textfield:hover>input,.vot-settings-section-content>.vot-textfield:hover>textarea{border-color:var(--vot-border-color-hover)!important}.vot-settings-section-content>.vot-textfield>input:not(:focus):placeholder-shown,.vot-settings-section-content>.vot-textfield>textarea:not(:focus):placeholder-shown{border-color:var(--vot-border-color)!important}.vot-settings-section-content>.vot-textfield>input:focus,.vot-settings-section-content>.vot-textfield>textarea:focus{border-color:rgba(var(--vot-primary-rgb),.7)!important}.vot-lang-select{--vot-helper-theme-rgb:var(--vot-onsurface-rgb,0, 0, 0);--vot-helper-theme:rgba(var(--vot-helper-theme-rgb), .87);color:var(--vot-helper-theme);fill:var(--vot-helper-theme);justify-content:space-between;align-items:center;display:flex}.vot-lang-select-icon{justify-content:center;align-items:center;width:32px;height:32px;display:flex}.vot-lang-select-icon svg{fill:inherit;stroke:inherit}.vot-segmented-button{--vot-helper-theme-rgb:var(--vot-onsurface-rgb,0, 0, 0);--vot-helper-theme:rgba(var(--vot-helper-theme-rgb), .87);-webkit-user-select:none;user-select:none;background:rgb(var(--vot-surface-rgb,255, 255, 255));max-width:100vw;height:36px;color:var(--vot-helper-theme);fill:var(--vot-helper-theme);cursor:default;transition:opacity var(--vot-duration-slow) var(--vot-easing-standard);z-index:2147483647;align-items:center;font-size:16px;line-height:1.5;display:flex;position:absolute;top:5rem;left:50%;overflow:hidden;transform:translate(-50%);opacity:1!important;pointer-events:auto!important;touch-action:none!important;border:1px solid var(--vot-border-color)!important;border-radius:var(--vot-radius-s)!important;box-shadow:var(--vot-shadow-1)!important;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important}.vot-segmented-button.vot-segmented-button--hidden{opacity:0!important;pointer-events:none!important}.vot-segmented-button *{box-sizing:border-box!important}.vot-segmented-button .vot-separator{background:rgba(var(--vot-helper-theme-rgb),.1);width:1px;height:50%}.vot-segmented-button .vot-segment,.vot-segmented-button .vot-segment-only-icon{height:100%;color:inherit;transition:background-color var(--vot-duration-fast) var(--vot-easing-standard);-webkit-tap-highlight-color:transparent;background-color:#0000;outline:none;justify-content:center;align-items:center;display:flex;position:relative;overflow:hidden;padding:0 var(--vot-space-2)!important;border:none!important}.vot-segmented-button .vot-segment:focus,.vot-segmented-button .vot-segment-only-icon:focus{box-shadow:inset 0 0 0 2px var(--vot-focus-ring-color);outline:none}.vot-segmented-button .vot-segment:focus:not(:focus-visible),.vot-segmented-button .vot-segment-only-icon:focus:not(:focus-visible){box-shadow:none}.vot-segmented-button .vot-segment:before,.vot-segmented-button .vot-segment-only-icon:before,.vot-segmented-button .vot-segment:after,.vot-segmented-button .vot-segment-only-icon:after{content:"";opacity:0;position:absolute;inset:0;border-radius:inherit!important}.vot-segmented-button .vot-segment:before,.vot-segmented-button .vot-segment-only-icon:before{background-color:rgb(var(--vot-helper-theme-rgb));transition:opacity var(--vot-duration-medium) var(--vot-easing-standard)}.vot-segmented-button .vot-segment:after,.vot-segmented-button .vot-segment-only-icon:after{transition:opacity var(--vot-duration-slow) var(--vot-easing-standard),background-size var(--vot-duration-slow) var(--vot-easing-standard);background:radial-gradient(circle,currentColor 1%,#0000 1%) 50%/10000% 10000% no-repeat}.vot-segmented-button .vot-segment:hover:before,.vot-segmented-button .vot-segment-only-icon:hover:before{opacity:.04}.vot-segmented-button .vot-segment:active:after,.vot-segmented-button .vot-segment-only-icon:active:after{opacity:.16;background-size:100% 100%;transition:background-size}.vot-segmented-button .vot-segment-only-icon{min-width:36px;padding:0!important}.vot-segmented-button .vot-segment-label{white-space:nowrap;color:inherit;font-weight:400;margin-left:var(--vot-space-2)!important}.vot-segmented-button[data-status=success] .vot-translate-button{color:rgb(var(--vot-primary-rgb,33, 150, 243));fill:rgb(var(--vot-primary-rgb,33, 150, 243))}.vot-segmented-button[data-status=error] .vot-translate-button{color:#f28b82;fill:#f28b82}.vot-segmented-button[data-loading=true] #vot-loading-icon{display:block!important}.vot-segmented-button[data-loading=true] #vot-translate-icon{display:none!important}.vot-segmented-button[data-direction=column]{flex-direction:column;height:fit-content}.vot-segmented-button[data-direction=column] .vot-segment-label{display:none}.vot-segmented-button[data-direction=column]>.vot-segment-only-icon,.vot-segmented-button[data-direction=column]>.vot-segment{padding:8px!important}.vot-segmented-button[data-direction=column] .vot-separator{width:50%;height:1px}.vot-segmented-button[data-position=left]{top:12.5vh;left:50px}.vot-segmented-button[data-position=right]{top:12.5vh;left:auto;right:0}.vot-segmented-button svg{width:24px;fill:inherit;stroke:inherit}.vot-tooltip{--vot-helper-theme-rgb:var(--vot-onsurface-rgb,0, 0, 0);--vot-helper-theme:rgba(var(--vot-helper-theme-rgb), .87);--vot-helper-ondialog:rgb(var(--vot-ondialog-rgb,37, 38, 40));--vot-helper-border:rgb(var(--vot-tooltip-border,69, 69, 69));-webkit-user-select:none;user-select:none;background:rgb(var(--vot-surface-rgb,255, 255, 255));color:var(--vot-helper-theme);fill:var(--vot-helper-theme);font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif);cursor:default;z-index:2147483647;opacity:0;align-items:center;width:max-content;max-width:calc(100vw - 10px);height:max-content;font-size:14px;line-height:1.5;transition:opacity .5s;display:flex;position:absolute;inset:0;overflow:hidden;box-shadow:0 1px 3px #0000001f;border-radius:4px!important;padding:4px 8px!important}.vot-tooltip[data-trigger=click]{-webkit-user-select:text;user-select:text}.vot-tooltip.vot-tooltip-bordered{border:1px solid var(--vot-helper-border)}.vot-tooltip *{box-sizing:border-box!important}.vot-menu{--vot-helper-surface-rgb:var(--vot-surface-rgb,255, 255, 255);--vot-helper-surface:rgb(var(--vot-helper-surface-rgb));--vot-helper-onsurface-rgb:var(--vot-onsurface-rgb,0, 0, 0);--vot-helper-onsurface:rgba(var(--vot-helper-onsurface-rgb), .87);--vot-settings-control-width:clamp(120px, 45%, 200px);-webkit-user-select:none;user-select:none;background-color:var(--vot-helper-surface);color:var(--vot-helper-onsurface);cursor:default;z-index:2147483646;visibility:visible;opacity:1;transform-origin:top;width:fit-content;min-width:320px;max-width:min(90vw,560px);transition:opacity var(--vot-duration-medium) var(--vot-easing-standard),transform var(--vot-duration-medium) var(--vot-easing-standard);font-size:16px;line-height:1.5;position:absolute;top:calc(5rem + 48px);left:50%;overflow:hidden;transform:translate(-50%)scale(1);border:1px solid var(--vot-border-color)!important;border-radius:var(--vot-radius-m)!important;box-shadow:var(--vot-shadow-2)!important;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important}.vot-menu *{box-sizing:border-box!important}.vot-menu[hidden]{pointer-events:none;visibility:hidden;opacity:0;transform:translate(-50%,-4px)scale(.98);display:block!important}.vot-menu-content-wrapper{min-width:320px;min-height:100px;max-height:calc(var(--vot-container-height,75vh) - (5rem + 32px + 16px) * 2);flex-direction:column;display:flex;overflow:auto}.vot-menu-header-container{flex-shrink:0;align-items:center;min-height:31px;display:flex;padding-inline-end:var(--vot-space-2)!important}.vot-menu-header-container:empty{padding:0 0 16px!important}.vot-menu-header-container>.vot-icon-button{margin-inline-end:var(--vot-space-1)!important;margin-top:var(--vot-space-1)!important}.vot-menu-title-container{font-size:inherit;font-weight:inherit;text-align:start;outline:0;flex:1;display:flex;margin:0!important}.vot-menu-title{flex:1;font-size:16px;font-weight:500;line-height:1;padding:var(--vot-space-4)!important}.vot-menu-body-container{box-sizing:border-box;gap:var(--vot-space-2);overscroll-behavior:contain;flex-direction:column;min-height:1.375rem;display:flex;overflow:auto;padding:0 var(--vot-space-4)!important;scrollbar-color:rgba(var(--vot-helper-onsurface-rgb),.1) var(--vot-helper-surface)!important}.vot-menu-body-container::-webkit-scrollbar{background:var(--vot-helper-surface)!important;width:12px!important;height:12px!important}.vot-menu-body-container::-webkit-scrollbar-track{background:var(--vot-helper-surface)!important;width:12px!important;height:12px!important}.vot-menu-body-container::-webkit-scrollbar-thumb{border-radius:1ex;background:rgba(var(--vot-helper-onsurface-rgb),.1)!important;border:5px solid var(--vot-helper-surface)!important}.vot-menu-body-container::-webkit-scrollbar-thumb:hover{border-width:3px!important}.vot-menu-body-container::-webkit-scrollbar-corner{background:var(--vot-helper-surface)!important}.vot-menu-footer-container{flex-shrink:0;justify-content:flex-end;display:flex;padding:var(--vot-space-4)!important}.vot-menu-footer-container:empty{padding:var(--vot-space-4) 0 0 0!important}.vot-menu .vot-select--labeled>.vot-select-outer{margin-left:auto}.vot-menu[data-position=left]{transform-origin:0;top:12.5vh;left:240px}.vot-menu[data-position=right]{transform-origin:100%;top:12.5vh;left:auto;right:-80px}.vot-dialog{--vot-helper-surface-rgb:var(--vot-surface-rgb,255, 255, 255);--vot-helper-surface:rgb(var(--vot-helper-surface-rgb));--vot-helper-onsurface-rgb:var(--vot-onsurface-rgb,0, 0, 0);--vot-helper-onsurface:rgba(var(--vot-helper-onsurface-rgb), .87);--vot-dialog-viewport-margin:16px;--vot-dialog-max-height:75vh;max-width:initial;max-height:initial;width:min(var(--vot-dialog-width,512px),100%);border:1px solid var(--vot-border-color);border-radius:var(--vot-radius-l);background-color:var(--vot-helper-surface);height:fit-content;color:var(--vot-helper-onsurface);box-shadow:var(--vot-shadow-2);-webkit-user-select:none;user-select:none;visibility:visible;opacity:1;transform-origin:50%;transition:opacity var(--vot-duration-medium) var(--vot-easing-standard),transform var(--vot-duration-medium) var(--vot-easing-standard);font-size:16px;line-height:1.5;display:block;position:fixed;inset-block:0;inset-inline:0;overflow:auto hidden;transform:scale(1);font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;margin:auto!important;padding:0!important}[hidden]>.vot-dialog{pointer-events:none;opacity:0;transition:opacity var(--vot-duration-fast) var(--vot-easing-standard),transform var(--vot-duration-medium) var(--vot-easing-standard);transform:translateY(-4px)scale(.98)}.vot-dialog[data-vertical-align=top]{inset-block-start:var(--vot-dialog-viewport-margin);inset-block-end:auto;margin:0 auto!important}.vot-dialog-container{visibility:visible;z-index:2147483647;position:absolute}.vot-dialog-container[hidden]{pointer-events:none;visibility:hidden;display:block!important}.vot-dialog-container *{box-sizing:border-box!important}.vot-dialog-backdrop{opacity:1;background-color:#0009;transition:opacity .3s;position:fixed;inset:0}[hidden]>.vot-dialog-backdrop{pointer-events:none;opacity:0}.vot-dialog-content-wrapper{max-height:var(--vot-dialog-max-height,75vh);flex-direction:column;display:flex;overflow:auto}.vot-dialog-header-container{flex-shrink:0;align-items:flex-start;min-height:31px;display:flex}.vot-dialog-header-container:empty{padding:0 0 20px}.vot-dialog-header-container>.vot-icon-button{margin-inline-end:var(--vot-space-1)!important;margin-top:var(--vot-space-1)!important}.vot-dialog-title-container{font-size:inherit;font-weight:inherit;outline:0;flex:1;display:flex;margin:0!important}.vot-dialog-title{flex:1;font-size:115.385%;font-weight:700;line-height:1;padding:var(--vot-space-5) var(--vot-space-5) var(--vot-space-4)!important}.vot-dialog-body-container{box-sizing:border-box;gap:var(--vot-space-4);overscroll-behavior:contain;flex-direction:column;min-height:1.375rem;display:flex;overflow:auto;padding:0 var(--vot-space-5)!important;scrollbar-color:rgba(var(--vot-helper-onsurface-rgb),.1) var(--vot-helper-surface)!important}.vot-dialog-body-container::-webkit-scrollbar{background:var(--vot-helper-surface)!important;width:12px!important;height:12px!important}.vot-dialog-body-container::-webkit-scrollbar-track{background:var(--vot-helper-surface)!important;width:12px!important;height:12px!important}.vot-dialog-body-container::-webkit-scrollbar-thumb{border-radius:1ex;background:rgba(var(--vot-helper-onsurface-rgb),.1)!important;border:5px solid var(--vot-helper-surface)!important}.vot-dialog-body-container::-webkit-scrollbar-thumb:hover{border-width:3px!important}.vot-dialog-body-container::-webkit-scrollbar-corner{background:var(--vot-helper-surface)!important}.vot-dialog-footer-container{justify-content:flex-end;gap:var(--vot-space-2);flex-wrap:wrap;flex-shrink:0;display:flex;padding:var(--vot-space-4)!important}.vot-dialog-footer-container:empty{padding:var(--vot-space-5) 0 0 0!important}@media(max-width:480px){.vot-dialog-footer-container{flex-direction:column;align-items:stretch}.vot-dialog-footer-container>:is(.vot-button,.vot-outlined-button,.vot-text-button){white-space:normal;text-overflow:clip;text-align:center;justify-content:center;align-items:center;width:100%;height:auto;min-height:36px;padding:8px 16px;line-height:1.2;display:flex;overflow:visible}}.vot-inline-loader{aspect-ratio:5;--vot-loader-bg:no-repeat radial-gradient(farthest-side, rgba(var(--vot-onsurface-rgb,0, 0, 0), .38) 94%, transparent);background:var(--vot-loader-bg),var(--vot-loader-bg),var(--vot-loader-bg),var(--vot-loader-bg);background-size:20% 100%;height:8px;animation:.75s infinite alternate dotsSlide,1.5s infinite alternate dotsFlip}.vot-loader-progress{--vot-helper-theme:var(--vot-theme-rgb,var(--vot-primary-rgb,33, 150, 243));fill:none;stroke:rgb(var(--vot-helper-theme));stroke-width:2px;stroke-linecap:round;transform-origin:50%;transform:rotate(-90deg)}@keyframes dotsSlide{0%,10%{background-position:0 0,0 0,0 0,0 0}33%{background-position:0 0,33.3333% 0,33.3333% 0,33.3333% 0}66%{background-position:0 0,33.3333% 0,66.6667% 0,66.6667% 0}90%,to{background-position:0 0,33.3333% 0,66.6667% 0,100% 0}}@keyframes dotsFlip{0%,49.99%{transform:scale(1)}50%,to{transform:scale(-1)}}.vot-label{font-family:inherit;font-size:16px;line-height:1.5;display:block}.vot-label-text{display:inline}.vot-label-icon{vertical-align:text-bottom;cursor:help;justify-content:center;align-items:center;width:20px;height:20px;margin-left:4px;display:inline-flex}.vot-label-icon>svg{width:20px;height:20px;display:block}.vot-account{justify-content:space-between;align-items:center;gap:1rem;display:flex}.vot-account-container,.vot-account-wrapper,.vot-account-buttons{align-items:center;gap:1rem;display:flex}.vot-account-avatar{min-width:36px;max-width:36px;min-height:36px;max-height:36px;overflow:hidden}.vot-account-avatar-img{object-fit:cover;border-radius:50%;width:36px;height:36px}.vot-subtitles{--vot-subtitles-background:rgba(var(--vot-surface-rgb,46, 47, 52), var(--vot-subtitles-opacity,.8));max-width:var(--vot-subtitles-max-width,70vw);background:var(--vot-subtitles-background,#2e2f34cc);width:max-content;color:var(--vot-subtitles-color,#e3e3e3);pointer-events:all;touch-action:none;font-size:calc(var(--vot-subtitles-font-size,clamp(18px, 2.2vw, 36px)) * var(--vot-subtitles-scale-compensation,1));text-shadow:var(--vot-subtitles-text-shadow,0 1px 2px #0009, 0 2px 8px #00000059);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-synthesis:none;position:relative;--vot-subtitles-font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;font-family:var(--vot-subtitles-font-family)!important;font-style:normal!important;font-weight:var(--vot-subtitles-font-weight,500)!important;text-transform:none!important;letter-spacing:normal!important;border-radius:.5em!important;padding:.5em .75em!important;line-height:1.25!important}.vot-subtitles,.vot-subtitles *{font-family:var(--vot-subtitles-font-family)!important}.vot-subtitles{box-sizing:border-box;-webkit-user-select:none;user-select:none;contain:layout paint;isolation:isolate;text-align:center;margin:0 auto;display:block}.vot-subtitles.vot-subtitles--multiline{text-align:center}.vot-subtitles{text-wrap:wrap;white-space:normal;overflow-wrap:break-word}.vot-subtitles-widget{box-sizing:border-box;z-index:2147483647;--vot-subtitles-fallback-bottom-inset: calc(env(safe-area-inset-bottom,0px) + clamp(56px, 10vh, 220px) + 10px) ;left:50%;top:calc(100% - var(--vot-subtitles-fallback-bottom-inset));width:max-content;max-width:var(--vot-subtitles-max-width,70vw);pointer-events:none;will-change:left,top,transform;max-height:100%;display:block;position:absolute;transform:translate(-50%,-100%)}.vot-subtitles-info{flex-direction:column;gap:2px;display:flex;padding:6px!important}.vot-subtitles-info-service{color:var(--vot-subtitles-context-color,#86919b);margin-bottom:8px!important;font-size:10px!important;line-height:1!important}.vot-subtitles-info-header{color:var(--vot-subtitles-header-color,#fff);margin-bottom:6px!important;font-size:20px!important;font-weight:500!important;line-height:1!important}.vot-subtitles-info-context{color:var(--vot-subtitles-context-color,#86919b);font-size:12px!important;line-height:1.2!important}.vot-subtitles span{cursor:pointer;white-space:normal;overflow-wrap:inherit;word-break:normal;position:relative;font-size:inherit!important;font-family:inherit!important;font-style:inherit!important;font-weight:inherit!important;line-height:inherit!important;text-transform:inherit!important;text-decoration:none!important}.vot-subtitles span.passed{color:var(--vot-subtitles-passed-color,#2196f3)}.vot-subtitles span:before{content:"";z-index:-1;width:100%;height:100%;position:absolute;inset:2px -2px;border-radius:4px!important;padding:0 2px!important}.vot-subtitles span:hover:before{background:var(--vot-subtitles-hover-color,#ffffff8c)}.vot-subtitles span.selected:before{background:var(--vot-subtitles-passed-color,#2196f3)}:-webkit-any(:-webkit-full-screen .vot-subtitles,:-webkit-full-screen .vot-subtitles){max-width:var(--vot-subtitles-max-width,80vw);font-size:calc(var(--vot-subtitles-font-size,clamp(18px, 2vw, 34px)) * var(--vot-subtitles-fullscreen-scale,1) * .95 * var(--vot-subtitles-scale-compensation,1))}:is(:fullscreen .vot-subtitles){max-width:var(--vot-subtitles-max-width,80vw);font-size:calc(var(--vot-subtitles-font-size,clamp(18px, 2vw, 34px)) * var(--vot-subtitles-fullscreen-scale,1) * .95 * var(--vot-subtitles-scale-compensation,1))}#vot-subtitles-info.vot-subtitles-info *{-webkit-user-select:text!important;user-select:text!important}:root{--vot-font-family:"Roboto", "Segoe UI", system-ui, sans-serif;--vot-primary-rgb:139, 180, 245;--vot-onprimary-rgb:32, 33, 36;--vot-surface-rgb:32, 33, 36;--vot-onsurface-rgb:227, 227, 227;--vot-subtitles-color:rgb(var(--vot-onsurface-rgb,227, 227, 227));--vot-subtitles-passed-color:rgb(var(--vot-primary-rgb,33, 150, 243));--vot-space-1:4px;--vot-space-2:8px;--vot-space-3:12px;--vot-space-4:16px;--vot-space-5:20px;--vot-space-6:24px;--vot-radius-xs:6px;--vot-radius-s:10px;--vot-radius-m:14px;--vot-radius-l:18px;--vot-border-color:rgba(var(--vot-onsurface-rgb,227, 227, 227), .14);--vot-border-color-hover:rgba(var(--vot-onsurface-rgb,227, 227, 227), .22);--vot-shadow-1:0 1px 2px #0000002e, 0 8px 24px #00000024;--vot-shadow-2:0 2px 4px #00000038, 0 12px 32px #00000038;--vot-duration-fast:.12s;--vot-duration-medium:.2s;--vot-duration-slow:.32s;--vot-easing-standard:cubic-bezier(.4, 0, .2, 1);--vot-focus-ring-color:rgba(var(--vot-primary-rgb,139, 180, 245), .9);--vot-focus-ring:0 0 0 2px var(--vot-focus-ring-color);--vot-focus-ring-offset:0 0 0 4px rgba(var(--vot-surface-rgb,32, 33, 36), .9)}vot-block,vot-block *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}vot-block[hidden]:not(.vot-menu):not(.vot-dialog-container),vot-block [hidden]:not(.vot-menu):not(.vot-dialog-container){display:none!important}vot-block{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizelegibility;-moz-text-size-adjust:100%;text-size-adjust:100%;display:block;--vot-font-family:"Roboto", "Segoe UI", system-ui, sans-serif!important;font-family:var(--vot-font-family,"Roboto", "Segoe UI", system-ui, sans-serif)!important;visibility:visible!important}.vot-portal-local,.vot-subtitles-widget{isolation:isolate}vot-block:focus,vot-block :focus{box-shadow:none!important;outline:none!important}html.vot-keyboard-nav vot-block:focus-visible,html.vot-keyboard-nav vot-block :focus-visible{box-shadow:var(--vot-focus-ring),var(--vot-focus-ring-offset)!important}@supports not selector(:focus-visible){html.vot-keyboard-nav vot-block:focus,html.vot-keyboard-nav vot-block :focus{box-shadow:var(--vot-focus-ring),var(--vot-focus-ring-offset)!important}}@media(prefers-reduced-motion:reduce){.vot-portal-local *,.vot-portal *,.vot-subtitles-widget *{scroll-behavior:auto!important;transition-duration:.001ms!important;animation-duration:.001ms!important;animation-iteration-count:1!important}}.vot-portal{display:inline}.vot-portal-local{z-index:2147483647;position:fixed;top:0;left:0}'; importCSS(mainScss); function initKeyboardNavigationMode() { if (globalThis.__votKeyboardNavInitialized) return; globalThis.__votKeyboardNavInitialized = true; const root = document.documentElement; const CLASS = "vot-keyboard-nav"; const enable = () => root.classList.add(CLASS); const disable = () => root.classList.remove(CLASS); globalThis.addEventListener( "keydown", (e2) => { if (e2.key === "Tab") enable(); }, true ); for (const evt of ["pointerdown", "mousedown", "touchstart"]) { globalThis.addEventListener(evt, disable, { capture: true, passive: true }); } } initKeyboardNavigationMode(); const UI = { makeButtonLike(el, { ariaLabel } = {}) { el.setAttribute("role", "button"); if (!el.hasAttribute("tabindex")) { el.tabIndex = 0; } const enabledTabIndex = el.tabIndex; const syncDisabledState = () => { const isDisabled = el.getAttribute("disabled") === "true"; if (isDisabled) { el.setAttribute("aria-disabled", "true"); el.tabIndex = -1; } else { el.removeAttribute("aria-disabled"); el.tabIndex = enabledTabIndex; } }; syncDisabledState(); new MutationObserver(() => syncDisabledState()).observe(el, { attributes: true, attributeFilter: ["disabled"] }); if (ariaLabel) { el.setAttribute("aria-label", ariaLabel); } el.addEventListener("keydown", (e2) => { const disabled = el.getAttribute("disabled") === "true" || el.getAttribute("aria-disabled") === "true"; if (disabled) return; if (e2.key === "Enter" || e2.key === " ") { e2.preventDefault(); el.click(); } }); return el; }, createEl(tag, classes = [], content = null) { const el = document.createElement(tag); if (classes.length) el.classList.add(...classes); if (content !== null) el.append(content); return el; }, createHeader(html, level = 4) { return UI.createEl( "vot-block", ["vot-header", `vot-header-level-${level}`], html ); }, createInformation(labelHtml, valueHtml) { const container = UI.createEl("vot-block", ["vot-info"]); const header = UI.createEl("vot-block"); D(labelHtml, header); const value = UI.createEl("vot-block"); D(valueHtml, value); container.append(header, value); return { container, header, value }; }, createButton(html) { const el = UI.createEl("vot-block", ["vot-button"], html); return UI.makeButtonLike(el); }, createTextButton(html) { const el = UI.createEl("vot-block", ["vot-text-button"], html); return UI.makeButtonLike(el); }, createOutlinedButton(html) { const el = UI.createEl("vot-block", ["vot-outlined-button"], html); return UI.makeButtonLike(el); }, createIconButton(templateHtml, options = {}) { const button = UI.createEl("vot-block", ["vot-icon-button"]); D(templateHtml, button); return UI.makeButtonLike(button, options); }, createInlineLoader() { return UI.createEl("vot-block", ["vot-inline-loader"]); }, createPortal(local = false) { return UI.createEl("vot-block", [`vot-portal${local ? "-local" : ""}`]); }, createSubtitleInfo(word, desc, translationService) { const container = UI.createEl("vot-block", ["vot-subtitles-info"]); container.id = "vot-subtitles-info"; const translatedWith = UI.createEl( "vot-block", ["vot-subtitles-info-service"], localizationProvider.get("VOTTranslatedBy").replace("{0}", translationService) ); const header = UI.createEl( "vot-block", ["vot-subtitles-info-header"], word ); const context = UI.createEl( "vot-block", ["vot-subtitles-info-context"], desc ); container.append(translatedWith, header, context); return { container, translatedWith, header, context }; } }; const positions$1 = ["left", "top", "right", "bottom"]; const triggers = ["hover", "click"]; function addComponentEventListener(events, type, listener) { events[type].addListener(listener); } function removeComponentEventListener(events, type, listener) { events[type].removeListener(listener); } function setHiddenState(element, isHidden) { element.hidden = isHidden; } function getHiddenState(element) { return element.hidden; } class Tooltip { showed = false; target; anchor; content; position; preferredPosition; trigger; parentElement; layoutRoot; offsetX; offsetY; _hidden; autoLayout; pageWidth; pageHeight; globalOffsetX; globalOffsetY; maxWidth; backgroundColor; borderRadius; _bordered; container; onResizeObserver; intersectionObserver; scrollListening = false; positionRafId = null; destroyFallbackTimerId; static DESTROY_FALLBACK_MS = 700; tooltipId = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `vot-tooltip-${Math.random().toString(36).slice(2)}`; prevAriaDescribedBy = null; constructor({ target, anchor = void 0, content = "", position: position2 = "top", trigger = "hover", offset = 4, maxWidth = void 0, hidden = false, autoLayout = true, backgroundColor = void 0, borderRadius = void 0, bordered = true, parentElement = document.body, layoutRoot = document.documentElement }) { if (!(target instanceof HTMLElement)) { throw new TypeError("target must be a valid HTMLElement"); } this.target = target; this.anchor = anchor instanceof HTMLElement ? anchor : target; this.content = content; if (typeof offset === "number") { this.offsetY = this.offsetX = offset; } else { this.offsetX = offset.x; this.offsetY = offset.y; } this._hidden = hidden; this.autoLayout = autoLayout; this.trigger = Tooltip.validateTrigger(trigger) ? trigger : "hover"; this.position = Tooltip.validatePos(position2) ? position2 : "top"; this.preferredPosition = this.position; this.parentElement = parentElement; this.layoutRoot = layoutRoot; this.borderRadius = borderRadius; this._bordered = bordered; this.maxWidth = maxWidth; this.backgroundColor = backgroundColor; this.updatePageSize(); this.init(); } static validatePos(position2) { return positions$1.includes(position2); } static validateTrigger(trigger) { return triggers.includes(trigger); } setPosition(position2) { this.preferredPosition = Tooltip.validatePos(position2) ? position2 : "top"; this.position = this.preferredPosition; this.schedulePositionUpdate(); return this; } setContent(content) { this.content = content; if (this.container) { this.container.replaceChildren(); if (typeof content === "string") { this.container.textContent = content; } else { this.container.append(content); } this.schedulePositionUpdate(); return this; } return this; } updateMount({ parentElement, layoutRoot }) { if (parentElement && this.parentElement !== parentElement) { this.parentElement = parentElement; if (this.container?.isConnected) { parentElement.appendChild(this.container); } } if (layoutRoot && this.layoutRoot !== layoutRoot) { this.layoutRoot = layoutRoot; } this.schedulePositionUpdate(); return this; } onResize = () => { this.schedulePositionUpdate(); }; onClick = () => { this.showed ? this.destroy() : this.create(); }; onScroll = () => { this.schedulePositionUpdate(); }; onHoverPointerDown = (e2) => { if (e2.pointerType === "mouse") { return; } this.create(); }; onHoverPointerUp = (e2) => { if (e2.pointerType === "mouse") { return; } this.destroy(); }; onMouseEnter = () => { this.create(); }; onMouseLeave = () => { this.destroy(); }; updatePageSize() { if (this.layoutRoot === document.documentElement) { this.globalOffsetX = 0; this.globalOffsetY = 0; } else { const { left, top } = this.parentElement.getBoundingClientRect(); this.globalOffsetX = left; this.globalOffsetY = top; } this.pageWidth = (this.layoutRoot.clientWidth || document.documentElement.clientWidth) + (globalThis.scrollX ?? globalThis.pageXOffset ?? 0); this.pageHeight = (this.layoutRoot.clientHeight || document.documentElement.clientHeight) + (globalThis.scrollY ?? globalThis.pageYOffset ?? 0); return this; } onIntersect = ([entry]) => { if (!entry.isIntersecting) { return this.destroy(true); } }; init() { this.onResizeObserver = new ResizeObserver(this.onResize); this.intersectionObserver = new IntersectionObserver(this.onIntersect); if (this.trigger === "click") { this.target.addEventListener("pointerdown", this.onClick); return this; } this.target.addEventListener("mouseenter", this.onMouseEnter); this.target.addEventListener("mouseleave", this.onMouseLeave); this.target.addEventListener("focusin", this.onMouseEnter); this.target.addEventListener("focusout", this.onMouseLeave); this.target.addEventListener("pointerdown", this.onHoverPointerDown); this.target.addEventListener("pointerup", this.onHoverPointerUp); return this; } release() { this.destroy(); this.detachScrollListener(); if (this.trigger === "click") { this.target.removeEventListener("pointerdown", this.onClick); return this; } this.target.removeEventListener("mouseenter", this.onMouseEnter); this.target.removeEventListener("mouseleave", this.onMouseLeave); this.target.removeEventListener("focusin", this.onMouseEnter); this.target.removeEventListener("focusout", this.onMouseLeave); this.target.removeEventListener("pointerdown", this.onHoverPointerDown); this.target.removeEventListener("pointerup", this.onHoverPointerUp); return this; } schedulePositionUpdate() { if (this.positionRafId !== null) { return; } this.positionRafId = requestAnimationFrame(() => { this.positionRafId = null; this.updatePageSize(); this.updatePos(); }); } cancelPositionUpdate() { if (this.positionRafId === null) { return; } cancelAnimationFrame(this.positionRafId); this.positionRafId = null; } clearDestroyFallbackTimer() { if (this.destroyFallbackTimerId === void 0) { return; } globalThis.clearTimeout(this.destroyFallbackTimerId); this.destroyFallbackTimerId = void 0; } create() { this.destroy(true); this.showed = true; this.clearDestroyFallbackTimer(); this.container = UI.createEl("vot-block", ["vot-tooltip"], this.content); if (this.bordered) { this.container.classList.add("vot-tooltip-bordered"); } this.container.setAttribute("role", "tooltip"); this.container.id = this.tooltipId; this.container.dataset.trigger = this.trigger; this.container.dataset.position = this.position; this.parentElement.appendChild(this.container); this.schedulePositionUpdate(); if (this.backgroundColor !== void 0) { this.container.style.backgroundColor = this.backgroundColor; } if (this.borderRadius !== void 0) { this.container.style.borderRadius = `${this.borderRadius}px`; } if (this.hidden) { this.container.hidden = true; } else { this.syncAriaDescribedBy(true); } this.container.style.opacity = "1"; this.attachScrollListener(); this.onResizeObserver?.observe(this.layoutRoot); this.onResizeObserver?.observe(this.anchor); this.intersectionObserver?.observe(this.target); return this; } updatePos() { if (!this.container) { return this; } const { top, left } = this.calcPos(this.autoLayout, this.preferredPosition); const availableWidth = this.pageWidth - this.offsetX * 2; const maxWidth = this.maxWidth ?? Math.min( availableWidth, this.pageWidth - Math.min(left, this.pageWidth - availableWidth) ); this.container.style.transform = `translate(${left}px, ${top}px)`; this.container.dataset.position = this.position; this.container.style.maxWidth = `${maxWidth}px`; return this; } calcPos(autoLayout = true, position2 = this.preferredPosition) { if (!this.container) { return { top: 0, left: 0 }; } const { left: anchorLeft, right: anchorRight, top: anchorTop, bottom: anchorBottom, width: anchorWidth, height: anchorHeight } = this.anchor.getBoundingClientRect(); const { width: containerWidth, height: containerHeight } = this.container.getBoundingClientRect(); const width = clamp$2(containerWidth, 0, this.pageWidth); const height = clamp$2(containerHeight, 0, this.pageHeight); const left = anchorLeft - this.globalOffsetX; const right = anchorRight - this.globalOffsetX; const top = anchorTop - this.globalOffsetY; const bottom = anchorBottom - this.globalOffsetY; let coords; switch (position2) { case "top": { const pTop = clamp$2(top - height - this.offsetY, 0, this.pageHeight); if (autoLayout && pTop + this.offsetY < height) { return this.calcPos(false, "bottom"); } coords = { top: pTop, left: clamp$2( left - width / 2 + anchorWidth / 2, this.offsetX, this.pageWidth - width - this.offsetX ) }; break; } case "right": { const pLeft = clamp$2(right + this.offsetX, 0, this.pageWidth - width); if (autoLayout && pLeft + width > this.pageWidth - this.offsetX) { return this.calcPos(false, "left"); } coords = { top: clamp$2( top + (anchorHeight - height) / 2, this.offsetY, this.pageHeight - height - this.offsetY ), left: pLeft }; break; } case "bottom": { const pTop = clamp$2(bottom + this.offsetY, 0, this.pageHeight - height); if (autoLayout && pTop + height > this.pageHeight - this.offsetY) { return this.calcPos(false, "top"); } coords = { top: pTop, left: clamp$2( left - width / 2 + anchorWidth / 2, this.offsetX, this.pageWidth - width - this.offsetX ) }; break; } case "left": { const pLeft = Math.max(0, left - width - this.offsetX); if (autoLayout && pLeft + width > left - this.offsetX) { return this.calcPos(false, "right"); } coords = { top: clamp$2( top + (anchorHeight - height) / 2, this.offsetY, this.pageHeight - height - this.offsetY ), left: pLeft }; break; } default: coords = { top: 0, left: 0 }; break; } this.position = position2; return coords; } destroy(instant = false) { if (!this.container) { return this; } this.cancelPositionUpdate(); this.clearDestroyFallbackTimer(); this.showed = false; this.syncAriaDescribedBy(false); this.onResizeObserver?.disconnect(); this.intersectionObserver?.disconnect(); this.detachScrollListener(); if (instant) { this.container.remove(); this.container = void 0; return this; } const container = this.container; container.style.pointerEvents = "none"; container.style.opacity = "0"; const handleTransitionDone = () => { this.clearDestroyFallbackTimer(); container?.remove(); if (this.container === container) { this.container = void 0; } }; container.addEventListener("transitionend", handleTransitionDone, { once: true }); container.addEventListener("transitioncancel", handleTransitionDone, { once: true }); this.destroyFallbackTimerId = globalThis.setTimeout( handleTransitionDone, Tooltip.DESTROY_FALLBACK_MS ); return this; } syncAriaDescribedBy(isShowing) { const existing = this.target.getAttribute("aria-describedby"); this.prevAriaDescribedBy ??= existing; if (!isShowing) { if (this.prevAriaDescribedBy === null) { this.target.removeAttribute("aria-describedby"); } else { this.target.setAttribute("aria-describedby", this.prevAriaDescribedBy); } this.prevAriaDescribedBy = null; return; } const tokens = new Set((existing ?? "").split(/\s+/).filter(Boolean)); tokens.add(this.tooltipId); this.target.setAttribute("aria-describedby", Array.from(tokens).join(" ")); } set bordered(isBordered) { this._bordered = isBordered; this.container?.classList.toggle("vot-tooltip-bordered", isBordered); } get bordered() { return this._bordered; } set hidden(isHidden) { this._hidden = isHidden; if (this.container) { setHiddenState(this.container, isHidden); } if (this.showed) { this.syncAriaDescribedBy(!isHidden); } } get hidden() { return this._hidden; } attachScrollListener() { if (this.scrollListening) return; this.scrollListening = true; document.addEventListener("scroll", this.onScroll, { passive: true, capture: true }); } detachScrollListener() { if (!this.scrollListening) return; this.scrollListening = false; document.removeEventListener("scroll", this.onScroll, { capture: true }); } } function isTimeInLine(time, line) { return time >= line.startMs && time < line.startMs + line.durationMs; } function findActiveSubtitleLineIndex(time, subtitlesList) { let low = 0; let high = subtitlesList.length - 1; while (low <= high) { const mid = low + high >> 1; const line = subtitlesList[mid]; if (time < line.startMs) { high = mid - 1; } else if (time >= line.startMs + line.durationMs) { low = mid + 1; } else { return mid; } } return -1; } function getLayoutAffectingKey(subtitleTokensKey, wrapKey, variantKey = 0) { return `${subtitleTokensKey}:${wrapKey}:${variantKey}`; } function clampToRange(value, min, max) { return Math.max(min, Math.min(value, max)); } function hasDragThresholdBeenExceeded(startClientX, startClientY, nextClientX, nextClientY, thresholdPx) { const dx = nextClientX - startClientX; const dy = nextClientY - startClientY; return dx * dx + dy * dy >= thresholdPx * thresholdPx; } function clampAnchorWithinBox({ anchorX, anchorY, elementWidth, elementHeight, boxWidth, boxHeight, bottomInset }) { let nextAnchorX = anchorX; let nextAnchorY = anchorY; const maxAnchorY = Math.max(0, boxHeight - bottomInset); const minAnchorY = elementHeight || 0; if (elementWidth) { let leftPx = nextAnchorX - elementWidth / 2; const maxLeftPx = boxWidth - elementWidth; if (maxLeftPx >= 0) { leftPx = clampToRange(leftPx, 0, maxLeftPx); } else { leftPx = maxLeftPx / 2; } nextAnchorX = leftPx + elementWidth / 2; } nextAnchorY = clampToRange(nextAnchorY, minAnchorY, maxAnchorY); return { anchorX: nextAnchorX, anchorY: nextAnchorY }; } const EST_CHAR_WIDTH_RATIO = 0.55; function clamp$1(value, min, max) { if (Number.isNaN(value)) return min; return Math.min(max, Math.max(min, value)); } function targetCharsPerLine(aspect) { if (aspect < 1) return 28; if (aspect < 1.4) return 32; return 42; } function computeGuidelineMaxWidthRatio(aspect) { if (!Number.isFinite(aspect) || aspect <= 0) return 0.9; if (aspect >= 1.4) return 0.68; if (aspect >= 1.2) return 0.9; return 0.92; } function computeSmartFontSizePx(anchorBox) { const w2 = Math.max(1, anchorBox.w); const h2 = Math.max(1, anchorBox.h); const aspect = w2 / h2; const guidelineMaxWidthRatio = computeGuidelineMaxWidthRatio(aspect); const guidelineMaxWidthPx = w2 * guidelineMaxWidthRatio; const targetCPL = targetCharsPerLine(aspect); const targetPxAt1080 = 36; const baseFontRatioLandscape = targetPxAt1080 / 1080; const portraitScale = 0.89; const baseFontRatio = aspect < 1 ? baseFontRatioLandscape * portraitScale : baseFontRatioLandscape; const minFontPx = 18; const maxFontPx = 50; const pivotH = 1080; const pivotFont = pivotH * baseFontRatio; const flattenExponent = 0.35; const heightBasedPx = h2 <= pivotH ? h2 * baseFontRatio : pivotFont * (h2 / pivotH) ** flattenExponent; const maxByWidthPx = guidelineMaxWidthPx / (targetCPL * EST_CHAR_WIDTH_RATIO); const fontSizePx = Math.round(Math.min(heightBasedPx, maxByWidthPx)); return clamp$1(fontSizePx, minFontPx, maxFontPx); } function computeSmartLayoutForBox(anchorBox) { const w2 = Math.max(1, anchorBox.w); const h2 = Math.max(1, anchorBox.h); const aspect = w2 / h2; const fontSizePx = computeSmartFontSizePx(anchorBox); const guidelineMaxWidthRatio = computeGuidelineMaxWidthRatio(aspect); const guidelineMaxWidthPx = w2 * guidelineMaxWidthRatio; const targetCPL = targetCharsPerLine(aspect); const estCharW = fontSizePx * EST_CHAR_WIDTH_RATIO; const targetMaxWidth = targetCPL * estCharW; const minWidthPx = w2 * (aspect < 1 ? 0.8 : 0.55); const maxWidthPx = clamp$1(targetMaxWidth, minWidthPx, guidelineMaxWidthPx); const computedCPL = maxWidthPx / estCharW; const maxLength = clamp$1(Math.round(computedCPL * 2), 50, 180); return { fontSizePx, maxWidthPx, maxLength }; } const isWordToken = (token) => Boolean(token?.isWordLike && token.text?.trim()); const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); const buildPrefixSums = (values) => { const prefix2 = new Array(values.length + 1).fill(0); for (let i2 = 0; i2 < values.length; i2 += 1) { prefix2[i2 + 1] = prefix2[i2] + values[i2]; } return prefix2; }; const rangeSum = (prefix2, start, end) => { if (end < start) return 0; return prefix2[end + 1] - prefix2[start]; }; const sentenceEndingWordRegexp = /[.!?\u2026]+(?:["'`)\]}\u00BB\u201D\u2019]+)?$/u; function resolveBreakAfterTokenIndex(tokens, wordTokenIndex) { let breakTokenIndex = wordTokenIndex; let cursor = wordTokenIndex + 1; let seenTrailingPunctuation = false; while (cursor < tokens.length) { const token = tokens[cursor]; if (!token || isWordToken(token)) break; if (!token.text.trim()) { if (seenTrailingPunctuation) break; cursor += 1; continue; } breakTokenIndex = cursor; seenTrailingPunctuation = true; cursor += 1; } return breakTokenIndex; } function buildWordSlices(tokens) { const wordTokenIndices = []; for (let i2 = 0; i2 < tokens.length; i2 += 1) { if (isWordToken(tokens[i2])) { wordTokenIndices.push(i2); } } const slices = []; for (let i2 = 0; i2 < wordTokenIndices.length; i2 += 1) { const tokenIndex = wordTokenIndices[i2]; const nextWordTokenIndex = i2 + 1 < wordTokenIndices.length ? wordTokenIndices[i2 + 1] : tokens.length; const rawBreakAfterTokenIndex = resolveBreakAfterTokenIndex( tokens, tokenIndex ); const breakAfterTokenIndex = clamp( rawBreakAfterTokenIndex, tokenIndex, nextWordTokenIndex - 1 ); let textToNextWord = ""; for (let cursor = tokenIndex; cursor < nextWordTokenIndex; cursor += 1) { textToNextWord += tokens[cursor]?.text ?? ""; } let trailingGapAfterBreakText = ""; for (let cursor = breakAfterTokenIndex + 1; cursor < nextWordTokenIndex; cursor += 1) { trailingGapAfterBreakText += tokens[cursor]?.text ?? ""; } slices.push({ tokenIndex, breakAfterTokenIndex, textToNextWord, trailingGapAfterBreakText }); } const keyParts = []; for (const slice of slices) { keyParts.push( `${slice.textToNextWord}${slice.trailingGapAfterBreakText}${slice.breakAfterTokenIndex}` ); } const key = keyParts.join(""); return { slices, key }; } function measureWordSlices(slices, measure) { const widths = slices.map((slice) => measure(slice.textToNextWord)); const chars = slices.map((slice) => slice.textToNextWord.length); const trailingGapWidths = slices.map( (slice) => measure(slice.trailingGapAfterBreakText) ); const trailingGapChars = slices.map( (slice) => slice.trailingGapAfterBreakText.length ); return { widths, chars, trailingGapWidths, trailingGapChars, prefixWidths: buildPrefixSums(widths), prefixChars: buildPrefixSums(chars) }; } function getWordRangeWidth(metrics, startWord, endWord) { if (endWord < startWord) return 0; if (!metrics.widths.length) return 0; const start = clamp(startWord, 0, metrics.widths.length - 1); const end = clamp(endWord, 0, metrics.widths.length - 1); if (end < start) return 0; const total = rangeSum(metrics.prefixWidths, start, end); return total - (metrics.trailingGapWidths[end] ?? 0); } function getWordRangeChars(metrics, startWord, endWord) { if (endWord < startWord) return 0; if (!metrics.chars.length) return 0; const start = clamp(startWord, 0, metrics.chars.length - 1); const end = clamp(endWord, 0, metrics.chars.length - 1); if (end < start) return 0; const total = rangeSum(metrics.prefixChars, start, end); return total - (metrics.trailingGapChars[end] ?? 0); } function fitsInTwoLines(metrics, startWord, endWord, maxWidth) { if (endWord < startWord) return true; if (maxWidth <= 0) return false; if (getWordRangeWidth(metrics, startWord, endWord) <= maxWidth) { return true; } for (let k2 = startWord; k2 < endWord; k2 += 1) { const top = getWordRangeWidth(metrics, startWord, k2); const bottom = getWordRangeWidth(metrics, k2 + 1, endWord); if (top <= maxWidth && bottom <= maxWidth) { return true; } } return false; } const scoreTwoLineCandidate = (w1, w2, maxWidth, count1, count2, wordsInRange) => { const overflowPenaltyMul = 1e4; const widths = [w1, w2]; const mean = (w1 + w2) / 2; let slackCost = 0; for (const width of widths) { const slack = maxWidth - width; if (slack >= 0) { slackCost += slack * slack; } else { const over = -slack; slackCost += over * over * overflowPenaltyMul; } } let variance = 0; for (const width of widths) { const delta = width - mean; variance += delta * delta; } const canAvoidSingleton = wordsInRange >= 4; const singletonPenalty = canAvoidSingleton && (count1 <= 1 || count2 <= 1) ? 1e9 : 0; const canAvoidTwoWordOrphans = wordsInRange >= 6; const twoWordOrphanPenalty = canAvoidTwoWordOrphans && (count1 <= 2 || count2 <= 2) ? 2e7 : 0; let cost = slackCost + variance + singletonPenalty + twoWordOrphanPenalty; if (w1 > w2) { cost += (w1 - w2) / Math.max(1, maxWidth) * 0.15; } return cost; }; function computeBestTwoLineBreak(metrics, startWord, endWord, maxWidth) { if (maxWidth <= 0) return null; if (endWord <= startWord) return null; let bestBreak = null; let bestCost = Number.POSITIVE_INFINITY; const wordsInRange = endWord - startWord + 1; for (let k2 = startWord; k2 < endWord; k2 += 1) { const w1 = getWordRangeWidth(metrics, startWord, k2); const w2 = getWordRangeWidth(metrics, k2 + 1, endWord); if (w1 > maxWidth || w2 > maxWidth) continue; const count1 = k2 - startWord + 1; const count2 = endWord - k2; const cost = scoreTwoLineCandidate( w1, w2, maxWidth, count1, count2, wordsInRange ); if (cost < bestCost) { bestCost = cost; bestBreak = k2; } } return bestBreak; } function computeBalancedBreaks(metrics, maxWidth) { const n2 = metrics.widths.length; if (n2 <= 1) return []; if (getWordRangeWidth(metrics, 0, n2 - 1) <= maxWidth) { return []; } const breakIndex = computeBestTwoLineBreak(metrics, 0, n2 - 1, maxWidth); return breakIndex === null ? [] : [breakIndex]; } function findLongestPrefixFittingTwoLines(metrics, maxWidth) { const n2 = metrics.widths.length; if (n2 <= 0) return null; for (let endWord = n2 - 1; endWord >= 0; endWord -= 1) { if (fitsInTwoLines(metrics, 0, endWord, maxWidth)) { return endWord; } } return null; } function resolveStrictTwoLineLayout(metrics, maxWidth) { if (maxWidth <= 0) { return { breakAfterWordIndices: [], truncateAfterWordIndex: null }; } const n2 = metrics.widths.length; if (n2 <= 1) { return { breakAfterWordIndices: [], truncateAfterWordIndex: null }; } const fullLineFits = getWordRangeWidth(metrics, 0, n2 - 1) <= maxWidth; if (fullLineFits) { return { breakAfterWordIndices: [], truncateAfterWordIndex: null }; } const balancedBreaks = computeBalancedBreaks(metrics, maxWidth); if (balancedBreaks.length) { return { breakAfterWordIndices: balancedBreaks, truncateAfterWordIndex: null }; } const prefixEndWordRaw = findLongestPrefixFittingTwoLines(metrics, maxWidth); const prefixEndWord = prefixEndWordRaw ?? 0; if (prefixEndWord >= n2 - 1) { return { breakAfterWordIndices: [], truncateAfterWordIndex: null }; } const prefixBreak = computeBestTwoLineBreak( metrics, 0, prefixEndWord, maxWidth ); return { breakAfterWordIndices: prefixBreak === null ? [] : [prefixBreak], truncateAfterWordIndex: prefixEndWord }; } const getRangeWordCount = (range) => range.endWord - range.startWord + 1; const buildInitialWordRanges = (wordsCount, isRangeAllowed) => { const wordRanges = []; let startWord = 0; while (startWord < wordsCount) { let endWord = startWord; while (endWord + 1 < wordsCount && isRangeAllowed(startWord, endWord + 1)) { endWord += 1; } wordRanges.push({ startWord, endWord }); startWord = endWord + 1; } return wordRanges; }; const tryBorrowFromPrevious = (wordRanges, index, isRangeAllowed) => { if (index <= 0) return false; const current = wordRanges[index]; const previous = wordRanges[index - 1]; if (!current || !previous) return false; if (getRangeWordCount(previous) < 3) return false; const movedWord = previous.endWord; const nextPrevious = { startWord: previous.startWord, endWord: movedWord - 1 }; const nextCurrent = { startWord: movedWord, endWord: current.endWord }; if (!isRangeAllowed(nextPrevious.startWord, nextPrevious.endWord) || !isRangeAllowed(nextCurrent.startWord, nextCurrent.endWord)) { return false; } previous.endWord = nextPrevious.endWord; current.startWord = nextCurrent.startWord; return true; }; const tryBorrowFromNext = (wordRanges, index, isRangeAllowed) => { if (index >= wordRanges.length - 1) return false; const current = wordRanges[index]; const next = wordRanges[index + 1]; if (!current || !next) return false; if (getRangeWordCount(next) < 3) return false; const movedWord = next.startWord; const nextCurrent = { startWord: current.startWord, endWord: movedWord }; const nextNext = { startWord: movedWord + 1, endWord: next.endWord }; if (!isRangeAllowed(nextCurrent.startWord, nextCurrent.endWord) || !isRangeAllowed(nextNext.startWord, nextNext.endWord)) { return false; } current.endWord = nextCurrent.endWord; next.startWord = nextNext.startWord; return true; }; const rebalanceSingletonRanges = (wordRanges, isRangeAllowed) => { for (let i2 = wordRanges.length - 1; i2 >= 0; i2 -= 1) { if (getRangeWordCount(wordRanges[i2]) !== 1) continue; if (!tryBorrowFromPrevious(wordRanges, i2, isRangeAllowed)) { tryBorrowFromNext(wordRanges, i2, isRangeAllowed); } } }; const getWordPayload = (tokens, words, wordIndex) => { const word = words[wordIndex]; if (!word) return ""; let text = ""; for (let tokenIndex = word.tokenIndex; tokenIndex <= word.breakAfterTokenIndex; tokenIndex += 1) { text += tokens[tokenIndex]?.text ?? ""; } return text.trimEnd(); }; const applySentenceBoundarySplits = (wordRanges, tokens, words, isRangeAllowed) => { for (let i2 = 0; i2 < wordRanges.length - 1; i2 += 1) { const previous = wordRanges[i2]; const next = wordRanges[i2 + 1]; if (!previous || !next) continue; let sentenceBoundaryWord = -1; const minCandidate = Math.max(previous.startWord, previous.endWord - 2); for (let candidate = previous.endWord - 1; candidate >= minCandidate; candidate -= 1) { if (sentenceEndingWordRegexp.test(getWordPayload(tokens, words, candidate))) { sentenceBoundaryWord = candidate; break; } } if (sentenceBoundaryWord < previous.startWord) continue; const nextPreviousStartWord = previous.startWord; const nextPreviousEndWord = sentenceBoundaryWord; const nextPreviousWordCount = nextPreviousEndWord - nextPreviousStartWord + 1; if (nextPreviousWordCount < 3) continue; const nextStartWord = sentenceBoundaryWord + 1; const nextEndWord = next.endWord; if (!isRangeAllowed(nextPreviousStartWord, nextPreviousEndWord) || !isRangeAllowed(nextStartWord, nextEndWord)) { continue; } previous.endWord = nextPreviousEndWord; next.startWord = nextStartWord; } }; const mapWordRangesToTimedSegments = (wordRanges, words, tokens) => { const segments = []; for (const range of wordRanges) { const startWord = words[range.startWord]; const endWord = words[range.endWord]; if (!startWord || !endWord) continue; const startToken = startWord.tokenIndex; const endToken = endWord.breakAfterTokenIndex + 1; const startMs = tokens[startToken]?.startMs ?? 0; const endTokenStartMs = tokens[endWord.tokenIndex]?.startMs ?? startMs; const endTokenDurationMs = tokens[endWord.tokenIndex]?.durationMs ?? 0; const nextWord = words[range.endWord + 1]; const nextWordStartMs = nextWord ? tokens[nextWord.tokenIndex]?.startMs : void 0; const endMs = nextWordStartMs ?? endTokenStartMs + endTokenDurationMs; segments.push({ startToken, endToken, startMs, endMs }); } return segments; }; function computeTwoLineSegments(tokens, words, metrics, maxWidthPx, maxChars) { const wordsCount = words.length; if (wordsCount === 0) return []; const charLimit = Number.isFinite(maxChars) && maxChars > 0 ? maxChars : null; const isRangeAllowed = (startWord, endWord) => { if (endWord < startWord) return false; if (charLimit !== null && getWordRangeChars(metrics, startWord, endWord) > charLimit) { return false; } return fitsInTwoLines(metrics, startWord, endWord, maxWidthPx); }; const wordRanges = buildInitialWordRanges(wordsCount, isRangeAllowed); rebalanceSingletonRanges(wordRanges, isRangeAllowed); applySentenceBoundarySplits(wordRanges, tokens, words, isRangeAllowed); return mapWordRangesToTimedSegments(wordRanges, words, tokens); } function shouldShowSmartEllipsis(smartLayoutEnabled, truncateAfterTokenIndex, tokensLength) { if (!smartLayoutEnabled) return false; if (typeof truncateAfterTokenIndex !== "number") return false; return truncateAfterTokenIndex >= 0 && truncateAfterTokenIndex < tokensLength - 1; } const WRAP_WIDTH_GUARD_PX = 8; const WRAP_WIDTH_GUARD_RATIO = 0.97; const MIN_EFFECTIVE_WRAP_WIDTH_PX = 24; function applyWrapWidthGuard(maxWidthPx) { if (!Number.isFinite(maxWidthPx) || maxWidthPx <= 0) return 0; const byPixelGuard = maxWidthPx - WRAP_WIDTH_GUARD_PX; const byRatioGuard = maxWidthPx * WRAP_WIDTH_GUARD_RATIO; const guarded = Math.min(byPixelGuard, byRatioGuard); return Math.max(MIN_EFFECTIVE_WRAP_WIDTH_PX, guarded); } class SubtitlesWidget { video; container; portal; tooltipLayoutRoot; subtitlesContainer = null; subtitlesBlock = null; renderedTokenEls = []; subtitles = null; subtitleLang; lastRenderKey = null; lastActiveLineIndex = null; highlightWords = false; fontSize = 20; fontSizeOverridden = false; manualMaxLength = 300; smartLayoutEnabled = true; smartFontSizePx = 0; smartMaxWidthPx = 0; smartMaxLength = 0; lastSmartLayoutKey = null; lastSmartLayoutCheckTs = 0; opacity = "0.2"; maxLength = 300; repositionPending = false; positionRefreshPending = false; updatePending = false; lastUpdateRequestTs = 0; updateMinIntervalMs = 100; updateMinIntervalHighlightMs = 33; useVideoFrameCallbacks; videoFrameRequestId = null; dragDocListenersAttached = false; lastPositionRefreshTs = 0; positionRefreshIntervalMs = 250; subtitleMaxWidthPx = 0; breakAfterTokenIndices = []; breakAfterTokenIndexSet = null; smartTruncateAfterTokenIndex = null; wrapPending = false; lastWrapKey = null; lastWrapTokens = null; measureCanvas = null; measureCtx = null; lastMultilineMeasureSignature = null; lastLayoutAffectingKey = null; tokenProcessingMemo = null; tokenPrecomputeMemo = null; lineMeasureMemo = null; lastSegmentIndex = 0; position = { left: 50, top: 100 }; dragging = { pointerId: null, candidate: false, active: false, moved: false, startClientX: 0, startClientY: 0, offset: { x: 0, y: 0 } }; dragStartThresholdPx = 4; suppressTokenClicksUntil = 0; abortController = new AbortController(); resizeObserver; tokenTooltip; tooltipTranslationRequestId = 0; intervalIdleChecker; checkerUnsubscribe = null; edgePunctuationTrimRe = /(?:^[\p{P}\p{S}]+|[\p{P}\p{S}]+$)/gu; strTokens = ""; strTranslatedTokens = ""; normalizeTokenTextForTranslation(raw) { return raw.trim().replace(this.edgePunctuationTrimRe, ""); } bottomInsetCachedPx = 0; bottomInsetByMode = { normal: { ratio: 0.1, minPx: 56, maxPx: 220, gapPx: 10 }, fullscreen: { ratio: 0.07, minPx: 44, maxPx: 140, gapPx: 9 } }; safeAreaProbeEl = null; onPointerDownBound; onPointerUpBound; onPointerMoveBound; onTimeUpdateBound; onPlaybackStateChangeBound; onVisualViewportChangeBound; constructor(video, container, portal, intervalIdleChecker, tooltipLayoutRoot = void 0) { this.video = video; this.container = container; this.portal = portal; this.intervalIdleChecker = intervalIdleChecker; this.tooltipLayoutRoot = tooltipLayoutRoot; this.useVideoFrameCallbacks = !!this.video && typeof this.video.requestVideoFrameCallback === "function"; this.onPointerDownBound = (event) => this.onPointerDown(event); this.onPointerUpBound = (event) => this.onPointerUp(event); this.onPointerMoveBound = (event) => this.onPointerMove(event); this.onTimeUpdateBound = () => this.requestUpdate(); this.onPlaybackStateChangeBound = () => this.handlePlaybackStateChange(); this.onVisualViewportChangeBound = () => this.scheduleReposition(); this.checkerUnsubscribe = this.intervalIdleChecker.subscribe(() => { this.onCheckerTick(); }); this.bindEvents(); } setPortal(portal) { this.portal = portal; } resetTranslationContext(releaseTooltip = false) { this.strTranslatedTokens = ""; if (releaseTooltip) { this.releaseTooltip(); } } resetSegmentationMemo() { this.tokenProcessingMemo = null; this.tokenPrecomputeMemo = null; this.lineMeasureMemo = null; this.lastSegmentIndex = 0; } resetWrapMemo() { this.setBreakAfterTokenIndices([]); this.smartTruncateAfterTokenIndex = null; this.lastWrapKey = null; } resetRenderMemo() { this.lastRenderKey = null; this.lastMultilineMeasureSignature = null; this.lastLayoutAffectingKey = null; } computeAnchorBoxLayout(layout) { const fallback = { left: 0, top: 0, w: layout.w, h: layout.h }; const video = this.video; if (!video) return fallback; const videoRect = video.getBoundingClientRect(); if (!(videoRect.width > 0 && videoRect.height > 0)) return fallback; const containerRect = layout.rect; const intersects = videoRect.right > containerRect.left && videoRect.left < containerRect.right && videoRect.bottom > containerRect.top && videoRect.top < containerRect.bottom; if (!intersects) return fallback; const w2 = videoRect.width / layout.scaleX; const h2 = videoRect.height / layout.scaleY; if (!(w2 > 0 && h2 > 0)) return fallback; const rawLeft = (videoRect.left - containerRect.left) / layout.scaleX; const rawTop = (videoRect.top - containerRect.top) / layout.scaleY; const maxLeft = layout.w - w2; const maxTop = layout.h - h2; const left = maxLeft >= 0 ? clampToRange(rawLeft, 0, maxLeft) : (layout.w - w2) / 2; const top = maxTop >= 0 ? clampToRange(rawTop, 0, maxTop) : (layout.h - h2) / 2; return { left, top, w: w2, h: h2 }; } ensureSmartLayout(anchorBox) { if (!this.smartLayoutEnabled) { this.maxLength = this.manualMaxLength; return null; } const next = computeSmartLayoutForBox(anchorBox); const nextKey = `${Math.round(next.fontSizePx)}|${Math.round( next.maxWidthPx )}|${next.maxLength}`; const fontChanged = next.fontSizePx !== this.smartFontSizePx; const widthChanged = Math.abs(next.maxWidthPx - this.smartMaxWidthPx) > 0.5; const lengthChanged = next.maxLength !== this.smartMaxLength; if (nextKey !== this.lastSmartLayoutKey) { this.lastSmartLayoutKey = nextKey; this.smartFontSizePx = next.fontSizePx; this.smartMaxWidthPx = next.maxWidthPx; this.smartMaxLength = next.maxLength; } if (lengthChanged) { this.maxLength = next.maxLength; this.resetRenderMemo(); this.resetSegmentationMemo(); } if (fontChanged && this.subtitlesBlock) { this.subtitlesBlock.style.setProperty( "--vot-subtitles-font-size", `${next.fontSizePx}px` ); } if ((fontChanged || widthChanged) && this.lastWrapTokens) { this.lastWrapKey = null; this.scheduleWrapRecompute(); this.resetSegmentationMemo(); } return next; } scheduleReposition() { if (this.abortController.signal.aborted) return; if (!this.subtitles) return; this.repositionPending = true; this.intervalIdleChecker.markActivity("subtitles-reposition"); this.intervalIdleChecker.requestImmediateTick(); } createSubtitlesContainer() { if (this.subtitlesContainer) { return this.subtitlesContainer; } if (getComputedStyle(this.container).position === "static") { this.container.style.position = "relative"; } const container = document.createElement("vot-block"); container.classList.add("vot-subtitles-widget"); this.container.appendChild(container); this.subtitlesContainer = container; container.addEventListener("pointerdown", this.onPointerDownBound, { signal: this.abortController.signal, passive: true }); container.style.transform = "translate(-50%, -100%)"; this.updateContainerRect(); return container; } bindEvents() { const { signal } = this.abortController; const opts = { signal }; this.video?.addEventListener("play", this.onPlaybackStateChangeBound, opts); this.video?.addEventListener( "pause", this.onPlaybackStateChangeBound, opts ); this.video?.addEventListener( "seeking", this.onPlaybackStateChangeBound, opts ); this.video?.addEventListener( "seeked", this.onPlaybackStateChangeBound, opts ); this.video?.addEventListener( "ended", this.onPlaybackStateChangeBound, opts ); this.resizeObserver = new ResizeObserver(() => this.onResize()); this.resizeObserver.observe(this.container); if (this.video) this.resizeObserver.observe(this.video); globalThis.visualViewport?.addEventListener( "resize", this.onVisualViewportChangeBound, opts ); globalThis.visualViewport?.addEventListener( "scroll", this.onVisualViewportChangeBound, opts ); } getUpdateMinIntervalMs() { return this.highlightWords ? this.updateMinIntervalHighlightMs : this.updateMinIntervalMs; } requestUpdate(now2 = performance.now()) { if (this.abortController.signal.aborted) return; if (!this.subtitles) return; const minInterval = this.getUpdateMinIntervalMs(); if (now2 - this.lastUpdateRequestTs < minInterval) return; this.lastUpdateRequestTs = now2; this.updatePending = true; this.intervalIdleChecker.requestImmediateTick(); } handlePlaybackStateChange() { if (!this.subtitles) { this.stopVideoFrameLoop(); return; } this.scheduleReposition(); this.requestUpdate(); this.syncVideoFrameLoop(); } syncVideoFrameLoop() { if (!this.useVideoFrameCallbacks) return; const video = this.video; if (!video) return; if (!this.subtitles || video.paused || video.ended) { this.stopVideoFrameLoop(); return; } this.startVideoFrameLoop(); } startVideoFrameLoop() { if (!this.useVideoFrameCallbacks) return; const video = this.video; if (!video) return; if (this.videoFrameRequestId !== null) return; this.videoFrameRequestId = video.requestVideoFrameCallback( this.onVideoFrame ); } stopVideoFrameLoop() { if (!this.useVideoFrameCallbacks) return; const video = this.video; if (!video) return; if (this.videoFrameRequestId === null) return; try { video.cancelVideoFrameCallback(this.videoFrameRequestId); } catch { } this.videoFrameRequestId = null; } onVideoFrame = (now2, _metadata) => { this.videoFrameRequestId = null; if (this.abortController.signal.aborted) return; const video = this.video; if (!video || video.paused || video.ended) return; if (!this.subtitles) return; this.requestUpdate(now2); this.startVideoFrameLoop(); }; onCheckerTick() { if (this.abortController.signal.aborted) return; if (this.repositionPending) { this.repositionPending = false; this.updateContainerRect(); this.updatePending = true; } if (this.wrapPending) { this.wrapPending = false; this.recomputeWrapNow(); } if (this.positionRefreshPending) { this.positionRefreshPending = false; this.applySubtitlePosition(); } if (this.updatePending) { this.updatePending = false; this.update(); } } attachDragDocumentListeners() { if (this.dragDocListenersAttached) return; this.dragDocListenersAttached = true; document.addEventListener("pointermove", this.onPointerMoveBound, { passive: false }); document.addEventListener("pointerup", this.onPointerUpBound); document.addEventListener("pointercancel", this.onPointerUpBound); } detachDragDocumentListeners() { if (!this.dragDocListenersAttached) return; this.dragDocListenersAttached = false; document.removeEventListener("pointermove", this.onPointerMoveBound); document.removeEventListener("pointerup", this.onPointerUpBound); document.removeEventListener("pointercancel", this.onPointerUpBound); } onResize() { this.scheduleReposition(); } updateContainerRect() { const layout = this.getLayoutSize(); if (!layout.w || !layout.h) return; const anchorBox = this.computeAnchorBoxLayout(layout); if (!anchorBox.w || !anchorBox.h) return; this.refreshBottomInsetNow(layout, anchorBox); this.applySubtitlePositionWithLayout(layout, anchorBox); } getLayoutSize() { const rect = this.container.getBoundingClientRect(); const w2 = this.container.clientWidth || rect.width; const h2 = this.container.clientHeight || rect.height; const scaleX = rect.width && w2 ? rect.width / w2 : 1; const scaleY = rect.height && h2 ? rect.height / h2 : 1; return { w: w2, h: h2, rect, scaleX, scaleY }; } ensureSafeAreaProbe() { if (this.safeAreaProbeEl) return; const el = document.createElement("div"); el.style.position = "fixed"; el.style.left = "0"; el.style.right = "0"; el.style.bottom = "0"; el.style.height = "env(safe-area-inset-bottom, 0px)"; el.style.pointerEvents = "none"; el.style.opacity = "0"; el.style.zIndex = "-1"; document.documentElement.appendChild(el); this.safeAreaProbeEl = el; } getSafeAreaBottomInsetPx() { this.ensureSafeAreaProbe(); if (!this.safeAreaProbeEl) return 0; const h2 = this.safeAreaProbeEl.offsetHeight || 0; return h2; } getBottomInsetPreset() { const doc = document; const fullscreenEl = doc.fullscreenElement ?? doc.webkitFullscreenElement; if (!(fullscreenEl instanceof Element)) { return this.bottomInsetByMode.normal; } const { container, video } = this; const fullscreenContainsContainer = fullscreenEl === container || fullscreenEl.contains(container) || container.contains(fullscreenEl); if (fullscreenContainsContainer) { return this.bottomInsetByMode.fullscreen; } if (video && (fullscreenEl === video || fullscreenEl.contains(video) || video.contains(fullscreenEl))) { return this.bottomInsetByMode.fullscreen; } return this.bottomInsetByMode.normal; } computeReservedBottomInsetPx(anchorBoxH, preset = this.getBottomInsetPreset()) { const raw = anchorBoxH * preset.ratio; return clampToRange(raw, preset.minPx, preset.maxPx); } refreshBottomInsetNow(layout, anchorBox) { const anchorH = anchorBox?.h ?? this.computeAnchorBoxLayout(layout ?? this.getLayoutSize()).h; if (!anchorH) { this.bottomInsetCachedPx = 0; return; } const preset = this.getBottomInsetPreset(); this.bottomInsetCachedPx = this.computeReservedBottomInsetPx( anchorH, preset ); } getBottomInsetPx(layout, anchorBox) { const preset = this.getBottomInsetPreset(); const safeAreaBottom = this.getSafeAreaBottomInsetPx(); const paddingBottom = Number.parseFloat( getComputedStyle(this.container).paddingBottom || "0" ) || 0; const anchorH = anchorBox?.h ?? this.computeAnchorBoxLayout(layout ?? this.getLayoutSize()).h; const reserved = anchorH ? this.computeReservedBottomInsetPx(anchorH, preset) : preset.minPx; const stableInset = Math.max(this.bottomInsetCachedPx, reserved); return Math.max(paddingBottom, safeAreaBottom, stableInset) + preset.gapPx; } onPointerDown(event) { const subtitlesContainer = this.subtitlesContainer; if (!subtitlesContainer) return; const target = event.target; if (!(target instanceof Node) || !subtitlesContainer.contains(target)) return; if (!event.isPrimary) return; if (event.pointerType === "mouse" && event.button !== 0) return; const layout = this.getLayoutSize(); const { rect: containerRect, w: w2, h: h2, scaleX, scaleY } = layout; if (!w2 || !h2) return; const anchorBox = this.computeAnchorBoxLayout(layout); if (!anchorBox.w || !anchorBox.h) return; this.lastPositionRefreshTs = performance.now(); const subRect = subtitlesContainer.getBoundingClientRect(); const pointerX = (event.clientX - containerRect.left) / scaleX - anchorBox.left; const pointerY = (event.clientY - containerRect.top) / scaleY - anchorBox.top; const anchorX = (subRect.left - containerRect.left + subRect.width / 2) / scaleX - anchorBox.left; const anchorY = (subRect.top - containerRect.top + subRect.height) / scaleY - anchorBox.top; this.dragging.pointerId = event.pointerId; this.dragging.candidate = true; this.dragging.active = false; this.dragging.moved = false; this.dragging.startClientX = event.clientX; this.dragging.startClientY = event.clientY; this.dragging.offset.x = anchorX - pointerX; this.dragging.offset.y = anchorY - pointerY; this.attachDragDocumentListeners(); const captureEl = this.subtitlesBlock ?? (target instanceof Element ? target : null); try { captureEl?.setPointerCapture(event.pointerId); } catch { } } onPointerUp(event) { if (this.dragging.pointerId === null) return; if (event.pointerId !== this.dragging.pointerId) return; if (this.dragging.moved) { this.suppressTokenClicksUntil = performance.now() + 450; } this.dragging.pointerId = null; this.dragging.candidate = false; this.dragging.active = false; this.dragging.moved = false; this.detachDragDocumentListeners(); } onPointerMove(event) { if (!this.dragging.candidate || this.dragging.pointerId === null) return; if (event.pointerId !== this.dragging.pointerId) return; if (!this.dragging.active) { const thresholdExceeded = hasDragThresholdBeenExceeded( this.dragging.startClientX, this.dragging.startClientY, event.clientX, event.clientY, this.dragStartThresholdPx ); if (!thresholdExceeded) { return; } this.dragging.active = true; this.dragging.moved = true; this.suppressTokenClicksUntil = performance.now() + 450; this.releaseTooltip(); } else { this.dragging.moved = true; } event.preventDefault(); event.stopPropagation(); const layout = this.getLayoutSize(); const { rect: containerRect, w: w2, h: h2, scaleX, scaleY } = layout; if (!w2 || !h2) return; const anchorBox = this.computeAnchorBoxLayout(layout); if (!anchorBox.w || !anchorBox.h) return; const pointerX = (event.clientX - containerRect.left) / scaleX - anchorBox.left; const pointerY = (event.clientY - containerRect.top) / scaleY - anchorBox.top; let anchorX = pointerX + this.dragging.offset.x; let anchorY = pointerY + this.dragging.offset.y; const elW = this.subtitlesContainer?.offsetWidth ?? 0; const elH = this.subtitlesContainer?.offsetHeight ?? 0; const bottomInset = this.getBottomInsetPx(layout, anchorBox); ({ anchorX, anchorY } = clampAnchorWithinBox({ anchorX, anchorY, elementWidth: elW, elementHeight: elH, boxWidth: anchorBox.w, boxHeight: anchorBox.h, bottomInset })); this.position.left = anchorX / anchorBox.w * 100; this.position.top = anchorY / anchorBox.h * 100; this.applySubtitlePositionWithLayout(layout, anchorBox); } applySubtitlePosition() { const subtitlesContainer = this.subtitlesContainer; if (!subtitlesContainer) return; const layout = this.getLayoutSize(); if (!layout.w || !layout.h) return; const anchorBox = this.computeAnchorBoxLayout(layout); if (!anchorBox.w || !anchorBox.h) return; this.applySubtitlePositionWithLayout(layout, anchorBox); } applySubtitlePositionWithLayout(layout, anchorBox) { const subtitlesContainer = this.subtitlesContainer; if (!subtitlesContainer) return; const visualScale = Math.min(layout.scaleX || 1, layout.scaleY || 1); const compensate = visualScale > 0 && visualScale < 0.999 ? Math.min(1 / visualScale, 3) : 1; if (Math.abs(compensate - 1) < 1e-3) { subtitlesContainer.style.removeProperty( "--vot-subtitles-scale-compensation" ); } else { subtitlesContainer.style.setProperty( "--vot-subtitles-scale-compensation", compensate.toFixed(3) ); } let desiredMaxWidthPx = 0; if (this.smartLayoutEnabled) { const smart = this.ensureSmartLayout(anchorBox); desiredMaxWidthPx = smart ? Math.max(0, smart.maxWidthPx) : Math.max(0, anchorBox.w * 0.7); } else { desiredMaxWidthPx = Math.max(0, anchorBox.w * 0.7); } if (Math.abs(desiredMaxWidthPx - this.subtitleMaxWidthPx) > 0.5) { this.subtitleMaxWidthPx = desiredMaxWidthPx; subtitlesContainer.style.setProperty( "--vot-subtitles-max-width", `${Math.round(desiredMaxWidthPx)}px` ); this.resetSegmentationMemo(); this.scheduleWrapRecompute(); } const elW = subtitlesContainer.offsetWidth; const elH = subtitlesContainer.offsetHeight; const bottomInset = this.getBottomInsetPx(layout, anchorBox); let anchorX = this.position.left / 100 * anchorBox.w; let anchorY = this.position.top / 100 * anchorBox.h; let leftPx = anchorX - elW / 2; let topPx = anchorY - elH; const maxLeftPx = anchorBox.w - elW; const maxTopPx = anchorBox.h - bottomInset - elH; if (maxLeftPx >= 0) { leftPx = clampToRange(leftPx, 0, maxLeftPx); } else { leftPx = maxLeftPx / 2; } if (maxTopPx >= 0) { topPx = clampToRange(topPx, 0, maxTopPx); } else { topPx = 0; } anchorX = leftPx + elW / 2; anchorY = topPx + elH; const containerAnchorX = anchorBox.left + anchorX; const containerAnchorY = anchorBox.top + anchorY; const leftPct = containerAnchorX / layout.w * 100; const topPct = containerAnchorY / layout.h * 100; subtitlesContainer.style.left = `${leftPct}%`; subtitlesContainer.style.top = `${topPct}%`; subtitlesContainer.style.transform = "translate(-50%, -100%)"; this.tokenTooltip?.updatePos(); } applyPositionAfterContentRender() { const layout = this.getLayoutSize(); if (layout.w && layout.h) { const anchorBox = this.computeAnchorBoxLayout(layout); if (anchorBox.w && anchorBox.h) { this.refreshBottomInsetNow(layout, anchorBox); this.applySubtitlePositionWithLayout(layout, anchorBox); return; } this.refreshBottomInsetNow(layout); this.applySubtitlePosition(); return; } this.refreshBottomInsetNow(); this.applySubtitlePosition(); } trimEdgeWhitespaceTokens(tokens) { if (!tokens.length) return tokens; let s2 = 0; let e2 = tokens.length; while (s2 < e2 && !tokens[s2]?.text.trim()) s2 += 1; while (e2 > s2 && !tokens[e2 - 1]?.text.trim()) e2 -= 1; if (s2 === 0 && e2 === tokens.length) return tokens; return s2 >= e2 ? [] : tokens.slice(s2, e2); } splitRangesByMaxLength(tokens) { const ranges = []; let start = 0; let length = 0; for (const [index, token] of tokens.entries()) { length += token.text.length; if (length > this.maxLength && index > start) { ranges.push([start, index]); start = index; length = token.text.length; } } if (start < tokens.length) { ranges.push([start, tokens.length]); } return ranges; } pickRangeByTime(tokens, ranges, time) { let chosen = ranges[0] ?? [0, tokens.length]; for (const range of ranges) { const first = tokens[range[0]]; const last = tokens[range[1] - 1]; if (!first || !last) continue; const nextStartMs = range[1] < tokens.length ? tokens[range[1]].startMs : void 0; const endMs = nextStartMs ?? last.startMs + (last.durationMs ?? 0); if (first.startMs <= time && time < endMs) { chosen = range; break; } } return chosen; } selectTokensByMaxLength(tokens, time) { if (!tokens.length) return tokens; let totalChars = 0; for (const token of tokens) { totalChars += token?.text.length ?? 0; } if (totalChars <= this.maxLength) { return this.trimEdgeWhitespaceTokens(tokens); } const ranges = this.splitRangesByMaxLength(tokens); const chosen = this.pickRangeByTime(tokens, ranges, time); return this.trimEdgeWhitespaceTokens(tokens.slice(chosen[0], chosen[1])); } computeTwoLineSegments(tokens, words, metrics, maxWidthPx, maxChars) { return computeTwoLineSegments( tokens, words, metrics, maxWidthPx, maxChars ); } buildTokenPrecomputeInput(tokens) { const cached = this.tokenPrecomputeMemo; if (cached?.tokens === tokens) { return cached.value; } const { slices, key } = buildWordSlices(tokens); const words = slices.map((slice) => ({ tokenIndex: slice.tokenIndex, breakAfterTokenIndex: slice.breakAfterTokenIndex })); const value = { words, wordSlices: slices, normalizedWordsKey: key }; this.tokenPrecomputeMemo = { tokens, value }; return value; } getTokenLayoutInputs(ctx) { const block = this.subtitlesBlock; if (block) { const cs = getComputedStyle(block); const fontKey2 = `${cs.fontStyle} ${cs.fontVariant} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; ctx.font = fontKey2; const cssMaxWidth = Number.parseFloat(cs.maxWidth); const paddingLeft = Number.parseFloat(cs.paddingLeft) || 0; const paddingRight = Number.parseFloat(cs.paddingRight) || 0; const baseMaxWidth2 = Number.isFinite(cssMaxWidth) ? cssMaxWidth : this.subtitleMaxWidthPx || globalThis.innerWidth * 0.8; return { fontKey: fontKey2, maxWidthPx: Math.max(0, baseMaxWidth2 - paddingLeft - paddingRight) }; } const remPx = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; const maxRem = 52; const cssFallbackVw = 0.8; const baseMaxWidth = Math.min( remPx * maxRem, this.subtitleMaxWidthPx || globalThis.innerWidth * cssFallbackVw ); const fontSizePx = this.fontSizeOverridden ? this.fontSize : Math.min(24, Math.max(14, globalThis.innerWidth * 0.016)); const fontKey = `normal normal 500 ${fontSizePx}px Roboto, "Segoe UI", system-ui, sans-serif`; ctx.font = fontKey; return { fontKey, maxWidthPx: Math.max(0, baseMaxWidth - fontSizePx) }; } getActiveLineKey(tokens) { if (this.lastActiveLineIndex !== null) { return `${this.lastActiveLineIndex}`; } return `${tokens[0]?.startMs ?? 0}:${tokens[0]?.durationMs ?? 0}:${tokens.length}`; } getLineMeasureMemo(tokens, activeLineKey) { const { words, wordSlices, normalizedWordsKey } = this.buildTokenPrecomputeInput(tokens); if (!words.length) return null; const ctx = this.getMeasureContext(); if (!ctx) return null; const { fontKey, maxWidthPx } = this.getTokenLayoutInputs(ctx); if (!Number.isFinite(maxWidthPx) || maxWidthPx < 24) { return null; } const key = `${activeLineKey}|${fontKey}|${Math.round( maxWidthPx )}|${normalizedWordsKey}`; if (this.lineMeasureMemo?.key === key) { return this.lineMeasureMemo; } const metrics = measureWordSlices( wordSlices, (text) => ctx.measureText(text).width ); const memo = { key, words, metrics, maxWidthPx }; this.lineMeasureMemo = memo; return memo; } buildTokenProcessingMemo(tokens, activeLineKey) { const lineMeasure = this.getLineMeasureMemo(tokens, activeLineKey); if (!lineMeasure) return null; const memoKey = `${lineMeasure.key}|${this.maxLength}`; if (this.tokenProcessingMemo?.key === memoKey) { return this.tokenProcessingMemo; } const safeMaxWidthPx = applyWrapWidthGuard(lineMeasure.maxWidthPx); const segmentRanges = this.computeTwoLineSegments( tokens, lineMeasure.words, lineMeasure.metrics, safeMaxWidthPx, this.maxLength ); const memo = { key: memoKey, segmentRanges }; this.tokenProcessingMemo = memo; this.lastSegmentIndex = 0; return memo; } selectSegmentIndexFromRanges(segmentRanges, time) { if (!segmentRanges.length) return -1; let idx = this.lastSegmentIndex; if (idx >= segmentRanges.length) idx = 0; while (idx < segmentRanges.length - 1 && time >= segmentRanges[idx].endMs) { idx += 1; } while (idx > 0 && time < segmentRanges[idx].startMs) { idx -= 1; } if (!(time >= segmentRanges[idx].startMs && time < segmentRanges[idx].endMs)) { const found = segmentRanges.findIndex( (s2) => time >= s2.startMs && time < s2.endMs ); if (found >= 0) { idx = found; } else { idx = time < segmentRanges[0].startMs ? 0 : segmentRanges.length - 1; } } this.lastSegmentIndex = idx; return idx; } processTokens(tokens, time) { if (!tokens.length) return tokens; const activeLineKey = this.getActiveLineKey(tokens); const memo = this.buildTokenProcessingMemo(tokens, activeLineKey); if (!memo) { return this.selectTokensByMaxLength(tokens, time); } const { segmentRanges } = memo; if (!segmentRanges.length) { return this.trimEdgeWhitespaceTokens(tokens); } const segmentIndex = this.selectSegmentIndexFromRanges(segmentRanges, time); if (segmentIndex < 0) { return this.trimEdgeWhitespaceTokens(tokens); } const seg = segmentRanges[segmentIndex]; return this.trimEdgeWhitespaceTokens( tokens.slice(seg.startToken, seg.endToken) ); } async translateStrTokens(text) { const fromLang = this.subtitleLang ?? ""; const toLang = localizationProvider.lang; if (this.strTranslatedTokens) { const translated2 = await translate(text, fromLang, toLang); return [ this.strTranslatedTokens, typeof translated2 === "string" ? translated2 : "" ]; } const translated = await translate( [this.strTokens, text], fromLang, toLang ); const pair = Array.isArray(translated) ? translated : [translated, translated]; const context = typeof pair[0] === "string" ? pair[0] : ""; const current = typeof pair[1] === "string" ? pair[1] : ""; this.strTranslatedTokens = context; return [context, current]; } isTokenSpanElement(el) { return el instanceof HTMLSpanElement && el.dataset.votToken === "1"; } findTokenSpanInPath(path, root) { for (const node of path) { if (this.isTokenSpanElement(node) && root.contains(node)) { return node; } } return null; } findTokenSpanByPoint(x2, y2, root) { const hit = document.elementFromPoint(x2, y2); if (this.isTokenSpanElement(hit) && root.contains(hit)) { return hit; } if (!(hit instanceof Element)) return null; const closest = hit.closest('span[data-vot-token="1"]'); if (closest instanceof HTMLSpanElement && root.contains(closest)) { return closest; } return null; } resolveTokenSpanFromClick(event) { const root = this.subtitlesBlock ?? this.subtitlesContainer; if (!root) return null; if (this.isTokenSpanElement(event.target) && root.contains(event.target)) { return event.target; } const path = typeof event.composedPath === "function" ? event.composedPath() : []; const fromPath = this.findTokenSpanInPath(path, root); if (fromPath) { return fromPath; } const x2 = event.clientX; const y2 = event.clientY; if (Number.isFinite(x2) && Number.isFinite(y2)) { return this.findTokenSpanByPoint(x2, y2, root); } return null; } releaseTooltip() { this.tooltipTranslationRequestId += 1; if (this.tokenTooltip?.target) { this.tokenTooltip.target.classList.remove("selected"); } this.tokenTooltip?.release(); this.tokenTooltip = void 0; return this; } clearPendingSchedulerState() { this.repositionPending = false; this.updatePending = false; this.wrapPending = false; this.positionRefreshPending = false; } clearRenderedContent({ releaseTooltip = false } = {}) { if (releaseTooltip) this.releaseTooltip(); this.resetRenderMemo(); this.lastActiveLineIndex = null; this.strTokens = ""; this.resetTranslationContext(); this.subtitlesBlock = null; this.renderedTokenEls = []; this.resetWrapMemo(); this.lastWrapTokens = null; this.subtitleMaxWidthPx = 0; this.resetSegmentationMemo(); this.clearPendingSchedulerState(); if (this.subtitlesContainer) { D(null, this.subtitlesContainer); } } onClick = async (event) => { if (performance.now() < this.suppressTokenClicksUntil) { event.preventDefault(); event.stopPropagation(); return; } const target = this.resolveTokenSpanFromClick(event); if (!target) return; if (this.tokenTooltip?.target === target && this.tokenTooltip?.container) { if (this.tokenTooltip.showed) target.classList.add("selected"); else target.classList.remove("selected"); return; } this.releaseTooltip(); const requestId = this.tooltipTranslationRequestId; const text = this.normalizeTokenTextForTranslation( target.textContent ?? "" ); if (!text) return; const service = await votStorage.get( "translationService", defaultTranslationService ); if (requestId !== this.tooltipTranslationRequestId) return; target.classList.add("selected"); const subtitlesInfo = UI.createSubtitleInfo( text, this.strTranslatedTokens || this.strTokens, service ); const tooltip = new Tooltip({ target, anchor: this.subtitlesBlock ?? target, layoutRoot: this.tooltipLayoutRoot, content: subtitlesInfo.container, parentElement: this.portal, maxWidth: this.subtitlesBlock?.offsetWidth ?? this.subtitlesContainer?.offsetWidth, borderRadius: 12, bordered: false, position: "top", trigger: "click" }); this.tokenTooltip = tooltip; tooltip.onClick(); const strTokens = this.strTokens; const translated = await this.translateStrTokens(text); if (requestId !== this.tooltipTranslationRequestId) return; if (strTokens !== this.strTokens || this.tokenTooltip !== tooltip || tooltip.target !== target || !tooltip.showed) return; subtitlesInfo.header.textContent = translated[1]; subtitlesInfo.context.textContent = translated[0]; tooltip.setContent(subtitlesInfo.container); }; buildPassedState(tokens, time) { const flags = []; for (const token of tokens) { if (!token.isWordLike) continue; const halfway = token.startMs + token.durationMs / 2; const passed = time > halfway || time > token.startMs - 100 && halfway - time < 275; flags.push(passed); } return flags; } renderTokens(tokens) { const breakAfter = this.breakAfterTokenIndexSet; const truncateAfterTokenIndex = typeof this.smartTruncateAfterTokenIndex === "number" ? Math.max( 0, Math.min(this.smartTruncateAfterTokenIndex, tokens.length - 1) ) : null; const hasSmartTruncation = shouldShowSmartEllipsis( this.smartLayoutEnabled, truncateAfterTokenIndex, tokens.length ); const renderEndTokenIndex = hasSmartTruncation ? truncateAfterTokenIndex ?? tokens.length - 1 : tokens.length - 1; const out = []; for (let i2 = 0; i2 <= renderEndTokenIndex; ) { const token = tokens[i2]; if (!token.text) { i2 += 1; continue; } if (token.isWordLike) { let text = token.text; let endIndex = i2; const hasBreakAfterWord = Boolean(breakAfter?.has(i2)); let breakTokenIndex = hasBreakAfterWord ? i2 : null; while (breakTokenIndex === null && endIndex + 1 <= renderEndTokenIndex) { const next = tokens[endIndex + 1]; if (!next || next.isWordLike) break; text += next.text; endIndex += 1; if (breakAfter?.has(endIndex)) { breakTokenIndex = endIndex; break; } } out.push( b`${text}` ); if (breakTokenIndex !== null) { out.push(b`
`); i2 = breakTokenIndex + 1; while (i2 <= renderEndTokenIndex && !tokens[i2]?.isWordLike && !tokens[i2]?.text.trim()) { i2 += 1; } continue; } i2 = endIndex + 1; } else { out.push(token.text); if (breakAfter?.has(i2)) { out.push(b`
`); } i2 += 1; } } if (hasSmartTruncation) { const last = out.at(-1); if (typeof last === "string") { const trimmed = last.replace(/\s+$/u, ""); if (trimmed) out[out.length - 1] = trimmed; else out.pop(); } out.push("…"); } return out; } updatePassedClasses(passedFlags) { const tokenEls = this.renderedTokenEls; const len = Math.min(tokenEls.length, passedFlags.length); for (let i2 = 0; i2 < len; i2 += 1) { tokenEls[i2].classList.toggle("passed", passedFlags[i2]); } for (let i2 = len; i2 < tokenEls.length; i2 += 1) { tokenEls[i2].classList.remove("passed"); } } clearPassedClasses() { for (const tokenEl of this.renderedTokenEls) { tokenEl.classList.remove("passed"); } } setBreakAfterTokenIndices(indices) { this.breakAfterTokenIndices = indices; this.breakAfterTokenIndexSet = indices.length ? new Set(indices) : null; } enqueueWrapRecompute(tokens = null) { if (tokens) { this.lastWrapTokens = tokens; } this.wrapPending = true; this.intervalIdleChecker.requestImmediateTick(); } scheduleWrapRecompute(tokens = null) { this.enqueueWrapRecompute(tokens); } scheduleWrapRecomputeBeforePaint(tokens = null) { this.enqueueWrapRecompute(tokens); this.intervalIdleChecker.requestImmediateTick(); } maybeRefreshPosition(force = false) { if (this.abortController.signal.aborted) return; if (!this.subtitlesContainer) return; const now2 = performance.now(); if (!force && now2 - this.lastPositionRefreshTs < this.positionRefreshIntervalMs) return; this.lastPositionRefreshTs = now2; this.positionRefreshPending = true; this.intervalIdleChecker.requestImmediateTick(); } getMeasureContext(font) { if (!this.measureCanvas) { this.measureCanvas = document.createElement("canvas"); this.measureCanvas.width = 1; this.measureCanvas.height = 1; } if (!this.measureCtx) { this.measureCtx = this.measureCanvas.getContext("2d", { alpha: false }) ?? this.measureCanvas.getContext("2d"); } if (!this.measureCtx) return null; if (typeof font === "string" && font) { this.measureCtx.font = font; } return this.measureCtx; } arraysEqual(a2, b2) { if (a2 === b2) return true; if (a2.length !== b2.length) return false; for (let i2 = 0; i2 < a2.length; i2 += 1) { if (a2[i2] !== b2[i2]) return false; } return true; } recomputeWrapNow() { const tokens = this.lastWrapTokens; const block = this.subtitlesBlock; if (!tokens || !block) return; const lineMeasure = this.getLineMeasureMemo( tokens, this.getActiveLineKey(tokens) ); if (!lineMeasure || lineMeasure.maxWidthPx < 50) return; const { words, metrics, maxWidthPx } = lineMeasure; const safeMaxWidthPx = applyWrapWidthGuard(maxWidthPx); if (words.length <= 1) { if (this.breakAfterTokenIndices.length || this.smartTruncateAfterTokenIndex !== null) { this.resetWrapMemo(); this.resetRenderMemo(); this.update(); } return; } const wrapKey = lineMeasure.key; if (wrapKey === this.lastWrapKey) return; this.lastWrapKey = wrapKey; let nextBreakAfterTokens = []; let nextSmartTruncateAfterTokenIndex = null; const lineFitsOneLine = getWordRangeWidth(metrics, 0, words.length - 1) <= safeMaxWidthPx; if (!lineFitsOneLine) { const breakWordIndices = computeBalancedBreaks( metrics, safeMaxWidthPx ); if (breakWordIndices.length) { nextBreakAfterTokens = breakWordIndices.map( (wordIdx) => words[wordIdx].breakAfterTokenIndex ); } else if (this.smartLayoutEnabled) { const strict = resolveStrictTwoLineLayout(metrics, safeMaxWidthPx); nextBreakAfterTokens = strict.breakAfterWordIndices.map( (wordIdx) => words[wordIdx].breakAfterTokenIndex ); if (strict.truncateAfterWordIndex !== null) { nextSmartTruncateAfterTokenIndex = words[strict.truncateAfterWordIndex]?.breakAfterTokenIndex ?? null; } } } const breaksChanged = !this.arraysEqual( nextBreakAfterTokens, this.breakAfterTokenIndices ); const truncateChanged = nextSmartTruncateAfterTokenIndex !== this.smartTruncateAfterTokenIndex; if (breaksChanged || truncateChanged) { this.setBreakAfterTokenIndices(nextBreakAfterTokens); this.smartTruncateAfterTokenIndex = nextSmartTruncateAfterTokenIndex; this.resetRenderMemo(); this.update(); } } setContent(subtitles, lang2 = void 0) { this.releaseTooltip(); this.subtitleLang = lang2; if (!subtitles || !this.video) { this.clearRenderedContent(); this.subtitles = null; this.clearPendingSchedulerState(); this.video?.removeEventListener("timeupdate", this.onTimeUpdateBound); this.stopVideoFrameLoop(); this.detachDragDocumentListeners(); return; } this.createSubtitlesContainer(); this.subtitles = subtitles; this.lastActiveLineIndex = null; if (!this.useVideoFrameCallbacks) { this.video.addEventListener("timeupdate", this.onTimeUpdateBound, { signal: this.abortController.signal }); } this.syncVideoFrameLoop(); this.updateContainerRect(); this.update(); this.intervalIdleChecker.requestImmediateTick(); } setMaxLength(len) { if (typeof len === "number" && len > 0) { this.manualMaxLength = len; if (!this.smartLayoutEnabled) { this.maxLength = len; this.resetSegmentationMemo(); this.update(); this.scheduleReposition(); } } } setHighlightWords(value) { const wasEnabled = this.highlightWords; this.highlightWords = Boolean(value); if (wasEnabled && !this.highlightWords) { this.clearPassedClasses(); } this.update(); } applyManualFontSizeStyle() { if (!this.subtitlesBlock) return; if (this.fontSizeOverridden) { this.subtitlesBlock.style.setProperty( "--vot-subtitles-font-size", `${this.fontSize}px` ); return; } this.subtitlesBlock.style.removeProperty("--vot-subtitles-font-size"); } setSmartLayout(enabled) { const next = enabled !== false; if (next === this.smartLayoutEnabled) return; this.smartLayoutEnabled = next; this.lastSmartLayoutKey = null; this.resetWrapMemo(); this.resetRenderMemo(); this.resetSegmentationMemo(); if (!this.smartLayoutEnabled) { this.maxLength = this.manualMaxLength; this.applyManualFontSizeStyle(); } this.update(); this.scheduleWrapRecompute(); this.scheduleReposition(); } setFontSize(size) { this.fontSize = size; this.fontSizeOverridden = true; if (!this.smartLayoutEnabled) { this.applyManualFontSizeStyle(); this.lastWrapKey = null; this.resetSegmentationMemo(); this.scheduleWrapRecompute(); this.scheduleReposition(); } } setOpacity(rate) { this.opacity = ((100 - Number(rate)) / 100).toFixed(2); if (this.subtitlesBlock) { this.subtitlesBlock.style.setProperty( "--vot-subtitles-opacity", this.opacity ); } } stringifyTokens(tokens) { return tokens.map((token) => token.text).join(""); } updateMultilineAlignmentIfNeeded(layoutAffectingKey) { const block = this.subtitlesBlock; if (!block) return; if (layoutAffectingKey === this.lastLayoutAffectingKey) return; const cs = getComputedStyle(block); const measureSignature = `${layoutAffectingKey}|${cs.fontSize}|${Math.round( block.clientWidth )}`; this.updateMultilineAlignmentClass(measureSignature); this.lastLayoutAffectingKey = layoutAffectingKey; } updateMultilineAlignmentClass(measureSignature) { const block = this.subtitlesBlock; if (!block) return; if (measureSignature === this.lastMultilineMeasureSignature) return; this.lastMultilineMeasureSignature = measureSignature; const cs = getComputedStyle(block); const lineHeightPx = Number.parseFloat(cs.lineHeight); if (!Number.isFinite(lineHeightPx) || lineHeightPx <= 0) { block.classList.remove("vot-subtitles--multiline"); return; } const paddingTop = Number.parseFloat(cs.paddingTop) || 0; const paddingBottom = Number.parseFloat(cs.paddingBottom) || 0; const contentHeightPx = Math.max( 0, block.clientHeight - paddingTop - paddingBottom ); const lines = Math.max(1, Math.round(contentHeightPx / lineHeightPx)); if (lines > 1) block.classList.add("vot-subtitles--multiline"); else block.classList.remove("vot-subtitles--multiline"); } update() { if (!this.video || !this.subtitles) return; const time = this.video.currentTime * 1e3; const subtitlesList = this.subtitles.subtitles; let line; let lineIndex = -1; const lastIndex = this.lastActiveLineIndex; if (typeof lastIndex === "number" && lastIndex >= 0 && lastIndex < subtitlesList.length) { const candidate = subtitlesList[lastIndex]; if (isTimeInLine(time, candidate)) { line = candidate; lineIndex = lastIndex; } } if (!line) { const index = findActiveSubtitleLineIndex(time, subtitlesList); if (index !== -1) { line = subtitlesList[index]; lineIndex = index; } } if (!line) { this.lastActiveLineIndex = null; if (this.subtitlesBlock || this.lastRenderKey !== null || this.strTokens) { this.clearRenderedContent({ releaseTooltip: true }); } else { this.releaseTooltip(); } return; } this.lastActiveLineIndex = lineIndex; if (this.smartLayoutEnabled) { const now2 = performance.now(); if (this.lastSmartLayoutKey === null || now2 - this.lastSmartLayoutCheckTs > 500) { this.lastSmartLayoutCheckTs = now2; const layout = this.getLayoutSize(); if (layout.w && layout.h) { const anchorBox = this.computeAnchorBoxLayout(layout); if (anchorBox.w && anchorBox.h) { this.ensureSmartLayout(anchorBox); } } } } else { this.maxLength = this.manualMaxLength; } const tokens = this.processTokens(line.tokens, time); this.lastWrapTokens = tokens; const strTokens = this.stringifyTokens(tokens); const tokensChanged = strTokens !== this.strTokens; if (tokensChanged) { this.releaseTooltip(); this.strTokens = strTokens; this.resetTranslationContext(); this.resetWrapMemo(); } const passedFlags = this.highlightWords ? this.buildPassedState(tokens, time) : null; const wrapKey = `${this.breakAfterTokenIndices.join(",")}|${this.smartTruncateAfterTokenIndex ?? ""}`; let effectiveFontSizeKey = 0; if (this.smartLayoutEnabled) { effectiveFontSizeKey = Math.round(this.smartFontSizePx); } else if (this.fontSizeOverridden) { effectiveFontSizeKey = this.fontSize; } const layoutAffectingKey = getLayoutAffectingKey( strTokens, wrapKey, effectiveFontSizeKey ); const renderKey = `${lineIndex}:${strTokens}:${wrapKey}`; if (renderKey === this.lastRenderKey) { if (this.highlightWords && !tokensChanged && passedFlags) { this.updatePassedClasses(passedFlags); } this.updateMultilineAlignmentIfNeeded(layoutAffectingKey); this.maybeRefreshPosition(); return; } this.lastRenderKey = renderKey; this.subtitlesContainer = this.subtitlesContainer ?? this.createSubtitlesContainer(); const styleParts = [`--vot-subtitles-opacity: ${this.opacity}`]; if (this.smartLayoutEnabled) { if (this.smartFontSizePx > 0) styleParts.push(`--vot-subtitles-font-size: ${this.smartFontSizePx}px`); } else if (this.fontSizeOverridden) { styleParts.push(`--vot-subtitles-font-size: ${this.fontSize}px`); } D( b` ${this.renderTokens(tokens)} `, this.subtitlesContainer ); const firstChild = this.subtitlesContainer.firstElementChild; this.subtitlesBlock = firstChild instanceof HTMLElement && firstChild.classList.contains("vot-subtitles") ? firstChild : null; this.renderedTokenEls = this.subtitlesBlock ? Array.from( this.subtitlesBlock.querySelectorAll( 'span[data-vot-token="1"]' ) ) : []; if (this.highlightWords && passedFlags) { this.updatePassedClasses(passedFlags); } this.updateMultilineAlignmentIfNeeded(layoutAffectingKey); if (tokensChanged) { this.applyPositionAfterContentRender(); this.scheduleWrapRecomputeBeforePaint(tokens); this.scheduleReposition(); } else { this.maybeRefreshPosition(); } } release() { this.detachDragDocumentListeners(); this.stopVideoFrameLoop(); this.abortController.abort(); this.resizeObserver?.disconnect(); this.clearPendingSchedulerState(); this.checkerUnsubscribe?.(); this.checkerUnsubscribe = null; this.releaseTooltip(); if (this.subtitlesContainer) { this.subtitlesContainer.remove(); this.subtitlesContainer = null; } if (this.safeAreaProbeEl) { this.safeAreaProbeEl.remove(); this.safeAreaProbeEl = null; } this.measureCtx = null; this.measureCanvas = null; } } function toUint32BE(value) { return new Uint8Array([ value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255 ]); } function toSynchsafeInt(value) { return new Uint8Array([ value >>> 21 & 127, value >>> 14 & 127, value >>> 7 & 127, value & 127 ]); } function addTitleId3Tag(mp3Buffer, title) { const titleBytes = new TextEncoder().encode(title); const frameData = new Uint8Array(titleBytes.length + 1); frameData[0] = 3; frameData.set(titleBytes, 1); const frame = new Uint8Array(10 + frameData.length); frame.set([84, 73, 84, 50], 0); frame.set(toUint32BE(frameData.length), 4); frame.set(frameData, 10); const header = new Uint8Array(10); header.set([73, 68, 51, 3, 0, 0], 0); header.set(toSynchsafeInt(frame.length), 6); const audioBytes = new Uint8Array(mp3Buffer); const out = new Uint8Array(header.length + frame.length + audioBytes.length); out.set(header, 0); out.set(frame, header.length); out.set(audioBytes, header.length + frame.length); return new Blob([out], { type: "audio/mpeg" }); } async function readResponseArrayBuffer(res, onProgress) { const total = Number(res.headers.get("Content-Length") ?? 0); if (!res.body) return res.arrayBuffer(); const reader = res.body.getReader(); let loaded = 0; let out = total > 0 ? new Uint8Array(total) : null; const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; if (!value || value.byteLength === 0) continue; if (out) { const needed = loaded + value.byteLength; if (needed > out.length) { const grown = new Uint8Array(Math.max(needed, out.length * 2)); grown.set(out.subarray(0, loaded)); out = grown; } out.set(value, loaded); loaded = needed; } else { chunks.push(value); loaded += value.byteLength; } if (total > 0) { onProgress(clamp$2(Math.round(loaded / total * 100))); } } if (out) { return out.buffer.slice(0, loaded); } const merged = new Uint8Array(loaded); let offset = 0; for (const c2 of chunks) { merged.set(c2, offset); offset += c2.byteLength; } return merged.buffer; } async function downloadTranslation(res, filename, onProgress = () => { }) { const blob = await buildTranslationBlob(res, filename, onProgress); downloadBlob(blob, `${filename}.mp3`); return true; } async function buildTranslationBlob(res, filename, onProgress = () => { }) { const arrayBuffer = await readResponseArrayBuffer(res, onProgress); onProgress(100); return addTitleId3Tag(arrayBuffer, filename); } const TRANSLATE_ICON_SVG = w` `; const PIP_ICON_SVG = w` `; const MENU_ICON = w` `; const DOWNLOAD_ICON = w` `; const SUBTITLES_ICON = w` `; const SETTINGS_ICON = w` `; const CHEVRON_ICON = w` `; const ARROW_RIGHT_ICON = w` `; const CLOSE_ICON = w` `; const WARNING_ICON = w` `; const HELP_ICON = w` `; const REFRESH_ICON = w` `; const KEY_ICON = w` `; class VOTButton { container; translateButton; separator; pipButton; separator2; menuButton; label; _opacity = 1; _position; _direction; _status; _labelText; constructor({ position: position2 = "default", direction = "default", status = "none", labelHtml = "" }) { this._position = position2; this._direction = direction; this._status = status; this._labelText = labelHtml; const elements = this.createElements(); this.container = elements.container; this.translateButton = elements.translateButton; this.separator = elements.separator; this.pipButton = elements.pipButton; this.separator2 = elements.separator2; this.menuButton = elements.menuButton; this.label = elements.label; } static calcPosition(percentX, isBigContainer) { if (!isBigContainer) { return "default"; } if (percentX <= 44) { return "left"; } if (percentX >= 66) { return "right"; } return "default"; } static calcDirection(position2) { return ["default", "top"].includes(position2) ? "row" : "column"; } createElements() { const container = UI.createEl("vot-block", ["vot-segmented-button"]); container.dataset.position = this._position; container.dataset.direction = this._direction; container.dataset.status = this._status; const translateButton = UI.createEl("vot-block", [ "vot-segment", "vot-translate-button" ]); translateButton.setAttribute("role", "button"); translateButton.tabIndex = 0; translateButton.setAttribute("aria-label", this._labelText || "Translate"); D(TRANSLATE_ICON_SVG, translateButton); const label = UI.createEl("span", ["vot-segment-label"]); label.textContent = this._labelText; translateButton.appendChild(label); const separator = UI.createEl("vot-block", ["vot-separator"]); const pipButton = UI.createEl("vot-block", ["vot-segment-only-icon"]); pipButton.setAttribute("role", "button"); pipButton.tabIndex = 0; pipButton.setAttribute("aria-label", "Picture in picture"); D(PIP_ICON_SVG, pipButton); const separator2 = UI.createEl("vot-block", ["vot-separator"]); const menuButton = UI.createEl("vot-block", ["vot-segment-only-icon"]); menuButton.setAttribute("role", "button"); menuButton.tabIndex = 0; menuButton.setAttribute("aria-label", "Menu"); menuButton.setAttribute("aria-haspopup", "dialog"); menuButton.setAttribute("aria-expanded", "false"); D(MENU_ICON, menuButton); container.append( translateButton, separator, pipButton, separator2, menuButton ); return { container, translateButton, separator, pipButton, separator2, menuButton, label }; } showPiPButton(visible) { this.separator2.hidden = this.pipButton.hidden = !visible; return this; } setText(labelText) { this._labelText = labelText; this.label.textContent = labelText; this.translateButton.setAttribute("aria-label", labelText || "Translate"); return this; } remove() { this.container.remove(); return this; } get tooltipPos() { switch (this.position) { case "left": return "right"; case "right": return "left"; default: return "bottom"; } } set status(status) { this._status = this.container.dataset.status = status; } get status() { return this._status; } set loading(isLoading) { this.container.dataset.loading = isLoading.toString(); } get loading() { return this.container.dataset.loading === "true"; } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } get position() { return this._position; } set position(position2) { this._position = this.container.dataset.position = position2; } get direction() { return this._direction; } set direction(direction) { this._direction = this.container.dataset.direction = direction; } set opacity(opacity) { const o2 = Number.isFinite(opacity) ? opacity : 1; this._opacity = o2; const isHidden = o2 <= 0.01; this.container.classList.toggle("vot-segmented-button--hidden", isHidden); } get opacity() { return this._opacity; } } class DownloadButton { button; loaderMain; loaderCircle; onClick = new EventImpl(); events = { click: this.onClick }; _progress = 0; constructor() { const elements = this.createElements(); this.button = elements.button; this.loaderMain = elements.loaderMain; this.loaderCircle = elements.loaderCircle; this.progress = 0; } createElements() { const button = UI.createIconButton(DOWNLOAD_ICON, { ariaLabel: "Download translation" }); const loaderMain = button.querySelector(".vot-loader-main"); if (!loaderMain) { throw new Error("[VOT] DownloadButton loader main element not found"); } const loaderCircle = button.querySelector( ".vot-loader-progress" ); if (!loaderCircle) { throw new Error("[VOT] DownloadButton loader circle element not found"); } button.addEventListener("click", () => { this.onClick.dispatch(); }); return { button, loaderMain, loaderCircle }; } addEventListener(_type, listener) { addComponentEventListener(this.events, "click", listener); return this; } removeEventListener(_type, listener) { removeComponentEventListener(this.events, "click", listener); return this; } get progress() { return this._progress; } set progress(value) { const normalized = clampProgress(value); this._progress = normalized; const circumference = this.getCircleCircumference(); this.loaderCircle.style.strokeDasharray = `${circumference}`; const offset = circumference * (1 - normalized / 100); this.loaderCircle.style.strokeDashoffset = `${offset}`; this.loaderMain.style.opacity = normalized === 0 ? "1" : "0"; this.loaderCircle.style.opacity = normalized === 0 ? "0" : "1"; } getCircleCircumference() { const radius = this.loaderCircle.r?.baseVal?.value ?? 0; return 2 * Math.PI * radius; } set hidden(isHidden) { setHiddenState(this.button, isHidden); } get hidden() { return getHiddenState(this.button); } } function clampProgress(value) { if (!Number.isFinite(value)) return 0; const asPercent = value < 1 ? value * 100 : value; return Math.max(0, Math.min(100, Math.round(asPercent))); } class Label { container; icon; text; _labelText; _icon; constructor({ labelText, icon }) { this._labelText = labelText; this._icon = icon; const elements = this.createElements(); this.container = elements.container; this.icon = elements.icon; this.text = elements.text; } createElements() { const container = UI.createEl("vot-block", ["vot-label"]); const text = UI.createEl("span", ["vot-label-text"]); text.textContent = this._labelText; const icon = UI.createEl("span", ["vot-label-icon"]); if (this._icon) { D(this._icon, icon); } else { icon.hidden = true; } container.append(text, icon); return { container, icon, text }; } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } } class Dialog { container; backdrop; box; contentWrapper; headerContainer; titleContainer; title; closeButton; bodyContainer; footerContainer; onClose = new EventImpl(); events = { close: this.onClose }; previouslyFocused = null; keydownListener; adaptiveAlignObserver; adaptiveAlignRaf = null; handleViewportChange = () => { this.scheduleAdaptiveVerticalAlign(); }; titleId = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `vot-dialog-title-${Math.random().toString(36).slice(2)}`; _titleHtml; _isTemp; constructor({ titleHtml, isTemp = false }) { this._titleHtml = titleHtml; this._isTemp = isTemp; const elements = this.createElements(); this.container = elements.container; this.backdrop = elements.backdrop; this.box = elements.box; this.contentWrapper = elements.contentWrapper; this.headerContainer = elements.headerContainer; this.titleContainer = elements.titleContainer; this.title = elements.title; this.closeButton = elements.closeButton; this.bodyContainer = elements.bodyContainer; this.footerContainer = elements.footerContainer; } createElements() { const container = UI.createEl("vot-block", ["vot-dialog-container"]); if (this._isTemp) { container.classList.add("vot-dialog-temp"); } container.hidden = !this._isTemp; container.setAttribute("aria-hidden", container.hidden ? "true" : "false"); container.toggleAttribute("inert", container.hidden); const backdrop = UI.createEl("vot-block", ["vot-dialog-backdrop"]); const box = UI.createEl("vot-block", ["vot-dialog"]); box.dataset.verticalAlign = "center"; box.setAttribute("role", "dialog"); box.setAttribute("aria-modal", "true"); box.tabIndex = -1; const contentWrapper = UI.createEl("vot-block", [ "vot-dialog-content-wrapper" ]); const headerContainer = UI.createEl("vot-block", [ "vot-dialog-header-container" ]); const titleContainer = UI.createEl("vot-block", [ "vot-dialog-title-container" ]); const title = UI.createEl("vot-block", ["vot-dialog-title"]); title.id = this.titleId; title.append(this._titleHtml); titleContainer.appendChild(title); box.setAttribute("aria-labelledby", this.titleId); const closeButton = UI.createIconButton(CLOSE_ICON, { ariaLabel: "Close" }); closeButton.classList.add("vot-dialog-close-button"); backdrop.addEventListener("click", () => { this.close(); }); closeButton.addEventListener("click", () => { this.close(); }); headerContainer.append(titleContainer, closeButton); const bodyContainer = UI.createEl("vot-block", [ "vot-dialog-body-container" ]); const footerContainer = UI.createEl("vot-block", [ "vot-dialog-footer-container" ]); contentWrapper.append(headerContainer, bodyContainer, footerContainer); box.appendChild(contentWrapper); container.append(backdrop, box); box.addEventListener("click", (e2) => { e2.stopPropagation(); }); return { container, backdrop, box, contentWrapper, headerContainer, titleContainer, title, closeButton, bodyContainer, footerContainer }; } addEventListener(_type, listener) { addComponentEventListener(this.events, "close", listener); return this; } removeEventListener(_type, listener) { removeComponentEventListener(this.events, "close", listener); return this; } open() { this.previouslyFocused ??= document.activeElement; this.hidden = false; this.attachKeydownTrap(); this.attachAdaptiveVerticalAlign(); queueMicrotask(() => this.focusFirst()); return this; } remove() { this.detachAdaptiveVerticalAlign(); this.detachKeydownTrap(); this.container.remove(); this.restoreFocus(); this.onClose.dispatch(); return this; } close() { if (this._isTemp) { return this.remove(); } this.detachAdaptiveVerticalAlign(); this.detachKeydownTrap(); this.hidden = true; this.restoreFocus(); this.onClose.dispatch(); return this; } attachAdaptiveVerticalAlign() { if (this.adaptiveAlignObserver) { this.scheduleAdaptiveVerticalAlign(); return; } if (typeof ResizeObserver !== "undefined") { this.adaptiveAlignObserver = new ResizeObserver(() => { this.scheduleAdaptiveVerticalAlign(); }); this.adaptiveAlignObserver.observe(this.contentWrapper); } globalThis.addEventListener("resize", this.handleViewportChange, { passive: true }); if (globalThis.visualViewport) { globalThis.visualViewport.addEventListener( "resize", this.handleViewportChange, { passive: true } ); globalThis.visualViewport.addEventListener( "scroll", this.handleViewportChange, { passive: true } ); } this.scheduleAdaptiveVerticalAlign(); } detachAdaptiveVerticalAlign() { if (this.adaptiveAlignObserver) { this.adaptiveAlignObserver.disconnect(); this.adaptiveAlignObserver = void 0; } globalThis.removeEventListener("resize", this.handleViewportChange); globalThis.visualViewport?.removeEventListener( "resize", this.handleViewportChange ); globalThis.visualViewport?.removeEventListener( "scroll", this.handleViewportChange ); if (this.adaptiveAlignRaf !== null) { cancelAnimationFrame(this.adaptiveAlignRaf); this.adaptiveAlignRaf = null; } } scheduleAdaptiveVerticalAlign() { if (this.adaptiveAlignRaf !== null) { cancelAnimationFrame(this.adaptiveAlignRaf); } this.adaptiveAlignRaf = requestAnimationFrame(() => { this.adaptiveAlignRaf = null; this.updateAdaptiveVerticalAlign(); }); } updateAdaptiveVerticalAlign() { const viewportHeight = globalThis.visualViewport?.height ?? globalThis.innerHeight; if (!viewportHeight || viewportHeight <= 0) { return; } const marginPx = 16; const centerMaxPx = Math.max(160, Math.round(viewportHeight * 0.75)); const topMaxPx = Math.max(160, Math.round(viewportHeight - marginPx * 2)); const contentHeightPx = this.contentWrapper.scrollHeight; const currentlyTop = this.box.dataset.verticalAlign === "top"; const enterTopThresholdPx = centerMaxPx - 8; const exitTopThresholdPx = Math.round(viewportHeight * 0.6); const shouldTop = currentlyTop ? contentHeightPx > exitTopThresholdPx : contentHeightPx >= enterTopThresholdPx; if (shouldTop) { this.box.dataset.verticalAlign = "top"; this.box.style.setProperty("--vot-dialog-max-height", `${topMaxPx}px`); } else { this.box.dataset.verticalAlign = "center"; this.box.style.setProperty("--vot-dialog-max-height", `${centerMaxPx}px`); } } restoreFocus() { const el = this.previouslyFocused; this.previouslyFocused = null; if (el && el instanceof HTMLElement && document.contains(el)) { el.focus(); } } getFocusableElements() { const selectors = [ "button:not([disabled])", "[href]", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])", "[role='button']:not([aria-disabled='true'])" ]; return Array.from( this.container.querySelectorAll(selectors.join(",")) ).filter((el) => !el.hidden && el.getClientRects().length > 0); } focusFirst() { const focusables = this.getFocusableElements(); (focusables[0] ?? this.closeButton ?? this.box).focus?.(); } attachKeydownTrap() { if (this.keydownListener) return; this.keydownListener = (e2) => { if (e2.key === "Escape") { e2.preventDefault(); this.close(); return; } if (e2.key !== "Tab") { return; } const focusables = this.getFocusableElements(); if (!focusables.length) { e2.preventDefault(); this.box.focus(); return; } const first = focusables[0]; const last = focusables.at(-1) ?? first; const active = document.activeElement; if (e2.shiftKey) { if (active === first || active === this.box) { e2.preventDefault(); last.focus(); } } else if (active === last) { e2.preventDefault(); first.focus(); } }; this.container.addEventListener("keydown", this.keydownListener); } detachKeydownTrap() { if (!this.keydownListener) return; this.container.removeEventListener("keydown", this.keydownListener); this.keydownListener = void 0; } set hidden(isHidden) { setHiddenState(this.container, isHidden); this.container.setAttribute("aria-hidden", isHidden ? "true" : "false"); this.container.toggleAttribute("inert", isHidden); } get hidden() { return getHiddenState(this.container); } get isDialogOpen() { return !this.container.hidden; } } class Textfield { container; input; label; onInput = new EventImpl(); onChange = new EventImpl(); events = { input: this.onInput, change: this.onChange }; _labelHtml; _multiline; _placeholder; _value; constructor({ labelHtml = "", placeholder = "", value = "", multiline = false }) { this._labelHtml = labelHtml; this._multiline = multiline; this._placeholder = placeholder; this._value = value; const elements = this.createElements(); this.container = elements.container; this.input = elements.input; this.label = elements.label; } createElements() { const container = UI.createEl("vot-block", ["vot-textfield"]); const input = document.createElement( this._multiline ? "textarea" : "input" ); if (!this._labelHtml) { input.classList.add("vot-show-placeholer", "vot-show-placeholder"); } input.placeholder = this._placeholder; input.value = this._value; const label = UI.createEl("span"); label.append(this._labelHtml); container.append(input, label); input.addEventListener("input", () => { this._value = this.input.value; this.onInput.dispatch(this._value); }); input.addEventListener("change", () => { this._value = this.input.value; this.onChange.dispatch(this._value); }); return { container, label, input }; } addEventListener(type, listener) { addComponentEventListener(this.events, type, listener); return this; } removeEventListener(type, listener) { removeComponentEventListener(this.events, type, listener); return this; } get value() { return this._value; } set value(val) { if (this._value === val) { return; } this.input.value = this._value = val; this.onChange.dispatch(this._value); } get placeholder() { return this._placeholder; } set placeholder(text) { this.input.placeholder = this._placeholder = text; } get disabled() { return this.input.disabled; } set disabled(isDisabled) { this.input.disabled = isDisabled; } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } } class Select { container; outer; arrowIcon; title; dialogParent; labelElement; _selectTitle; _dialogTitle; multiSelect; _items; isLoading = false; isDialogOpen = false; onSelectItem = new EventImpl(); onBeforeOpen = new EventImpl(); events = { selectItem: this.onSelectItem, beforeOpen: this.onBeforeOpen }; contentList; selectedItems = []; selectedValues; constructor({ selectTitle, dialogTitle, items, labelElement, dialogParent = document.documentElement, multiSelect }) { this._selectTitle = selectTitle; this._dialogTitle = dialogTitle; this._items = items; this.multiSelect = multiSelect ?? false; this.labelElement = labelElement; this.dialogParent = dialogParent; this.selectedValues = this.calcSelectedValues(); const elements = this.createElements(); this.container = elements.container; this.outer = elements.outer; this.arrowIcon = elements.arrowIcon; this.title = elements.title; } static genLanguageItems(langs2, conditionString) { return langs2.map((lang2) => { const phrase = `langs.${lang2}`; const label = localizationProvider.get(phrase); return { label: label === phrase ? lang2.toUpperCase() : label, value: lang2, selected: conditionString === lang2 }; }); } multiSelectItemHandle = (contentItem, item) => { const value = item.value; if (this.selectedValues.has(value) && this.selectedValues.size > 1) { this.selectedValues.delete(value); item.selected = false; } else { this.selectedValues.add(value); item.selected = true; } contentItem.dataset.votSelected = this.selectedValues.has(value).toString(); this.updateSelectedState(); this.onSelectItem.dispatch(Array.from(this.selectedValues)); }; singleSelectItemHandle = (item) => { const value = item.value; this.selectedValues = new Set([value]); for (const contentItem of this.selectedItems) { contentItem.dataset.votSelected = (contentItem.dataset.votValue === value).toString(); } for (const item2 of this._items) { item2.selected = item2.value === value; } this.updateTitle(); this.onSelectItem.dispatch(value); }; createDialogContentList() { const contentList = UI.createEl("vot-block", ["vot-select-content-list"]); for (const item of this._items) { const contentItem = UI.createEl("vot-block", ["vot-select-content-item"]); contentItem.textContent = item.label; contentItem.dataset.votSelected = item.selected === true ? "true" : "false"; contentItem.dataset.votValue = item.value; if (item.disabled) { contentItem.inert = true; } contentItem.addEventListener("click", (e2) => { if (e2.target.inert) { return; } if (this.multiSelect) { return this.multiSelectItemHandle(contentItem, item); } return this.singleSelectItemHandle(item); }); contentList.appendChild(contentItem); } this.selectedItems = Array.from(contentList.children); return contentList; } createElements() { const container = UI.createEl("vot-block", ["vot-select"]); if (this.labelElement) { container.classList.add("vot-select--labeled"); container.append(this.labelElement); } else { container.classList.add("vot-select--control-only"); } const outer = UI.createEl("vot-block", ["vot-select-outer"]); UI.makeButtonLike(outer); outer.setAttribute("aria-haspopup", "dialog"); outer.setAttribute("aria-expanded", "false"); const title = UI.createEl("vot-block", ["vot-select-title"]); title.textContent = this.visibleText; const arrowIcon = UI.createEl("vot-block", ["vot-select-arrow-icon"]); D(CHEVRON_ICON, arrowIcon); outer.append(title, arrowIcon); outer.addEventListener("click", () => { const isDisabled = outer.getAttribute("disabled") === "true" || outer.getAttribute("aria-disabled") === "true"; if (isDisabled) { return; } if (this.isLoading || this.isDialogOpen) { return; } try { this.isLoading = true; const tempDialog = new Dialog({ titleHtml: this._dialogTitle, isTemp: true }); this.onBeforeOpen.dispatch(tempDialog); this.dialogParent.appendChild(tempDialog.container); this.isDialogOpen = true; outer.setAttribute("aria-expanded", "true"); const votSearchLangTextfield = new Textfield({ labelHtml: localizationProvider.get("searchField") }); votSearchLangTextfield.addEventListener("input", (searchText) => { const normalizedSearchText = searchText.toLowerCase(); for (const contentItem of this.selectedItems) { contentItem.hidden = !contentItem.textContent?.toLowerCase().includes(normalizedSearchText); } }); this.contentList = this.createDialogContentList(); tempDialog.bodyContainer.append( votSearchLangTextfield.container, this.contentList ); tempDialog.addEventListener("close", () => { this.isDialogOpen = false; this.selectedItems = []; this.contentList = void 0; outer.setAttribute("aria-expanded", "false"); }); tempDialog.open(); } finally { this.isLoading = false; } }); container.appendChild(outer); return { container, outer, arrowIcon, title }; } calcSelectedValues() { return new Set( this._items.filter((item) => item.selected).map((item) => item.value) ); } addEventListener(type, listener) { addComponentEventListener(this.events, type, listener); return this; } removeEventListener(type, listener) { removeComponentEventListener(this.events, type, listener); return this; } updateTitle() { this.title.textContent = this.visibleText; return this; } updateSelectedState() { if (this.selectedItems.length > 0) { for (const item of this.selectedItems) { const val = item.dataset.votValue; if (!val) { continue; } item.dataset.votSelected = this.selectedValues.has(val).toString(); } } this.updateTitle(); return this; } setSelectedValue(value) { if (this.multiSelect) { this.selectedValues = new Set( Array.isArray(value) ? value.map(String) : [String(value)] ); } else { this.selectedValues = new Set([String(value)]); } for (const item of this._items) { item.selected = this.selectedValues.has(String(item.value)); } this.updateSelectedState(); return this; } updateItems(newItems) { this._items = newItems; this.selectedValues = this.calcSelectedValues(); this.updateSelectedState(); const dialogContainer = this.contentList?.parentElement; if (!this.contentList || !dialogContainer) { return this; } const oldContentList = this.contentList; this.contentList = this.createDialogContentList(); dialogContainer.replaceChild(this.contentList, oldContentList); return this; } get visibleText() { if (!this.multiSelect) { return this._items.find((item) => item.selected)?.label ?? this._selectTitle; } return this._items.filter((item) => this.selectedValues.has(item.value)).map((item) => item.label).join(", ") || this._selectTitle; } set selectTitle(title) { this._selectTitle = title; this.updateTitle(); } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } get disabled() { return this.outer.getAttribute("disabled") === "true"; } set disabled(isDisabled) { this.outer.toggleAttribute("disabled", isDisabled); } } class LanguagePairSelect { container; fromSelect; directionIcon; toSelect; dialogParent; _fromSelectTitle; _fromDialogTitle; _fromItems; _toSelectTitle; _toDialogTitle; _toItems; constructor({ from: { selectTitle: fromSelectTitle = localizationProvider.get("videoLanguage"), dialogTitle: fromDialogTitle = localizationProvider.get("videoLanguage"), items: fromItems }, to: { selectTitle: toSelectTitle = localizationProvider.get( "translationLanguage" ), dialogTitle: toDialogTitle = localizationProvider.get( "translationLanguage" ), items: toItems }, dialogParent = document.documentElement }) { this._fromSelectTitle = fromSelectTitle; this._fromDialogTitle = fromDialogTitle; this._fromItems = fromItems; this._toSelectTitle = toSelectTitle; this._toDialogTitle = toDialogTitle; this._toItems = toItems; this.dialogParent = dialogParent; const elements = this.createElements(); this.container = elements.container; this.fromSelect = elements.fromSelect; this.directionIcon = elements.directionIcon; this.toSelect = elements.toSelect; } createElements() { const container = UI.createEl("vot-block", ["vot-lang-select"]); const fromSelect = new Select({ selectTitle: this._fromSelectTitle, dialogTitle: this._fromDialogTitle, items: this._fromItems, dialogParent: this.dialogParent }); const directionIcon = UI.createEl("vot-block", ["vot-lang-select-icon"]); D(ARROW_RIGHT_ICON, directionIcon); const toSelect = new Select({ selectTitle: this._toSelectTitle, dialogTitle: this._toDialogTitle, items: this._toItems, dialogParent: this.dialogParent }); container.append(fromSelect.container, directionIcon, toSelect.container); return { container, fromSelect, directionIcon, toSelect }; } setSelectedValues(from, to) { this.fromSelect.setSelectedValue(from); this.toSelect.setSelectedValue(to); return this; } updateItems(fromItems, toItems) { this._fromItems = fromItems; this._toItems = toItems; this.fromSelect = this.fromSelect.updateItems(fromItems); this.toSelect = this.toSelect.updateItems(toItems); return this; } } class Slider { container; input; label; onInput = new EventImpl(); _labelHtml; _value; _min; _max; _step; constructor({ labelHtml, value = 50, min = 0, max = 100, step = 1 }) { this._labelHtml = labelHtml; this._value = value; this._min = min; this._max = max; this._step = step; const elements = this.createElements(); this.container = elements.container; this.input = elements.input; this.label = elements.label; this.update(); } updateProgress() { const range = this._max - this._min; const raw = range <= 0 ? 0 : (this._value - this._min) / range; const progress = Math.max(0, Math.min(1, raw)); this.container.style.setProperty("--vot-progress", progress.toString()); return this; } update() { this._value = this.input.valueAsNumber; this._min = +this.input.min; this._max = +this.input.max; this.updateProgress(); return this; } createElements() { const container = UI.createEl("vot-block", ["vot-slider"]); const input = document.createElement("input"); input.type = "range"; input.min = this._min.toString(); input.max = this._max.toString(); input.step = this._step.toString(); input.value = this._value.toString(); const label = UI.createEl("span"); D(this._labelHtml, label); container.append(input, label); input.addEventListener("input", () => { this.update(); this.onInput.dispatch(this._value, false); }); return { container, label, input }; } addEventListener(_type, listener) { this.onInput.addListener(listener); return this; } removeEventListener(_type, listener) { this.onInput.removeListener(listener); return this; } get value() { return this._value; } set value(val) { this._value = clampNumber(val, this._min, this._max); this.input.value = this._value.toString(); this.updateProgress(); this.onInput.dispatch(this._value, true); } get min() { return this._min; } set min(val) { this._min = val; this.input.min = this._min.toString(); this._value = clampNumber(this._value, this._min, this._max); this.input.value = this._value.toString(); this.updateProgress(); } get max() { return this._max; } set max(val) { this._max = val; this.input.max = this._max.toString(); this._value = clampNumber(this._value, this._min, this._max); this.input.value = this._value.toString(); this.updateProgress(); } get step() { return this._step; } set step(val) { this._step = val; this.input.step = this._step.toString(); } get disabled() { return this.input.disabled; } set disabled(isDisabled) { this.input.disabled = isDisabled; } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } } function clampNumber(value, min, max) { if (!Number.isFinite(value)) return min; if (max < min) return min; return Math.max(min, Math.min(max, value)); } class SliderLabel { container; strong; text; _labelText; _labelEOL; _value; _symbol; constructor({ labelText, labelEOL = "", value = 50, symbol: symbol2 = "%" }) { this._labelText = labelText; this._labelEOL = labelEOL; this._value = value; this._symbol = symbol2; const elements = this.createElements(); this.container = elements.container; this.strong = elements.strong; this.text = elements.text; } createElements() { const container = UI.createEl("vot-block", ["vot-slider-label"]); const text = UI.createEl("span", ["vot-slider-label-text"]); text.textContent = this.labelText; const strong = UI.createEl("span", ["vot-slider-label-value"]); strong.textContent = this.valueText; container.append(text, strong); return { container, strong, text }; } get labelText() { return `${this._labelText}${this._labelEOL}`; } get valueText() { return `${this._value}${this._symbol}`; } get value() { return this._value; } set value(val) { this._value = val; this.strong.textContent = this.valueText; } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } } class VOTMenu { container; contentWrapper; headerContainer; bodyContainer; footerContainer; titleContainer; title; _position; _titleHtml; menuId = typeof crypto !== "undefined" && "randomUUID" in crypto ? `vot-menu-${crypto.randomUUID()}` : `vot-menu-${Math.random().toString(36).slice(2)}`; titleId = typeof crypto !== "undefined" && "randomUUID" in crypto ? `vot-menu-title-${crypto.randomUUID()}` : `vot-menu-title-${Math.random().toString(36).slice(2)}`; constructor({ position: position2 = "default", titleHtml = "" }) { this._position = position2; this._titleHtml = titleHtml; const elements = this.createElements(); this.container = elements.container; this.contentWrapper = elements.contentWrapper; this.headerContainer = elements.headerContainer; this.bodyContainer = elements.bodyContainer; this.footerContainer = elements.footerContainer; this.titleContainer = elements.titleContainer; this.title = elements.title; } createElements() { const container = UI.createEl("vot-block", ["vot-menu"]); container.hidden = true; container.id = this.menuId; container.dataset.position = this._position; container.setAttribute("role", "dialog"); container.setAttribute("aria-modal", "false"); container.setAttribute("aria-hidden", "true"); container.toggleAttribute("inert", true); const contentWrapper = UI.createEl("vot-block", [ "vot-menu-content-wrapper" ]); container.appendChild(contentWrapper); const headerContainer = UI.createEl("vot-block", [ "vot-menu-header-container" ]); const titleContainer = UI.createEl("vot-block", [ "vot-menu-title-container" ]); headerContainer.appendChild(titleContainer); const title = UI.createEl("vot-block", ["vot-menu-title"]); title.id = this.titleId; title.append(this._titleHtml); titleContainer.appendChild(title); container.setAttribute("aria-labelledby", this.titleId); const bodyContainer = UI.createEl("vot-block", ["vot-menu-body-container"]); const footerContainer = UI.createEl("vot-block", [ "vot-menu-footer-container" ]); contentWrapper.append(headerContainer, bodyContainer, footerContainer); return { container, contentWrapper, headerContainer, bodyContainer, footerContainer, titleContainer, title }; } setText(titleText) { this._titleHtml = this.title.textContent = titleText; return this; } remove() { this.container.remove(); return this; } set hidden(isHidden) { setHiddenState(this.container, isHidden); this.container.setAttribute("aria-hidden", isHidden ? "true" : "false"); this.container.toggleAttribute("inert", isHidden); } get hidden() { return getHiddenState(this.container); } get position() { return this._position; } set position(position2) { this._position = this.container.dataset.position = position2; } } class OverlayView { mount; globalPortal; abortController = null; defaultVolumePersistTimer; defaultVolumePersistDelayMs = 250; dragging = false; dragCandidate = false; dragDirty = false; dragStartX = 0; dragStartY = 0; currentClientX = 0; dragThresholdPx = 6; containerRect = null; dragIsBigContainer = null; checkerUnsubscribe = null; initialized = false; data; videoHandler; intervalIdleChecker; events = { "click:settings": new EventImpl(), "click:pip": new EventImpl(), "click:downloadTranslation": new EventImpl(), "click:downloadSubtitles": new EventImpl(), "click:translate": new EventImpl(), "input:videoVolume": new EventImpl(), "input:translationVolume": new EventImpl(), "select:fromLanguage": new EventImpl(), "select:toLanguage": new EventImpl(), "select:subtitles": new EventImpl() }; votOverlayPortal; votButton; votButtonTooltip; votMenu; downloadTranslationButton; downloadSubtitlesButton; openSettingsButton; languagePairSelect; subtitlesSelectLabel; subtitlesSelect; videoVolumeSliderLabel; videoVolumeSlider; translationVolumeSliderLabel; translationVolumeSlider; constructor({ mount, globalPortal, data = {}, videoHandler, intervalIdleChecker }) { this.mount = mount; this.globalPortal = globalPortal; this.data = data; this.videoHandler = videoHandler; this.intervalIdleChecker = intervalIdleChecker; } get root() { return this.mount.root; } get portalContainer() { return this.mount.portalContainer; } get tooltipLayoutRoot() { return this.mount.tooltipLayoutRoot; } updateMount(nextMount) { const prevRoot = this.mount.root; const nextRoot = nextMount.root; const prevPortal = this.mount.portalContainer; const nextPortal = nextMount.portalContainer; const prevTooltipRoot = this.mount.tooltipLayoutRoot; const nextTooltipRoot = nextMount.tooltipLayoutRoot; this.mount = nextMount; if (!this.isInitialized()) { return this; } if (this.votOverlayPortal && prevPortal !== nextPortal) { nextPortal.appendChild(this.votOverlayPortal); } if (prevRoot !== nextRoot) { if (this.votButton) { nextRoot.appendChild(this.votButton.container); } if (this.votMenu) { nextRoot.appendChild(this.votMenu.container); } } if (this.votButtonTooltip && prevTooltipRoot !== nextTooltipRoot) { this.votButtonTooltip.updateMount({ layoutRoot: nextTooltipRoot ?? document.documentElement }); } return this; } isInitialized() { return this.initialized; } calcButtonLayout(position2) { if (this.isBigContainer && ["left", "right"].includes(position2)) { return { direction: "column", position: position2 }; } return { direction: "row", position: "default" }; } addEventListener(type, listener) { this.events[type].addListener(listener); return this; } removeEventListener(type, listener) { this.events[type].removeListener(listener); return this; } scheduleDefaultVolumePersist() { if (this.defaultVolumePersistTimer !== void 0) { globalThis.clearTimeout(this.defaultVolumePersistTimer); } this.defaultVolumePersistTimer = globalThis.setTimeout(() => { this.defaultVolumePersistTimer = void 0; this.flushDefaultVolumePersist(); }, this.defaultVolumePersistDelayMs); } flushDefaultVolumePersist() { if (this.defaultVolumePersistTimer !== void 0) { globalThis.clearTimeout(this.defaultVolumePersistTimer); this.defaultVolumePersistTimer = void 0; } if (typeof this.data.defaultVolume !== "number") { return; } void votStorage.set("defaultVolume", this.data.defaultVolume); } initUI(buttonPosition2 = "default") { if (this.isInitialized()) { throw new Error("[VOT] OverlayView is already initialized"); } this.initialized = true; const { position: position2, direction } = this.calcButtonLayout(buttonPosition2); this.votOverlayPortal = UI.createPortal(true); this.portalContainer.appendChild(this.votOverlayPortal); this.votButton = new VOTButton({ position: position2, direction, status: "none", labelHtml: localizationProvider.get("translateVideo") }); this.votButton.opacity = 0; if (!this.pipButtonVisible) { this.votButton.showPiPButton(false); } this.root.appendChild(this.votButton.container); this.votButtonTooltip = new Tooltip({ target: this.votButton.translateButton, content: localizationProvider.get("translateVideo"), position: this.votButton.tooltipPos, hidden: direction === "row", bordered: false, parentElement: this.votOverlayPortal, layoutRoot: this.tooltipLayoutRoot }); this.votMenu = new VOTMenu({ titleHtml: localizationProvider.get("VOTSettings"), position: position2 }); this.root.appendChild(this.votMenu.container); this.votButton.menuButton.setAttribute( "aria-controls", this.votMenu.container.id ); this.downloadTranslationButton = new DownloadButton(); this.downloadTranslationButton.hidden = true; this.downloadSubtitlesButton = UI.createIconButton(SUBTITLES_ICON, { ariaLabel: "Download subtitles" }); this.downloadSubtitlesButton.hidden = true; this.openSettingsButton = UI.createIconButton(SETTINGS_ICON, { ariaLabel: localizationProvider.get("VOTSettings") }); this.votMenu.headerContainer.append( this.downloadTranslationButton.button, this.downloadSubtitlesButton, this.openSettingsButton ); const detectedLanguage = this.videoHandler?.videoData?.detectedLanguage ?? "en"; const responseLanguage = this.data.responseLanguage ?? "ru"; this.languagePairSelect = new LanguagePairSelect({ from: { selectTitle: localizationProvider.get( `langs.${detectedLanguage}` ), items: Select.genLanguageItems(availableLangs, detectedLanguage) }, to: { selectTitle: localizationProvider.get( `langs.${responseLanguage}` ), items: Select.genLanguageItems(availableTTS, responseLanguage) } }); this.subtitlesSelectLabel = new Label({ labelText: localizationProvider.get("VOTSubtitles") }); this.subtitlesSelect = new Select({ selectTitle: localizationProvider.get("VOTSubtitlesDisabled"), dialogTitle: localizationProvider.get("VOTSubtitles"), labelElement: this.subtitlesSelectLabel.container, dialogParent: this.globalPortal, items: [ { label: localizationProvider.get("VOTSubtitlesDisabled"), value: "disabled", selected: true } ] }); const videoVolume = this.videoHandler ? this.videoHandler.getVideoVolume() * 100 : 100; this.videoVolumeSliderLabel = new SliderLabel({ labelText: localizationProvider.get("VOTVolume"), value: videoVolume }); this.videoVolumeSlider = new Slider({ labelHtml: this.videoVolumeSliderLabel.container, value: videoVolume }); this.videoVolumeSlider.hidden = !this.data.showVideoSlider || this.votButton.status !== "success"; const defaultVolume = this.data.defaultVolume ?? 100; this.translationVolumeSliderLabel = new SliderLabel({ labelText: localizationProvider.get("VOTVolumeTranslation"), value: defaultVolume }); this.translationVolumeSlider = new Slider({ labelHtml: this.translationVolumeSliderLabel.container, value: defaultVolume, max: this.data.audioBooster ? maxAudioVolume : 100 }); this.translationVolumeSlider.hidden = this.votButton.status !== "success"; this.votMenu.bodyContainer.append( this.languagePairSelect.container, this.subtitlesSelect.container, this.videoVolumeSlider.container, this.translationVolumeSlider.container ); return this; } initUIEvents() { if (!this.isInitialized()) { throw new Error("[VOT] OverlayView isn't initialized"); } this.abortController = new AbortController(); const signal = this.abortController.signal; this.checkerUnsubscribe?.(); this.checkerUnsubscribe = this.intervalIdleChecker.subscribe(() => { this.onCheckerTick(); }); this.votButton.container.addEventListener( "click", (e2) => { e2.preventDefault(); e2.stopPropagation(); e2.stopImmediatePropagation(); }, { signal } ); const activateOnKey = (handler) => (e2) => { if (e2.key === "Enter" || e2.key === " ") { e2.preventDefault(); handler(); } }; const setMenuOpen = (open, { returnFocusToToggle = false } = {}) => { if (!this.isInitialized()) return; this.votMenu.hidden = !open; this.votButton.menuButton.setAttribute("aria-expanded", open.toString()); if (this.votButtonTooltip) { this.votButtonTooltip.hidden = open || this.votButton.direction === "row"; } if (open) { queueMicrotask(() => this.openSettingsButton?.focus?.()); } else if (returnFocusToToggle) { queueMicrotask(() => this.votButton.menuButton.focus?.()); } else { this.votButton.menuButton.blur(); } }; const toggleMenu = () => setMenuOpen(this.votMenu.hidden); const closeMenu = (returnFocusToToggle = false) => setMenuOpen(false, { returnFocusToToggle }); this.votButton.translateButton.addEventListener( "pointerdown", () => { closeMenu(); this.events["click:translate"].dispatch(); }, { signal } ); this.votButton.translateButton.addEventListener( "keydown", activateOnKey(() => { closeMenu(); this.events["click:translate"].dispatch(); }), { signal } ); this.votButton.pipButton.addEventListener( "pointerdown", () => { closeMenu(); this.events["click:pip"].dispatch(); }, { signal } ); this.votButton.pipButton.addEventListener( "keydown", activateOnKey(() => { closeMenu(); this.events["click:pip"].dispatch(); }), { signal } ); this.votButton.menuButton.addEventListener( "pointerdown", (e2) => { e2.preventDefault(); toggleMenu(); }, { signal } ); this.votButton.menuButton.addEventListener( "keydown", activateOnKey(toggleMenu), { signal } ); const touchAction = "none"; this.votButton.container.style.touchAction = touchAction; this.votButton.translateButton.style.touchAction = touchAction; this.votButton.pipButton.style.touchAction = touchAction; this.votButton.menuButton.style.touchAction = touchAction; this.votButton.container.addEventListener("pointerdown", this.onDragStart, { signal }); this.votButton.container.addEventListener( "touchstart", this.onTouchDragStart, { signal, passive: false } ); this.votMenu.container.addEventListener( "click", (e2) => { e2.preventDefault(); e2.stopPropagation(); e2.stopImmediatePropagation(); }, { signal } ); for (const event of ["pointerdown", "mousedown"]) { this.votMenu.container.addEventListener( event, (e2) => { e2.stopImmediatePropagation(); }, { signal } ); } document.addEventListener( "pointerdown", (e2) => { if (this.votMenu.hidden) return; const target = e2.target; const path = typeof e2.composedPath === "function" ? e2.composedPath() : []; const isInsideMenu = target && this.votMenu.container.contains(target) || path.includes(this.votMenu.container); const isInsideToggle = target && this.votButton.menuButton.contains(target) || path.includes(this.votButton.menuButton); const isInsideButton = target && this.votButton.container.contains(target) || path.includes(this.votButton.container); const isInsideDialog = target instanceof HTMLElement && !!target.closest(".vot-dialog-container"); if (isInsideMenu || isInsideToggle || isInsideButton || isInsideDialog) { return; } closeMenu(false); }, { signal, capture: true, passive: true } ); this.votMenu.container.addEventListener( "keydown", (e2) => { if (e2.key !== "Escape") return; const keyboardNav = document.documentElement.classList.contains("vot-keyboard-nav"); e2.preventDefault(); e2.stopPropagation(); closeMenu(keyboardNav); const hovered = this.votButton.container.matches(":hover") || this.votMenu.container.matches(":hover"); if (!hovered) { this.videoHandler?.overlayVisibility?.queueAutoHide?.(); } }, { signal } ); this.downloadTranslationButton.addEventListener("click", () => { this.events["click:downloadTranslation"].dispatch(); }); this.downloadSubtitlesButton.addEventListener( "click", () => { this.events["click:downloadSubtitles"].dispatch(); }, { signal } ); this.openSettingsButton.addEventListener( "click", () => { closeMenu(); this.events["click:settings"].dispatch(); }, { signal } ); this.languagePairSelect.fromSelect.addEventListener( "selectItem", (language) => { if (this.videoHandler?.videoData) { this.videoHandler.videoData.detectedLanguage = language; } this.events["select:fromLanguage"].dispatch(language); } ); this.languagePairSelect.toSelect.addEventListener( "selectItem", async (language) => { if (this.videoHandler?.videoData) { this.videoHandler.translateToLang = this.videoHandler.videoData.responseLanguage = language; } const prevResponseLanguage = this.data.responseLanguage; this.data.responseLanguage = language; await votStorage.set("responseLanguage", this.data.responseLanguage); if (this.data.enabledDontTranslateLanguages && Array.isArray(this.data.dontTranslateLanguages) && this.data.dontTranslateLanguages.length === 1 && typeof prevResponseLanguage === "string" && this.data.dontTranslateLanguages[0] === prevResponseLanguage) { this.data.dontTranslateLanguages = [language]; await votStorage.set( "dontTranslateLanguages", this.data.dontTranslateLanguages ); } this.events["select:toLanguage"].dispatch(language); } ); this.subtitlesSelect.addEventListener("beforeOpen", async (dialog) => { if (!this.videoHandler?.videoData) { return; } const cacheKey = this.videoHandler.getSubtitlesCacheKey( this.videoHandler.videoData.videoId, this.videoHandler.videoData.detectedLanguage, this.videoHandler.videoData.responseLanguage ); if (this.videoHandler.cacheManager.getSubtitles(cacheKey)) { return; } if (this.votButton) { this.votButton.loading = true; } const loadingEl = UI.createInlineLoader(); loadingEl.style.margin = "0 auto"; dialog.footerContainer.appendChild(loadingEl); await this.videoHandler.loadSubtitles(); loadingEl.remove(); if (this.votButton) { this.votButton.loading = false; } }); this.subtitlesSelect.addEventListener("selectItem", (data) => { this.events["select:subtitles"].dispatch(data); }); this.videoVolumeSlider.addEventListener("input", (value, fromSetter) => { if (this.videoVolumeSliderLabel) { this.videoVolumeSliderLabel.value = value; } if (fromSetter) { return; } this.events["input:videoVolume"].dispatch(value); }); this.translationVolumeSlider.addEventListener( "input", (value, fromSetter) => { if (this.translationVolumeSliderLabel) { this.translationVolumeSliderLabel.value = value; } this.data.defaultVolume = value; this.scheduleDefaultVolumePersist(); if (fromSetter) { return; } this.events["input:translationVolume"].dispatch(value); } ); return this; } updateButtonLayout(position2, direction) { if (!this.isInitialized()) { return this; } this.votMenu.position = position2; this.votButton.position = position2; this.votButton.direction = direction; this.votButtonTooltip.hidden = direction === "row"; this.votButtonTooltip.setPosition(this.votButton.tooltipPos); return this; } moveButton(percentX) { if (!this.isInitialized()) { return this; } const isBigContainer = this.dragIsBigContainer ?? this.isBigContainer; const position2 = VOTButton.calcPosition(percentX, isBigContainer); if (position2 === this.votButton.position) { return this; } const direction = VOTButton.calcDirection(position2); this.data.buttonPos = position2; this.updateButtonLayout(position2, direction); return this; } onDragStart = (event) => { if (!event.isPrimary || event.button !== 0) return; if (event.pointerType === "touch") return; event.preventDefault(); this.dragCandidate = true; this.dragging = false; this.dragStartX = event.clientX; this.dragStartY = event.clientY; this.currentClientX = event.clientX; this.containerRect = this.root.getBoundingClientRect(); this.dragIsBigContainer = this.isBigContainer; this.dragDirty = false; this.intervalIdleChecker.markActivity("overlay-pointer-down"); this.intervalIdleChecker.requestImmediateTick(); document.addEventListener("pointermove", this.onGlobalPointerMove); document.addEventListener("pointerup", this.onDragEnd); document.addEventListener("pointercancel", this.onDragEnd); }; onTouchDragStart = (event) => { if (!event.touches || event.touches.length === 0) return; this.dragCandidate = true; this.dragging = false; const t2 = event.touches[0]; this.dragStartX = t2.clientX; this.dragStartY = t2.clientY; this.currentClientX = t2.clientX; this.containerRect = this.root.getBoundingClientRect(); this.dragIsBigContainer = this.isBigContainer; this.dragDirty = false; this.intervalIdleChecker.markActivity("overlay-touch-start"); this.intervalIdleChecker.requestImmediateTick(); document.addEventListener("touchmove", this.onGlobalTouchMove, { passive: false }); document.addEventListener("touchend", this.onDragEnd); document.addEventListener("touchcancel", this.onDragEnd); }; onGlobalTouchMove = (event) => { if (!event.touches || event.touches.length === 0) return; const t2 = event.touches[0]; this.currentClientX = t2.clientX; const clientY = t2.clientY; if (!this.dragCandidate) return; if (!this.dragging) { const dx = Math.abs(this.currentClientX - this.dragStartX); const dy = Math.abs(clientY - this.dragStartY); if (dx + dy >= this.dragThresholdPx) { this.dragging = true; } } if (this.dragging) { event.preventDefault(); } if (this.dragging) { this.dragDirty = true; this.intervalIdleChecker.markActivity("overlay-touch-move"); this.intervalIdleChecker.requestImmediateTick(); } }; onGlobalPointerMove = (event) => { this.currentClientX = event.clientX; const clientY = event.clientY; if (!this.dragCandidate) return; if (!this.dragging) { const dx = Math.abs(this.currentClientX - this.dragStartX); const dy = Math.abs(clientY - this.dragStartY); if (dx + dy >= this.dragThresholdPx) { this.dragging = true; } } if (this.dragging) { this.dragDirty = true; this.intervalIdleChecker.markActivity("overlay-pointer-move"); this.intervalIdleChecker.requestImmediateTick(); } }; applyDragFromState = () => { if (!this.dragging || !this.dragDirty || !this.containerRect) return; this.dragDirty = false; const x2 = this.currentClientX - this.containerRect.left; const clampedX = Math.max(0, Math.min(x2, this.containerRect.width)); const percentX = clampedX / this.containerRect.width * 100; this.moveButton(percentX); }; onCheckerTick = () => { this.applyDragFromState(); }; onDragEnd = () => { document.removeEventListener("pointermove", this.onGlobalPointerMove); document.removeEventListener("pointerup", this.onDragEnd); document.removeEventListener("pointercancel", this.onDragEnd); document.removeEventListener("touchmove", this.onGlobalTouchMove); document.removeEventListener("touchend", this.onDragEnd); document.removeEventListener("touchcancel", this.onDragEnd); this.applyDragFromState(); const isBigContainer = this.dragIsBigContainer ?? this.isBigContainer; if (this.dragging && isBigContainer && this.data.buttonPos) { void votStorage.set("buttonPos", this.data.buttonPos); } this.dragging = false; this.dragCandidate = false; this.dragDirty = false; this.containerRect = null; this.dragIsBigContainer = null; }; updateButtonOpacity(opacity) { if (!this.isInitialized() || !this.votMenu.hidden) { return this; } if (Math.abs(this.votButton.opacity - opacity) > 0.01) { this.votButton.opacity = opacity; } return this; } doReleaseUI() { this.votButton?.remove(); this.votMenu?.remove(); this.votButtonTooltip?.release(); this.votOverlayPortal?.remove(); } doReleaseUIEvents() { this.abortController?.abort(); this.abortController = null; this.checkerUnsubscribe?.(); this.checkerUnsubscribe = null; this.onDragEnd(); this.flushDefaultVolumePersist(); for (const event of Object.values(this.events)) { event.clear(); } } releaseUI(initialized = false) { if (!this.isInitialized()) { throw new Error("[VOT] OverlayView isn't initialized"); } this.doReleaseUI(); this.initialized = initialized; return this; } releaseUIEvents(initialized = false) { if (!this.isInitialized()) { throw new Error("[VOT] OverlayView isn't initialized"); } this.doReleaseUIEvents(); this.initialized = initialized; return this; } release() { if (!this.isInitialized()) { return this; } this.doReleaseUIEvents(); this.doReleaseUI(); this.initialized = false; return this; } get isBigContainer() { const widthFromVideo = this.videoHandler?.video?.getBoundingClientRect?.().width; if (typeof widthFromVideo === "number" && Number.isFinite(widthFromVideo)) { return widthFromVideo > 550; } const widthFromContainer = this.videoHandler?.container?.getBoundingClientRect?.().width; if (typeof widthFromContainer === "number" && Number.isFinite(widthFromContainer)) { return widthFromContainer > 550; } return this.root.clientWidth > 550; } get pipButtonVisible() { return isPiPAvailable() && !!this.data.showPiPButton; } } const positions = ["default", "top", "left", "right"]; const BROWSER_ALIASES_MAP = { AmazonBot: "amazonbot", "Amazon Silk": "amazon_silk", "Android Browser": "android", BaiduSpider: "baiduspider", Bada: "bada", BingCrawler: "bingcrawler", Brave: "brave", BlackBerry: "blackberry", "ChatGPT-User": "chatgpt_user", Chrome: "chrome", ClaudeBot: "claudebot", Chromium: "chromium", Diffbot: "diffbot", DuckDuckBot: "duckduckbot", DuckDuckGo: "duckduckgo", Electron: "electron", Epiphany: "epiphany", FacebookExternalHit: "facebookexternalhit", Firefox: "firefox", Focus: "focus", Generic: "generic", "Google Search": "google_search", Googlebot: "googlebot", GPTBot: "gptbot", "Internet Explorer": "ie", InternetArchiveCrawler: "internetarchivecrawler", "K-Meleon": "k_meleon", LibreWolf: "librewolf", Linespider: "linespider", Maxthon: "maxthon", "Meta-ExternalAds": "meta_externalads", "Meta-ExternalAgent": "meta_externalagent", "Meta-ExternalFetcher": "meta_externalfetcher", "Meta-WebIndexer": "meta_webindexer", "Microsoft Edge": "edge", "MZ Browser": "mz", "NAVER Whale Browser": "naver", "OAI-SearchBot": "oai_searchbot", Omgilibot: "omgilibot", Opera: "opera", "Opera Coast": "opera_coast", "Pale Moon": "pale_moon", PerplexityBot: "perplexitybot", "Perplexity-User": "perplexity_user", PhantomJS: "phantomjs", PingdomBot: "pingdombot", Puffin: "puffin", QQ: "qq", QQLite: "qqlite", QupZilla: "qupzilla", Roku: "roku", Safari: "safari", Sailfish: "sailfish", "Samsung Internet for Android": "samsung_internet", SlackBot: "slackbot", SeaMonkey: "seamonkey", Sleipnir: "sleipnir", "Sogou Browser": "sogou", Swing: "swing", Tizen: "tizen", "UC Browser": "uc", Vivaldi: "vivaldi", "WebOS Browser": "webos", WeChat: "wechat", YahooSlurp: "yahooslurp", "Yandex Browser": "yandex", YandexBot: "yandexbot", YouBot: "youbot" }; const BROWSER_MAP = { amazonbot: "AmazonBot", amazon_silk: "Amazon Silk", android: "Android Browser", baiduspider: "BaiduSpider", bada: "Bada", bingcrawler: "BingCrawler", blackberry: "BlackBerry", brave: "Brave", chatgpt_user: "ChatGPT-User", chrome: "Chrome", claudebot: "ClaudeBot", chromium: "Chromium", diffbot: "Diffbot", duckduckbot: "DuckDuckBot", duckduckgo: "DuckDuckGo", edge: "Microsoft Edge", electron: "Electron", epiphany: "Epiphany", facebookexternalhit: "FacebookExternalHit", firefox: "Firefox", focus: "Focus", generic: "Generic", google_search: "Google Search", googlebot: "Googlebot", gptbot: "GPTBot", ie: "Internet Explorer", internetarchivecrawler: "InternetArchiveCrawler", k_meleon: "K-Meleon", librewolf: "LibreWolf", linespider: "Linespider", maxthon: "Maxthon", meta_externalads: "Meta-ExternalAds", meta_externalagent: "Meta-ExternalAgent", meta_externalfetcher: "Meta-ExternalFetcher", meta_webindexer: "Meta-WebIndexer", mz: "MZ Browser", naver: "NAVER Whale Browser", oai_searchbot: "OAI-SearchBot", omgilibot: "Omgilibot", opera: "Opera", opera_coast: "Opera Coast", pale_moon: "Pale Moon", perplexitybot: "PerplexityBot", perplexity_user: "Perplexity-User", phantomjs: "PhantomJS", pingdombot: "PingdomBot", puffin: "Puffin", qq: "QQ Browser", qqlite: "QQ Browser Lite", qupzilla: "QupZilla", roku: "Roku", safari: "Safari", sailfish: "Sailfish", samsung_internet: "Samsung Internet for Android", seamonkey: "SeaMonkey", slackbot: "SlackBot", sleipnir: "Sleipnir", sogou: "Sogou Browser", swing: "Swing", tizen: "Tizen", uc: "UC Browser", vivaldi: "Vivaldi", webos: "WebOS Browser", wechat: "WeChat", yahooslurp: "YahooSlurp", yandex: "Yandex Browser", yandexbot: "YandexBot", youbot: "YouBot" }; const PLATFORMS_MAP = { bot: "bot", desktop: "desktop", mobile: "mobile", tablet: "tablet", tv: "tv" }; const OS_MAP = { Android: "Android", Bada: "Bada", BlackBerry: "BlackBerry", ChromeOS: "Chrome OS", HarmonyOS: "HarmonyOS", iOS: "iOS", Linux: "Linux", MacOS: "macOS", PlayStation4: "PlayStation 4", Roku: "Roku", Tizen: "Tizen", WebOS: "WebOS", Windows: "Windows", WindowsPhone: "Windows Phone" }; const ENGINE_MAP = { Blink: "Blink", EdgeHTML: "EdgeHTML", Gecko: "Gecko", Presto: "Presto", Trident: "Trident", WebKit: "WebKit" }; class Utils { static getFirstMatch(regexp, ua) { const match = ua.match(regexp); return match && match.length > 0 && match[1] || ""; } static getSecondMatch(regexp, ua) { const match = ua.match(regexp); return match && match.length > 1 && match[2] || ""; } static matchAndReturnConst(regexp, ua, _const) { if (regexp.test(ua)) { return _const; } return void 0; } static getWindowsVersionName(version) { switch (version) { case "NT": return "NT"; case "XP": return "XP"; case "NT 5.0": return "2000"; case "NT 5.1": return "XP"; case "NT 5.2": return "2003"; case "NT 6.0": return "Vista"; case "NT 6.1": return "7"; case "NT 6.2": return "8"; case "NT 6.3": return "8.1"; case "NT 10.0": return "10"; default: return void 0; } } static getMacOSVersionName(version) { const v2 = version.split(".").splice(0, 2).map((s2) => parseInt(s2, 10) || 0); v2.push(0); const major = v2[0]; const minor = v2[1]; if (major === 10) { switch (minor) { case 5: return "Leopard"; case 6: return "Snow Leopard"; case 7: return "Lion"; case 8: return "Mountain Lion"; case 9: return "Mavericks"; case 10: return "Yosemite"; case 11: return "El Capitan"; case 12: return "Sierra"; case 13: return "High Sierra"; case 14: return "Mojave"; case 15: return "Catalina"; default: return void 0; } } switch (major) { case 11: return "Big Sur"; case 12: return "Monterey"; case 13: return "Ventura"; case 14: return "Sonoma"; case 15: return "Sequoia"; default: return void 0; } } static getAndroidVersionName(version) { const v2 = version.split(".").splice(0, 2).map((s2) => parseInt(s2, 10) || 0); v2.push(0); if (v2[0] === 1 && v2[1] < 5) return void 0; if (v2[0] === 1 && v2[1] < 6) return "Cupcake"; if (v2[0] === 1 && v2[1] >= 6) return "Donut"; if (v2[0] === 2 && v2[1] < 2) return "Eclair"; if (v2[0] === 2 && v2[1] === 2) return "Froyo"; if (v2[0] === 2 && v2[1] > 2) return "Gingerbread"; if (v2[0] === 3) return "Honeycomb"; if (v2[0] === 4 && v2[1] < 1) return "Ice Cream Sandwich"; if (v2[0] === 4 && v2[1] < 4) return "Jelly Bean"; if (v2[0] === 4 && v2[1] >= 4) return "KitKat"; if (v2[0] === 5) return "Lollipop"; if (v2[0] === 6) return "Marshmallow"; if (v2[0] === 7) return "Nougat"; if (v2[0] === 8) return "Oreo"; if (v2[0] === 9) return "Pie"; return void 0; } static getVersionPrecision(version) { return version.split(".").length; } static compareVersions(versionA, versionB, isLoose = false) { const versionAPrecision = Utils.getVersionPrecision(versionA); const versionBPrecision = Utils.getVersionPrecision(versionB); let precision = Math.max(versionAPrecision, versionBPrecision); let lastPrecision = 0; const chunks = Utils.map([versionA, versionB], (version) => { const delta = precision - Utils.getVersionPrecision(version); const _version = version + new Array(delta + 1).join(".0"); return Utils.map(_version.split("."), (chunk) => new Array(20 - chunk.length).join("0") + chunk).reverse(); }); if (isLoose) { lastPrecision = precision - Math.min(versionAPrecision, versionBPrecision); } precision -= 1; while (precision >= lastPrecision) { if (chunks[0][precision] > chunks[1][precision]) { return 1; } if (chunks[0][precision] === chunks[1][precision]) { if (precision === lastPrecision) { return 0; } precision -= 1; } else if (chunks[0][precision] < chunks[1][precision]) { return -1; } } return void 0; } static map(arr, iterator) { const result = []; let i2; if (Array.prototype.map) { return Array.prototype.map.call(arr, iterator); } for (i2 = 0; i2 < arr.length; i2 += 1) { result.push(iterator(arr[i2])); } return result; } static find(arr, predicate) { let i2; let l2; if (Array.prototype.find) { return Array.prototype.find.call(arr, predicate); } for (i2 = 0, l2 = arr.length; i2 < l2; i2 += 1) { const value = arr[i2]; if (predicate(value, i2)) { return value; } } return void 0; } static assign(obj, ...assigners) { const result = obj; let i2; let l2; if (Object.assign) { return Object.assign(obj, ...assigners); } for (i2 = 0, l2 = assigners.length; i2 < l2; i2 += 1) { const assigner = assigners[i2]; if (typeof assigner === "object" && assigner !== null) { const keys = Object.keys(assigner); keys.forEach((key) => { result[key] = assigner[key]; }); } } return obj; } static getBrowserAlias(browserName) { return BROWSER_ALIASES_MAP[browserName]; } static getBrowserTypeByAlias(browserAlias) { return BROWSER_MAP[browserAlias] || ""; } } const commonVersionIdentifier = /version\/(\d+(\.?_?\d+)+)/i; const browsersList = [ { test: [/gptbot/i], describe(ua) { const browser = { name: "GPTBot" }; const version = Utils.getFirstMatch(/gptbot\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/chatgpt-user/i], describe(ua) { const browser = { name: "ChatGPT-User" }; const version = Utils.getFirstMatch(/chatgpt-user\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/oai-searchbot/i], describe(ua) { const browser = { name: "OAI-SearchBot" }; const version = Utils.getFirstMatch(/oai-searchbot\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/claudebot/i, /claude-web/i, /claude-user/i, /claude-searchbot/i], describe(ua) { const browser = { name: "ClaudeBot" }; const version = Utils.getFirstMatch(/(?:claudebot|claude-web|claude-user|claude-searchbot)\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/omgilibot/i, /webzio-extended/i], describe(ua) { const browser = { name: "Omgilibot" }; const version = Utils.getFirstMatch(/(?:omgilibot|webzio-extended)\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/diffbot/i], describe(ua) { const browser = { name: "Diffbot" }; const version = Utils.getFirstMatch(/diffbot\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/perplexitybot/i], describe(ua) { const browser = { name: "PerplexityBot" }; const version = Utils.getFirstMatch(/perplexitybot\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/perplexity-user/i], describe(ua) { const browser = { name: "Perplexity-User" }; const version = Utils.getFirstMatch(/perplexity-user\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/youbot/i], describe(ua) { const browser = { name: "YouBot" }; const version = Utils.getFirstMatch(/youbot\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/meta-webindexer/i], describe(ua) { const browser = { name: "Meta-WebIndexer" }; const version = Utils.getFirstMatch(/meta-webindexer\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/meta-externalads/i], describe(ua) { const browser = { name: "Meta-ExternalAds" }; const version = Utils.getFirstMatch(/meta-externalads\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/meta-externalagent/i], describe(ua) { const browser = { name: "Meta-ExternalAgent" }; const version = Utils.getFirstMatch(/meta-externalagent\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/meta-externalfetcher/i], describe(ua) { const browser = { name: "Meta-ExternalFetcher" }; const version = Utils.getFirstMatch(/meta-externalfetcher\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/googlebot/i], describe(ua) { const browser = { name: "Googlebot" }; const version = Utils.getFirstMatch(/googlebot\/(\d+(\.\d+))/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/linespider/i], describe(ua) { const browser = { name: "Linespider" }; const version = Utils.getFirstMatch(/(?:linespider)(?:-[-\w]+)?[\s/](\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/amazonbot/i], describe(ua) { const browser = { name: "AmazonBot" }; const version = Utils.getFirstMatch(/amazonbot\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/bingbot/i], describe(ua) { const browser = { name: "BingCrawler" }; const version = Utils.getFirstMatch(/bingbot\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/baiduspider/i], describe(ua) { const browser = { name: "BaiduSpider" }; const version = Utils.getFirstMatch(/baiduspider\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/duckduckbot/i], describe(ua) { const browser = { name: "DuckDuckBot" }; const version = Utils.getFirstMatch(/duckduckbot\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/ia_archiver/i], describe(ua) { const browser = { name: "InternetArchiveCrawler" }; const version = Utils.getFirstMatch(/ia_archiver\/(\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/facebookexternalhit/i, /facebookcatalog/i], describe() { return { name: "FacebookExternalHit" }; } }, { test: [/slackbot/i, /slack-imgProxy/i], describe(ua) { const browser = { name: "SlackBot" }; const version = Utils.getFirstMatch(/(?:slackbot|slack-imgproxy)(?:-[-\w]+)?[\s/](\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/yahoo!?[\s/]*slurp/i], describe() { return { name: "YahooSlurp" }; } }, { test: [/yandexbot/i, /yandexmobilebot/i], describe() { return { name: "YandexBot" }; } }, { test: [/pingdom/i], describe() { return { name: "PingdomBot" }; } }, { test: [/opera/i], describe(ua) { const browser = { name: "Opera" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:opera)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/opr\/|opios/i], describe(ua) { const browser = { name: "Opera" }; const version = Utils.getFirstMatch(/(?:opr|opios)[\s/](\S+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/SamsungBrowser/i], describe(ua) { const browser = { name: "Samsung Internet for Android" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:SamsungBrowser)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/Whale/i], describe(ua) { const browser = { name: "NAVER Whale Browser" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:whale)[\s/](\d+(?:\.\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/PaleMoon/i], describe(ua) { const browser = { name: "Pale Moon" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:PaleMoon)[\s/](\d+(?:\.\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/MZBrowser/i], describe(ua) { const browser = { name: "MZ Browser" }; const version = Utils.getFirstMatch(/(?:MZBrowser)[\s/](\d+(?:\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/focus/i], describe(ua) { const browser = { name: "Focus" }; const version = Utils.getFirstMatch(/(?:focus)[\s/](\d+(?:\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/swing/i], describe(ua) { const browser = { name: "Swing" }; const version = Utils.getFirstMatch(/(?:swing)[\s/](\d+(?:\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/coast/i], describe(ua) { const browser = { name: "Opera Coast" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:coast)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/opt\/\d+(?:.?_?\d+)+/i], describe(ua) { const browser = { name: "Opera Touch" }; const version = Utils.getFirstMatch(/(?:opt)[\s/](\d+(\.?_?\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/yabrowser/i], describe(ua) { const browser = { name: "Yandex Browser" }; const version = Utils.getFirstMatch(/(?:yabrowser)[\s/](\d+(\.?_?\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/ucbrowser/i], describe(ua) { const browser = { name: "UC Browser" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:ucbrowser)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/Maxthon|mxios/i], describe(ua) { const browser = { name: "Maxthon" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:Maxthon|mxios)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/epiphany/i], describe(ua) { const browser = { name: "Epiphany" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:epiphany)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/puffin/i], describe(ua) { const browser = { name: "Puffin" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:puffin)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/sleipnir/i], describe(ua) { const browser = { name: "Sleipnir" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:sleipnir)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/k-meleon/i], describe(ua) { const browser = { name: "K-Meleon" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/(?:k-meleon)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/micromessenger/i], describe(ua) { const browser = { name: "WeChat" }; const version = Utils.getFirstMatch(/(?:micromessenger)[\s/](\d+(\.?_?\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/qqbrowser/i], describe(ua) { const browser = { name: /qqbrowserlite/i.test(ua) ? "QQ Browser Lite" : "QQ Browser" }; const version = Utils.getFirstMatch(/(?:qqbrowserlite|qqbrowser)[/](\d+(\.?_?\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/msie|trident/i], describe(ua) { const browser = { name: "Internet Explorer" }; const version = Utils.getFirstMatch(/(?:msie |rv:)(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/\sedg\//i], describe(ua) { const browser = { name: "Microsoft Edge" }; const version = Utils.getFirstMatch(/\sedg\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/edg([ea]|ios)/i], describe(ua) { const browser = { name: "Microsoft Edge" }; const version = Utils.getSecondMatch(/edg([ea]|ios)\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/vivaldi/i], describe(ua) { const browser = { name: "Vivaldi" }; const version = Utils.getFirstMatch(/vivaldi\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/seamonkey/i], describe(ua) { const browser = { name: "SeaMonkey" }; const version = Utils.getFirstMatch(/seamonkey\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/sailfish/i], describe(ua) { const browser = { name: "Sailfish" }; const version = Utils.getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/silk/i], describe(ua) { const browser = { name: "Amazon Silk" }; const version = Utils.getFirstMatch(/silk\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/phantom/i], describe(ua) { const browser = { name: "PhantomJS" }; const version = Utils.getFirstMatch(/phantomjs\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/slimerjs/i], describe(ua) { const browser = { name: "SlimerJS" }; const version = Utils.getFirstMatch(/slimerjs\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/blackberry|\bbb\d+/i, /rim\stablet/i], describe(ua) { const browser = { name: "BlackBerry" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/blackberry[\d]+\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/(web|hpw)[o0]s/i], describe(ua) { const browser = { name: "WebOS Browser" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua) || Utils.getFirstMatch(/w(?:eb)?[o0]sbrowser\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/bada/i], describe(ua) { const browser = { name: "Bada" }; const version = Utils.getFirstMatch(/dolfin\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/tizen/i], describe(ua) { const browser = { name: "Tizen" }; const version = Utils.getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.?_?\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/qupzilla/i], describe(ua) { const browser = { name: "QupZilla" }; const version = Utils.getFirstMatch(/(?:qupzilla)[\s/](\d+(\.?_?\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/librewolf/i], describe(ua) { const browser = { name: "LibreWolf" }; const version = Utils.getFirstMatch(/(?:librewolf)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/firefox|iceweasel|fxios/i], describe(ua) { const browser = { name: "Firefox" }; const version = Utils.getFirstMatch(/(?:firefox|iceweasel|fxios)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/electron/i], describe(ua) { const browser = { name: "Electron" }; const version = Utils.getFirstMatch(/(?:electron)\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/sogoumobilebrowser/i, /metasr/i, /se 2\.[x]/i], describe(ua) { const browser = { name: "Sogou Browser" }; const sogouMobileVersion = Utils.getFirstMatch(/(?:sogoumobilebrowser)[\s/](\d+(\.?_?\d+)+)/i, ua); const chromiumVersion = Utils.getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.?_?\d+)+)/i, ua); const seVersion = Utils.getFirstMatch(/se ([\d.]+)x/i, ua); const version = sogouMobileVersion || chromiumVersion || seVersion; if (version) { browser.version = version; } return browser; } }, { test: [/MiuiBrowser/i], describe(ua) { const browser = { name: "Miui" }; const version = Utils.getFirstMatch(/(?:MiuiBrowser)[\s/](\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test(parser) { if (parser.hasBrand("DuckDuckGo")) { return true; } return parser.test(/\sDdg\/[\d.]+$/i); }, describe(ua, parser) { const browser = { name: "DuckDuckGo" }; if (parser) { const hintsVersion = parser.getBrandVersion("DuckDuckGo"); if (hintsVersion) { browser.version = hintsVersion; return browser; } } const uaVersion = Utils.getFirstMatch(/\sDdg\/([\d.]+)$/i, ua); if (uaVersion) { browser.version = uaVersion; } return browser; } }, { test(parser) { return parser.hasBrand("Brave"); }, describe(ua, parser) { const browser = { name: "Brave" }; if (parser) { const hintsVersion = parser.getBrandVersion("Brave"); if (hintsVersion) { browser.version = hintsVersion; return browser; } } return browser; } }, { test: [/chromium/i], describe(ua) { const browser = { name: "Chromium" }; const version = Utils.getFirstMatch(/(?:chromium)[\s/](\d+(\.?_?\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/chrome|crios|crmo/i], describe(ua) { const browser = { name: "Chrome" }; const version = Utils.getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test: [/GSA/i], describe(ua) { const browser = { name: "Google Search" }; const version = Utils.getFirstMatch(/(?:GSA)\/(\d+(\.?_?\d+)+)/i, ua); if (version) { browser.version = version; } return browser; } }, { test(parser) { const notLikeAndroid = !parser.test(/like android/i); const butAndroid = parser.test(/android/i); return notLikeAndroid && butAndroid; }, describe(ua) { const browser = { name: "Android Browser" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/playstation 4/i], describe(ua) { const browser = { name: "PlayStation 4" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/safari|applewebkit/i], describe(ua) { const browser = { name: "Safari" }; const version = Utils.getFirstMatch(commonVersionIdentifier, ua); if (version) { browser.version = version; } return browser; } }, { test: [/.*/i], describe(ua) { const regexpWithoutDeviceSpec = /^(.*)\/(.*) /; const regexpWithDeviceSpec = /^(.*)\/(.*)[ \t]\((.*)/; const hasDeviceSpec = ua.search("\\(") !== -1; const regexp = hasDeviceSpec ? regexpWithDeviceSpec : regexpWithoutDeviceSpec; return { name: Utils.getFirstMatch(regexp, ua), version: Utils.getSecondMatch(regexp, ua) }; } } ]; const osParsersList = [ { test: [/Roku\/DVP/], describe(ua) { const version = Utils.getFirstMatch(/Roku\/DVP-(\d+\.\d+)/i, ua); return { name: OS_MAP.Roku, version }; } }, { test: [/windows phone/i], describe(ua) { const version = Utils.getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i, ua); return { name: OS_MAP.WindowsPhone, version }; } }, { test: [/windows /i], describe(ua) { const version = Utils.getFirstMatch(/Windows ((NT|XP)( \d\d?.\d)?)/i, ua); const versionName = Utils.getWindowsVersionName(version); return { name: OS_MAP.Windows, version, versionName }; } }, { test: [/Macintosh(.*?) FxiOS(.*?)\//], describe(ua) { const result = { name: OS_MAP.iOS }; const version = Utils.getSecondMatch(/(Version\/)(\d[\d.]+)/, ua); if (version) { result.version = version; } return result; } }, { test: [/macintosh/i], describe(ua) { const version = Utils.getFirstMatch(/mac os x (\d+(\.?_?\d+)+)/i, ua).replace(/[_\s]/g, "."); const versionName = Utils.getMacOSVersionName(version); const os = { name: OS_MAP.MacOS, version }; if (versionName) { os.versionName = versionName; } return os; } }, { test: [/(ipod|iphone|ipad)/i], describe(ua) { const version = Utils.getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i, ua).replace(/[_\s]/g, "."); return { name: OS_MAP.iOS, version }; } }, { test: [/OpenHarmony/i], describe(ua) { const version = Utils.getFirstMatch(/OpenHarmony\s+(\d+(\.\d+)*)/i, ua); return { name: OS_MAP.HarmonyOS, version }; } }, { test(parser) { const notLikeAndroid = !parser.test(/like android/i); const butAndroid = parser.test(/android/i); return notLikeAndroid && butAndroid; }, describe(ua) { const version = Utils.getFirstMatch(/android[\s/-](\d+(\.\d+)*)/i, ua); const versionName = Utils.getAndroidVersionName(version); const os = { name: OS_MAP.Android, version }; if (versionName) { os.versionName = versionName; } return os; } }, { test: [/(web|hpw)[o0]s/i], describe(ua) { const version = Utils.getFirstMatch(/(?:web|hpw)[o0]s\/(\d+(\.\d+)*)/i, ua); const os = { name: OS_MAP.WebOS }; if (version && version.length) { os.version = version; } return os; } }, { test: [/blackberry|\bbb\d+/i, /rim\stablet/i], describe(ua) { const version = Utils.getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i, ua) || Utils.getFirstMatch(/blackberry\d+\/(\d+([_\s]\d+)*)/i, ua) || Utils.getFirstMatch(/\bbb(\d+)/i, ua); return { name: OS_MAP.BlackBerry, version }; } }, { test: [/bada/i], describe(ua) { const version = Utils.getFirstMatch(/bada\/(\d+(\.\d+)*)/i, ua); return { name: OS_MAP.Bada, version }; } }, { test: [/tizen/i], describe(ua) { const version = Utils.getFirstMatch(/tizen[/\s](\d+(\.\d+)*)/i, ua); return { name: OS_MAP.Tizen, version }; } }, { test: [/linux/i], describe() { return { name: OS_MAP.Linux }; } }, { test: [/CrOS/], describe() { return { name: OS_MAP.ChromeOS }; } }, { test: [/PlayStation 4/], describe(ua) { const version = Utils.getFirstMatch(/PlayStation 4[/\s](\d+(\.\d+)*)/i, ua); return { name: OS_MAP.PlayStation4, version }; } } ]; const platformParsersList = [ { test: [/googlebot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Google" }; } }, { test: [/linespider/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Line" }; } }, { test: [/amazonbot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Amazon" }; } }, { test: [/gptbot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "OpenAI" }; } }, { test: [/chatgpt-user/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "OpenAI" }; } }, { test: [/oai-searchbot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "OpenAI" }; } }, { test: [/baiduspider/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Baidu" }; } }, { test: [/bingbot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Bing" }; } }, { test: [/duckduckbot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "DuckDuckGo" }; } }, { test: [/claudebot/i, /claude-web/i, /claude-user/i, /claude-searchbot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Anthropic" }; } }, { test: [/omgilibot/i, /webzio-extended/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Webz.io" }; } }, { test: [/diffbot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Diffbot" }; } }, { test: [/perplexitybot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Perplexity AI" }; } }, { test: [/perplexity-user/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Perplexity AI" }; } }, { test: [/youbot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "You.com" }; } }, { test: [/ia_archiver/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Internet Archive" }; } }, { test: [/meta-webindexer/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Meta" }; } }, { test: [/meta-externalads/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Meta" }; } }, { test: [/meta-externalagent/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Meta" }; } }, { test: [/meta-externalfetcher/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Meta" }; } }, { test: [/facebookexternalhit/i, /facebookcatalog/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Meta" }; } }, { test: [/slackbot/i, /slack-imgProxy/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Slack" }; } }, { test: [/yahoo/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Yahoo" }; } }, { test: [/yandexbot/i, /yandexmobilebot/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Yandex" }; } }, { test: [/pingdom/i], describe() { return { type: PLATFORMS_MAP.bot, vendor: "Pingdom" }; } }, { test: [/huawei/i], describe(ua) { const model = Utils.getFirstMatch(/(can-l01)/i, ua) && "Nova"; const platform = { type: PLATFORMS_MAP.mobile, vendor: "Huawei" }; if (model) { platform.model = model; } return platform; } }, { test: [/nexus\s*(?:7|8|9|10).*/i], describe() { return { type: PLATFORMS_MAP.tablet, vendor: "Nexus" }; } }, { test: [/ipad/i], describe() { return { type: PLATFORMS_MAP.tablet, vendor: "Apple", model: "iPad" }; } }, { test: [/Macintosh(.*?) FxiOS(.*?)\//], describe() { return { type: PLATFORMS_MAP.tablet, vendor: "Apple", model: "iPad" }; } }, { test: [/kftt build/i], describe() { return { type: PLATFORMS_MAP.tablet, vendor: "Amazon", model: "Kindle Fire HD 7" }; } }, { test: [/silk/i], describe() { return { type: PLATFORMS_MAP.tablet, vendor: "Amazon" }; } }, { test: [/tablet(?! pc)/i], describe() { return { type: PLATFORMS_MAP.tablet }; } }, { test(parser) { const iDevice = parser.test(/ipod|iphone/i); const likeIDevice = parser.test(/like (ipod|iphone)/i); return iDevice && !likeIDevice; }, describe(ua) { const model = Utils.getFirstMatch(/(ipod|iphone)/i, ua); return { type: PLATFORMS_MAP.mobile, vendor: "Apple", model }; } }, { test: [/nexus\s*[0-6].*/i, /galaxy nexus/i], describe() { return { type: PLATFORMS_MAP.mobile, vendor: "Nexus" }; } }, { test: [/Nokia/i], describe(ua) { const model = Utils.getFirstMatch(/Nokia\s+([0-9]+(\.[0-9]+)?)/i, ua); const platform = { type: PLATFORMS_MAP.mobile, vendor: "Nokia" }; if (model) { platform.model = model; } return platform; } }, { test: [/[^-]mobi/i], describe() { return { type: PLATFORMS_MAP.mobile }; } }, { test(parser) { return parser.getBrowserName(true) === "blackberry"; }, describe() { return { type: PLATFORMS_MAP.mobile, vendor: "BlackBerry" }; } }, { test(parser) { return parser.getBrowserName(true) === "bada"; }, describe() { return { type: PLATFORMS_MAP.mobile }; } }, { test(parser) { return parser.getBrowserName() === "windows phone"; }, describe() { return { type: PLATFORMS_MAP.mobile, vendor: "Microsoft" }; } }, { test(parser) { const osMajorVersion = Number(String(parser.getOSVersion()).split(".")[0]); return parser.getOSName(true) === "android" && osMajorVersion >= 3; }, describe() { return { type: PLATFORMS_MAP.tablet }; } }, { test(parser) { return parser.getOSName(true) === "android"; }, describe() { return { type: PLATFORMS_MAP.mobile }; } }, { test: [/smart-?tv|smarttv/i], describe() { return { type: PLATFORMS_MAP.tv }; } }, { test: [/netcast/i], describe() { return { type: PLATFORMS_MAP.tv }; } }, { test(parser) { return parser.getOSName(true) === "macos"; }, describe() { return { type: PLATFORMS_MAP.desktop, vendor: "Apple" }; } }, { test(parser) { return parser.getOSName(true) === "windows"; }, describe() { return { type: PLATFORMS_MAP.desktop }; } }, { test(parser) { return parser.getOSName(true) === "linux"; }, describe() { return { type: PLATFORMS_MAP.desktop }; } }, { test(parser) { return parser.getOSName(true) === "playstation 4"; }, describe() { return { type: PLATFORMS_MAP.tv }; } }, { test(parser) { return parser.getOSName(true) === "roku"; }, describe() { return { type: PLATFORMS_MAP.tv }; } } ]; const enginesParsersList = [ { test(parser) { return parser.getBrowserName(true) === "microsoft edge"; }, describe(ua) { const isBlinkBased = /\sedg\//i.test(ua); if (isBlinkBased) { return { name: ENGINE_MAP.Blink }; } const version = Utils.getFirstMatch(/edge\/(\d+(\.?_?\d+)+)/i, ua); return { name: ENGINE_MAP.EdgeHTML, version }; } }, { test: [/trident/i], describe(ua) { const engine = { name: ENGINE_MAP.Trident }; const version = Utils.getFirstMatch(/trident\/(\d+(\.?_?\d+)+)/i, ua); if (version) { engine.version = version; } return engine; } }, { test(parser) { return parser.test(/presto/i); }, describe(ua) { const engine = { name: ENGINE_MAP.Presto }; const version = Utils.getFirstMatch(/presto\/(\d+(\.?_?\d+)+)/i, ua); if (version) { engine.version = version; } return engine; } }, { test(parser) { const isGecko = parser.test(/gecko/i); const likeGecko = parser.test(/like gecko/i); return isGecko && !likeGecko; }, describe(ua) { const engine = { name: ENGINE_MAP.Gecko }; const version = Utils.getFirstMatch(/gecko\/(\d+(\.?_?\d+)+)/i, ua); if (version) { engine.version = version; } return engine; } }, { test: [/(apple)?webkit\/537\.36/i], describe() { return { name: ENGINE_MAP.Blink }; } }, { test: [/(apple)?webkit/i], describe(ua) { const engine = { name: ENGINE_MAP.WebKit }; const version = Utils.getFirstMatch(/webkit\/(\d+(\.?_?\d+)+)/i, ua); if (version) { engine.version = version; } return engine; } } ]; class Parser { constructor(UA, skipParsingOrHints = false, clientHints = null) { if (UA === void 0 || UA === null || UA === "") { throw new Error("UserAgent parameter can't be empty"); } this._ua = UA; let skipParsing = false; if (typeof skipParsingOrHints === "boolean") { skipParsing = skipParsingOrHints; this._hints = clientHints; } else if (skipParsingOrHints != null && typeof skipParsingOrHints === "object") { this._hints = skipParsingOrHints; } else { this._hints = null; } this.parsedResult = {}; if (skipParsing !== true) { this.parse(); } } getHints() { return this._hints; } hasBrand(brandName) { if (!this._hints || !Array.isArray(this._hints.brands)) { return false; } const brandLower = brandName.toLowerCase(); return this._hints.brands.some( (b2) => b2.brand && b2.brand.toLowerCase() === brandLower ); } getBrandVersion(brandName) { if (!this._hints || !Array.isArray(this._hints.brands)) { return void 0; } const brandLower = brandName.toLowerCase(); const brand = this._hints.brands.find( (b2) => b2.brand && b2.brand.toLowerCase() === brandLower ); return brand ? brand.version : void 0; } getUA() { return this._ua; } test(regex) { return regex.test(this._ua); } parseBrowser() { this.parsedResult.browser = {}; const browserDescriptor = Utils.find(browsersList, (_browser) => { if (typeof _browser.test === "function") { return _browser.test(this); } if (Array.isArray(_browser.test)) { return _browser.test.some((condition) => this.test(condition)); } throw new Error("Browser's test function is not valid"); }); if (browserDescriptor) { this.parsedResult.browser = browserDescriptor.describe(this.getUA(), this); } return this.parsedResult.browser; } getBrowser() { if (this.parsedResult.browser) { return this.parsedResult.browser; } return this.parseBrowser(); } getBrowserName(toLowerCase) { if (toLowerCase) { return String(this.getBrowser().name).toLowerCase() || ""; } return this.getBrowser().name || ""; } getBrowserVersion() { return this.getBrowser().version; } getOS() { if (this.parsedResult.os) { return this.parsedResult.os; } return this.parseOS(); } parseOS() { this.parsedResult.os = {}; const os = Utils.find(osParsersList, (_os) => { if (typeof _os.test === "function") { return _os.test(this); } if (Array.isArray(_os.test)) { return _os.test.some((condition) => this.test(condition)); } throw new Error("Browser's test function is not valid"); }); if (os) { this.parsedResult.os = os.describe(this.getUA()); } return this.parsedResult.os; } getOSName(toLowerCase) { const { name } = this.getOS(); if (toLowerCase) { return String(name).toLowerCase() || ""; } return name || ""; } getOSVersion() { return this.getOS().version; } getPlatform() { if (this.parsedResult.platform) { return this.parsedResult.platform; } return this.parsePlatform(); } getPlatformType(toLowerCase = false) { const { type } = this.getPlatform(); if (toLowerCase) { return String(type).toLowerCase() || ""; } return type || ""; } parsePlatform() { this.parsedResult.platform = {}; const platform = Utils.find(platformParsersList, (_platform) => { if (typeof _platform.test === "function") { return _platform.test(this); } if (Array.isArray(_platform.test)) { return _platform.test.some((condition) => this.test(condition)); } throw new Error("Browser's test function is not valid"); }); if (platform) { this.parsedResult.platform = platform.describe(this.getUA()); } return this.parsedResult.platform; } getEngine() { if (this.parsedResult.engine) { return this.parsedResult.engine; } return this.parseEngine(); } getEngineName(toLowerCase) { if (toLowerCase) { return String(this.getEngine().name).toLowerCase() || ""; } return this.getEngine().name || ""; } parseEngine() { this.parsedResult.engine = {}; const engine = Utils.find(enginesParsersList, (_engine) => { if (typeof _engine.test === "function") { return _engine.test(this); } if (Array.isArray(_engine.test)) { return _engine.test.some((condition) => this.test(condition)); } throw new Error("Browser's test function is not valid"); }); if (engine) { this.parsedResult.engine = engine.describe(this.getUA()); } return this.parsedResult.engine; } parse() { this.parseBrowser(); this.parseOS(); this.parsePlatform(); this.parseEngine(); return this; } getResult() { return Utils.assign({}, this.parsedResult); } satisfies(checkTree) { const platformsAndOSes = {}; let platformsAndOSCounter = 0; const browsers = {}; let browsersCounter = 0; const allDefinitions = Object.keys(checkTree); allDefinitions.forEach((key) => { const currentDefinition = checkTree[key]; if (typeof currentDefinition === "string") { browsers[key] = currentDefinition; browsersCounter += 1; } else if (typeof currentDefinition === "object") { platformsAndOSes[key] = currentDefinition; platformsAndOSCounter += 1; } }); if (platformsAndOSCounter > 0) { const platformsAndOSNames = Object.keys(platformsAndOSes); const OSMatchingDefinition = Utils.find(platformsAndOSNames, (name) => this.isOS(name)); if (OSMatchingDefinition) { const osResult = this.satisfies(platformsAndOSes[OSMatchingDefinition]); if (osResult !== void 0) { return osResult; } } const platformMatchingDefinition = Utils.find( platformsAndOSNames, (name) => this.isPlatform(name) ); if (platformMatchingDefinition) { const platformResult = this.satisfies(platformsAndOSes[platformMatchingDefinition]); if (platformResult !== void 0) { return platformResult; } } } if (browsersCounter > 0) { const browserNames = Object.keys(browsers); const matchingDefinition = Utils.find(browserNames, (name) => this.isBrowser(name, true)); if (matchingDefinition !== void 0) { return this.compareVersion(browsers[matchingDefinition]); } } return void 0; } isBrowser(browserName, includingAlias = false) { const defaultBrowserName = this.getBrowserName().toLowerCase(); let browserNameLower = browserName.toLowerCase(); const alias = Utils.getBrowserTypeByAlias(browserNameLower); if (includingAlias && alias) { browserNameLower = alias.toLowerCase(); } return browserNameLower === defaultBrowserName; } compareVersion(version) { let expectedResults = [0]; let comparableVersion = version; let isLoose = false; const currentBrowserVersion = this.getBrowserVersion(); if (typeof currentBrowserVersion !== "string") { return void 0; } if (version[0] === ">" || version[0] === "<") { comparableVersion = version.substr(1); if (version[1] === "=") { isLoose = true; comparableVersion = version.substr(2); } else { expectedResults = []; } if (version[0] === ">") { expectedResults.push(1); } else { expectedResults.push(-1); } } else if (version[0] === "=") { comparableVersion = version.substr(1); } else if (version[0] === "~") { isLoose = true; comparableVersion = version.substr(1); } return expectedResults.indexOf( Utils.compareVersions(currentBrowserVersion, comparableVersion, isLoose) ) > -1; } isOS(osName) { return this.getOSName(true) === String(osName).toLowerCase(); } isPlatform(platformType) { return this.getPlatformType(true) === String(platformType).toLowerCase(); } isEngine(engineName) { return this.getEngineName(true) === String(engineName).toLowerCase(); } is(anything, includingAlias = false) { return this.isBrowser(anything, includingAlias) || this.isOS(anything) || this.isPlatform(anything); } some(anythings = []) { return anythings.some((anything) => this.is(anything)); } } class Bowser { static getParser(UA, skipParsingOrHints = false, clientHints = null) { if (typeof UA !== "string") { throw new Error("UserAgent should be a string"); } return new Parser(UA, skipParsingOrHints, clientHints); } static parse(UA, clientHints = null) { return new Parser(UA, clientHints).getResult(); } static get BROWSER_MAP() { return BROWSER_MAP; } static get ENGINE_MAP() { return ENGINE_MAP; } static get OS_MAP() { return OS_MAP; } static get PLATFORMS_MAP() { return PLATFORMS_MAP; } } const browserInfo = Bowser.getParser( globalThis.navigator.userAgent ).getResult(); const UNKNOWN_VALUE = "unknown"; const joinParts = (...parts) => { const value = parts.filter(Boolean).join(" ").trim(); return value || UNKNOWN_VALUE; }; function getEnvironmentInfo() { const os = joinParts(browserInfo.os?.name, browserInfo.os?.version); const browser = joinParts( browserInfo.browser?.name, browserInfo.browser?.version ); const loader = (() => { const handler = GM_info?.scriptHandler; const version = GM_info?.version; if (handler && version) return `${handler} v${version}`; return handler || version || UNKNOWN_VALUE; })(); const scriptVersion = GM_info?.script?.version ?? UNKNOWN_VALUE; const scriptName = GM_info?.script?.name ?? UNKNOWN_VALUE; const url = globalThis?.location?.href ?? UNKNOWN_VALUE; return { os, browser, loader, scriptVersion, scriptName, url }; } class AccountButton { container; accountWrapper; buttons; usernameEl; avatarEl; avatarImg; actionButton; refreshButton; tokenButton; onClick = new EventImpl(); onRefresh = new EventImpl(); onClickSecret = new EventImpl(); events = { click: this.onClick, "click:secret": this.onClickSecret, refresh: this.onRefresh }; _loggedIn; _username; _avatarId; constructor({ loggedIn = false, username = "unnamed", avatarId = "0/0-0" } = {}) { this._loggedIn = loggedIn; this._username = username; this._avatarId = avatarId; const elements = this.createElements(); this.container = elements.container; this.accountWrapper = elements.accountWrapper; this.buttons = elements.buttons; this.usernameEl = elements.usernameEl; this.avatarEl = elements.avatarEl; this.avatarImg = elements.avatarImg; this.actionButton = elements.actionButton; this.refreshButton = elements.refreshButton; this.tokenButton = elements.tokenButton; } createElements() { const container = UI.createEl("vot-block", ["vot-account"]); const accountWrapper = UI.createEl("vot-block", ["vot-account-wrapper"]); accountWrapper.hidden = !this._loggedIn; const avatarImg = UI.createEl("img", [ "vot-account-avatar-img" ]); avatarImg.src = `${avatarServerUrl}/${this._avatarId}/islands-retina-middle`; avatarImg.loading = "lazy"; avatarImg.alt = "user avatar"; const avatarEl = UI.createEl( "vot-block", ["vot-account-avatar"], avatarImg ); const usernameEl = UI.createEl("vot-block", ["vot-account-username"]); usernameEl.textContent = this._username; accountWrapper.append(avatarEl, usernameEl); const buttons = UI.createEl("vot-block", ["vot-account-buttons"]); const actionButton = UI.createOutlinedButton(this.buttonText); actionButton.addEventListener("click", () => { this.onClick.dispatch(); }); const tokenButton = UI.createIconButton(KEY_ICON, { ariaLabel: localizationProvider.get("VOTLoginViaToken") }); tokenButton.hidden = this._loggedIn; tokenButton.addEventListener("click", () => { this.onClickSecret.dispatch(); }); const refreshButton = UI.createIconButton(REFRESH_ICON, { ariaLabel: localizationProvider.get("VOTRefresh") }); refreshButton.addEventListener("click", () => { this.onRefresh.dispatch(); }); buttons.append(actionButton, tokenButton, refreshButton); container.append(accountWrapper, buttons); return { container, accountWrapper, buttons, usernameEl, avatarImg, avatarEl, actionButton, refreshButton, tokenButton }; } addEventListener(type, listener) { addComponentEventListener(this.events, type, listener); return this; } removeEventListener(type, listener) { removeComponentEventListener(this.events, type, listener); return this; } get buttonText() { return this._loggedIn ? localizationProvider.get("VOTLogout") : localizationProvider.get("VOTLogin"); } get loggedIn() { return this._loggedIn; } set loggedIn(isLoggedIn) { this._loggedIn = isLoggedIn; this.accountWrapper.hidden = !this._loggedIn; this.actionButton.textContent = this.buttonText; this.tokenButton.hidden = this._loggedIn; } get avatarId() { return this._avatarId; } set avatarId(avatarId) { this._avatarId = avatarId ?? "0/0-0"; this.avatarImg.src = `${avatarServerUrl}/${this._avatarId}/islands-retina-middle`; } get username() { return this._username; } set username(username) { this._username = username ?? "unnamed"; this.usernameEl.textContent = this._username; } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } } class Checkbox { container; input; label; onChange = new EventImpl(); events = { change: this.onChange }; _labelHtml; _checked; _isSubCheckbox; constructor({ labelHtml, checked = false, isSubCheckbox = false }) { this._labelHtml = labelHtml; this._checked = checked; this._isSubCheckbox = isSubCheckbox; const elements = this.createElements(); this.container = elements.container; this.input = elements.input; this.label = elements.label; } createElements() { const container = UI.createEl("label", ["vot-checkbox"]); if (this._isSubCheckbox) { container.classList.add("vot-checkbox-sub"); } const input = document.createElement("input"); input.type = "checkbox"; input.checked = this._checked; input.addEventListener("change", () => { this._checked = input.checked; this.onChange.dispatch(this._checked); }); const label = UI.createEl("span"); D(this._labelHtml, label); container.append(input, label); return { container, input, label }; } addEventListener(_type, listener) { addComponentEventListener(this.events, "change", listener); return this; } removeEventListener(_type, listener) { removeComponentEventListener(this.events, "change", listener); return this; } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } get disabled() { return this.input.disabled; } set disabled(isDisabled) { this.input.disabled = isDisabled; } get checked() { return this._checked; } set checked(isChecked) { if (this._checked === isChecked) { return; } this._checked = this.input.checked = isChecked; this.onChange.dispatch(this._checked); } } class Details { container; header; arrowIcon; onClick = new EventImpl(); events = { click: this.onClick }; _titleHtml; constructor({ titleHtml }) { this._titleHtml = titleHtml; const elements = this.createElements(); this.container = elements.container; this.header = elements.header; this.arrowIcon = elements.arrowIcon; } createElements() { const container = UI.createEl("vot-block", ["vot-details"]); UI.makeButtonLike(container); const header = UI.createEl("vot-block"); header.append(this._titleHtml); const arrowIcon = UI.createEl("vot-block", ["vot-details-arrow-icon"]); D(CHEVRON_ICON, arrowIcon); container.append(header, arrowIcon); container.addEventListener("click", () => { this.onClick.dispatch(); }); return { container, header, arrowIcon }; } addEventListener(_type, listener) { addComponentEventListener(this.events, "click", listener); return this; } removeEventListener(_type, listener) { removeComponentEventListener(this.events, "click", listener); return this; } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } } class HotkeyButton { container; button; onChange = new EventImpl(); events = { change: this.onChange }; _labelHtml; _key; pressedKeys; comboKeys; recording = false; constructor({ labelHtml, key = null }) { this._labelHtml = labelHtml; this._key = key; this.pressedKeys = new Set(); this.comboKeys = new Set(); const elements = this.createElements(); this.container = elements.container; this.button = elements.button; } stopRecordingKeys() { this.recording = false; document.removeEventListener("keydown", this.keydownHandle); document.removeEventListener("keyup", this.keyupOrBlurHandle); globalThis.removeEventListener("blur", this.blurHandle); delete this.button.dataset.status; this.pressedKeys.clear(); this.comboKeys.clear(); } keydownHandle = (event) => { if (!this.recording || event.repeat) { return; } event.preventDefault(); if (event.code === "Escape") { this.key = null; this.button.textContent = this.keyText; this.stopRecordingKeys(); return; } this.pressedKeys.add(event.code); this.comboKeys.add(event.code); this.button.textContent = formatKeysComboDisplay(this.pressedKeys); }; keyupOrBlurHandle = (event) => { if (!this.recording) return; if (event) { this.pressedKeys.delete(event.code); this.button.textContent = this.pressedKeys.size ? formatKeysComboDisplay(this.pressedKeys) : formatKeysComboDisplay(this.comboKeys); if (this.pressedKeys.size) { return; } } this.key = this.comboKeys.size ? formatKeysCombo(this.comboKeys) : null; this.stopRecordingKeys(); }; blurHandle = () => { this.keyupOrBlurHandle(); }; createElements() { const container = UI.createEl("vot-block", ["vot-hotkey"]); const label = UI.createEl("vot-block", ["vot-hotkey-label"]); label.textContent = this._labelHtml; const button = UI.createEl("vot-block", ["vot-hotkey-button"]); UI.makeButtonLike(button); button.textContent = this.keyText; button.addEventListener("click", () => { if (this.recording) { this.stopRecordingKeys(); this.button.textContent = this.keyText; return; } button.dataset.status = "active"; this.recording = true; this.pressedKeys.clear(); this.comboKeys.clear(); this.button.textContent = localizationProvider.get( "PressTheKeyCombination" ); document.addEventListener("keydown", this.keydownHandle); document.addEventListener("keyup", this.keyupOrBlurHandle); globalThis.addEventListener("blur", this.blurHandle); }); container.append(label, button); return { container, button, label }; } addEventListener(_type, listener) { addComponentEventListener(this.events, "change", listener); return this; } removeEventListener(_type, listener) { removeComponentEventListener(this.events, "change", listener); return this; } set hidden(isHidden) { setHiddenState(this.container, isHidden); } get hidden() { return getHiddenState(this.container); } get key() { return this._key; } get keyText() { if (!this._key) { return localizationProvider.get("None"); } return formatKeysComboDisplay(this._key); } set key(newKey) { if (this._key === newKey) { return; } this._key = newKey; this.button.textContent = this.keyText; this.onChange.dispatch(this._key); } } function formatKeysCombo(keys) { const keysArray = Array.isArray(keys) ? keys : Array.from(keys); return keysArray.map((code) => code.replace("Key", "").replace("Digit", "")).join("+"); } function formatKeysComboDisplay(keys) { let parts; if (typeof keys === "string") { parts = keys.split("+").filter(Boolean); } else if (Array.isArray(keys)) { parts = keys; } else { parts = Array.from(keys); } const map = (k2) => { switch (k2) { case "ControlLeft": case "ControlRight": case "Control": return "Ctrl"; case "ShiftLeft": case "ShiftRight": case "Shift": return "Shift"; case "AltLeft": case "AltRight": case "Alt": return "Alt"; case "MetaLeft": case "MetaRight": case "Meta": return "Meta"; case "Space": return "Space"; case "ArrowUp": return "↑"; case "ArrowDown": return "↓"; case "ArrowLeft": return "←"; case "ArrowRight": return "→"; default: return k2.replace("Key", "").replace("Digit", ""); } }; const priority = (k2) => { const m2 = map(k2); if (m2 === "Ctrl") return 0; if (m2 === "Alt") return 1; if (m2 === "Shift") return 2; if (m2 === "Meta") return 3; return 10; }; return parts.slice().sort((a2, b2) => priority(a2) - priority(b2)).map(map).join("+"); } const SETTINGS_EVENT_KEYS = [ "click:bugReport", "click:resetSettings", "update:account", "change:autoTranslate", "change:autoSubtitles", "change:showVideoVolume", "change:audioBooster", "change:useLivelyVoice", "change:subtitlesHighlightWords", "change:subtitlesSmartLayout", "change:proxyWorkerHost", "change:useNewAudioPlayer", "change:onlyBypassMediaCSP", "change:showPiPButton", "input:subtitlesMaxLength", "input:subtitlesFontSize", "input:subtitlesBackgroundOpacity", "input:autoHideButtonDelay", "select:proxyTranslationStatus", "select:translationTextService", "select:buttonPosition", "select:menuLanguage" ]; function createSettingsEvents() { const events = {}; for (const key of SETTINGS_EVENT_KEYS) { events[key] = new EventImpl(); } return events; } class SettingsView { static PERSIST_DELAY_MS = 250; globalPortal; initialized = false; data; videoHandler; suppressSubtitlesSmartLayoutCheckboxChange = false; events = createSettingsEvents(); persistTimerIds = {}; dialog; accountButton; accountButtonRefreshTooltip; accountButtonTokenTooltip; autoTranslateCheckbox; autoSubtitlesCheckbox; dontTranslateLanguagesCheckbox; dontTranslateLanguagesSelect; autoSetVolumeSliderLabel; autoSetVolumeCheckbox; smartDuckingCheckbox; autoSetVolumeSlider; showVideoVolumeSliderCheckbox; audioBoosterCheckbox; audioBoosterTooltip; syncVolumeCheckbox; downloadWithNameCheckbox; sendNotifyOnCompleteCheckbox; useLivelyVoiceCheckbox; useLivelyVoiceTooltip; useAudioDownloadCheckbox; useAudioDownloadCheckboxLabel; useAudioDownloadCheckboxTooltip; subtitlesDownloadFormatSelectLabel; subtitlesDownloadFormatSelect; subtitlesHighlightWordsCheckbox; subtitlesSmartLayoutCheckbox; subtitlesMaxLengthSliderLabel; subtitlesMaxLengthSlider; subtitlesFontSizeSliderLabel; subtitlesFontSizeSlider; subtitlesBackgroundOpacitySliderLabel; subtitlesBackgroundOpacitySlider; translateHotkeyButton; subtitlesHotkeyButton; proxyWorkerHostTextfield; proxyTranslationStatusSelectLabel; proxyTranslationStatusSelectTooltip; proxyTranslationStatusSelect; translateAPIErrorsCheckbox; useNewAudioPlayerCheckbox; useNewAudioPlayerTooltip; onlyBypassMediaCSPCheckbox; onlyBypassMediaCSPTooltip; translationTextServiceLabel; translationTextServiceSelect; translationTextServiceTooltip; detectServiceLabel; detectServiceSelect; showPiPButtonCheckbox; autoHideButtonDelaySliderLabel; autoHideButtonDelaySlider; buttonPositionSelectLabel; buttonPositionSelect; buttonPositionTooltip; menuLanguageSelectLabel; menuLanguageSelect; bugReportButton; resetSettingsButton; constructor({ globalPortal, data = {}, videoHandler }) { this.globalPortal = globalPortal; this.data = data; this.videoHandler = videoHandler; } isInitialized() { return this.initialized; } createAccordionSection(title, options = {}) { const section = UI.createEl("vot-block", ["vot-settings-section"]); const header = new Details({ titleHtml: title }); header.container.classList.add("vot-settings-section-header"); const sectionId = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`; const headerId = `vot-settings-section-header-${sectionId}`; const contentId = `vot-settings-section-content-${sectionId}`; header.container.id = headerId; const content = UI.createEl("vot-block", ["vot-settings-section-content"]); content.id = contentId; content.setAttribute("role", "region"); content.setAttribute("aria-labelledby", headerId); header.container.setAttribute("aria-controls", contentId); const setOpen = (open) => { header.container.dataset.open = open ? "true" : "false"; header.container.setAttribute("aria-expanded", open ? "true" : "false"); content.hidden = !open; }; const getOpen = () => header.container.dataset.open === "true"; setOpen(!!options.open); header.addEventListener("click", () => { const isOpen = header.container.dataset.open === "true"; setOpen(!isOpen); }); section.append(header.container, content); return { title, container: section, header: header.container, content, setOpen, getOpen }; } setSubtitlesSmartLayout(checked) { this.data.subtitlesSmartLayout = checked; void votStorage.set("subtitlesSmartLayout", checked); if (this.subtitlesSmartLayoutCheckbox?.checked !== checked) { this.suppressSubtitlesSmartLayoutCheckboxChange = true; this.subtitlesSmartLayoutCheckbox.checked = checked; this.suppressSubtitlesSmartLayoutCheckboxChange = false; } this.events["change:subtitlesSmartLayout"].dispatch(checked); } scheduleStoragePersist(key, value) { const prevTimerId = this.persistTimerIds[key]; if (prevTimerId !== void 0) { globalThis.clearTimeout(prevTimerId); } this.persistTimerIds[key] = globalThis.setTimeout(() => { this.persistTimerIds[key] = void 0; void votStorage.set(key, value); }, SettingsView.PERSIST_DELAY_MS); } flushStoragePersists() { for (const key of Object.keys(this.persistTimerIds)) { const timerId = this.persistTimerIds[key]; if (timerId === void 0) { continue; } globalThis.clearTimeout(timerId); this.persistTimerIds[key] = void 0; const value = this.data[key]; if (typeof value === "number") { void votStorage.set(key, value); } } } bindPersistedSetting({ control, event, apply, storageKey, readPersistedValue, logLabel, dispatch, afterPersist }) { control.addEventListener(event, async (value) => { apply(value); await votStorage.set(storageKey, readPersistedValue()); if (afterPersist) { await afterPersist(value); } dispatch?.(value); }); } initUI() { if (this.isInitialized()) { throw new Error("[VOT] SettingsView is already initialized"); } this.dialog = new Dialog({ titleHtml: localizationProvider.get("VOTSettings") }); this.globalPortal.appendChild(this.dialog.container); const accountSection = this.createAccordionSection( localizationProvider.get("VOTMyAccount"), { open: true } ); const translationSection = this.createAccordionSection( localizationProvider.get("translationSettings"), { open: true } ); const subtitlesSection = this.createAccordionSection( localizationProvider.get("subtitlesSettings") ); const hotkeysSection = this.createAccordionSection( localizationProvider.get("hotkeysSettings") ); const proxySection = this.createAccordionSection( localizationProvider.get("proxySettings") ); const miscSection = this.createAccordionSection( localizationProvider.get("miscSettings") ); const appearanceSection = this.createAccordionSection( localizationProvider.get("appearance") ); const aboutSection = this.createAccordionSection( localizationProvider.get("aboutExtension") ); const sections = [ accountSection, translationSection, subtitlesSection, hotkeysSection, proxySection, miscSection, appearanceSection, aboutSection ]; this.dialog.bodyContainer.append( ...sections.map((section) => section.container) ); this.accountButton = new AccountButton({ avatarId: this.data.account?.avatarId, username: this.data.account?.username, loggedIn: !!this.data.account?.token }); if (votStorage.isSupportOnlyLS) { this.accountButton.refreshButton.setAttribute("disabled", "true"); this.accountButton.actionButton.setAttribute("disabled", "true"); } else { this.accountButtonRefreshTooltip = new Tooltip({ target: this.accountButton.refreshButton, content: localizationProvider.get("VOTRefresh"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal }); } this.accountButtonTokenTooltip = new Tooltip({ target: this.accountButton.tokenButton, content: localizationProvider.get("VOTLoginViaToken"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal }); this.autoTranslateCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTAutoTranslate"), checked: this.data.autoTranslate }); this.autoSubtitlesCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTAutoSubtitles"), checked: this.data.autoSubtitles }); const dontTranslateLanguages = this.data.dontTranslateLanguages ?? []; this.dontTranslateLanguagesCheckbox = new Checkbox({ labelHtml: localizationProvider.get("DontTranslateSelectedLanguages"), checked: this.data.enabledDontTranslateLanguages }); this.dontTranslateLanguagesSelect = new Select({ dialogParent: this.globalPortal, dialogTitle: localizationProvider.get("DontTranslateSelectedLanguages"), selectTitle: dontTranslateLanguages.map((lang2) => localizationProvider.get(`langs.${lang2}`)).join(", ") || localizationProvider.get("DontTranslateSelectedLanguages"), items: Select.genLanguageItems(availableLangs).map((item) => ({ ...item, selected: dontTranslateLanguages.includes(item.value) })), multiSelect: true, labelElement: this.dontTranslateLanguagesCheckbox.container }); this.dontTranslateLanguagesSelect.disabled = !this.dontTranslateLanguagesCheckbox.checked; const autoVolume = this.data.autoVolume ?? defaultAutoVolume; this.autoSetVolumeSliderLabel = new SliderLabel({ labelText: localizationProvider.get("VOTAutoSetVolume"), value: autoVolume }); this.autoSetVolumeCheckbox = new Checkbox({ labelHtml: this.autoSetVolumeSliderLabel.container, checked: this.data.enabledAutoVolume ?? true }); this.autoSetVolumeSlider = new Slider({ labelHtml: this.autoSetVolumeCheckbox.container, value: autoVolume }); 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.showVideoVolumeSliderCheckbox = new Checkbox({ labelHtml: localizationProvider.get("showVideoVolumeSlider"), checked: this.data.showVideoSlider }); this.audioBoosterCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTAudioBooster"), checked: this.data.audioBooster }); if (!this.videoHandler?.isAudioContextSupported) { this.audioBoosterCheckbox.disabled = true; this.audioBoosterTooltip = new Tooltip({ target: this.audioBoosterCheckbox.container, content: localizationProvider.get("VOTNeedWebAudioAPI"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal }); } this.syncVolumeCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTSyncVolume"), checked: this.data.syncVolume }); this.downloadWithNameCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTDownloadWithName"), checked: this.data.downloadWithName }); this.downloadWithNameCheckbox.disabled = !isSupportGMXhr; this.sendNotifyOnCompleteCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTSendNotifyOnComplete"), checked: this.data.sendNotifyOnComplete }); this.useLivelyVoiceCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTUseLivelyVoice"), checked: this.data.useLivelyVoice }); this.useLivelyVoiceTooltip = new Tooltip({ target: this.useLivelyVoiceCheckbox.container, content: localizationProvider.get("VOTAccountRequired"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal, hidden: !!this.data.account?.token }); if (!this.data.account?.token) { this.useLivelyVoiceCheckbox.disabled = true; } this.useAudioDownloadCheckboxLabel = new Label({ labelText: localizationProvider.get("VOTUseAudioDownload"), icon: WARNING_ICON }); this.useAudioDownloadCheckbox = new Checkbox({ labelHtml: this.useAudioDownloadCheckboxLabel.container, checked: this.data.useAudioDownload }); if (!isUnsafeWindowAllowed && !(typeof IS_EXTENSION !== "undefined" && IS_EXTENSION)) { this.useAudioDownloadCheckbox.disabled = true; } this.useAudioDownloadCheckboxTooltip = new Tooltip({ target: this.useAudioDownloadCheckboxLabel.container, content: localizationProvider.get("VOTUseAudioDownloadWarning"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal }); accountSection.content.append(this.accountButton.container); translationSection.content.append( this.autoTranslateCheckbox.container, this.autoSubtitlesCheckbox.container, this.dontTranslateLanguagesSelect.container, this.autoSetVolumeSlider.container, this.smartDuckingCheckbox.container, this.showVideoVolumeSliderCheckbox.container, this.audioBoosterCheckbox.container, this.syncVolumeCheckbox.container, this.downloadWithNameCheckbox.container, this.sendNotifyOnCompleteCheckbox.container, this.useLivelyVoiceCheckbox.container, this.useAudioDownloadCheckbox.container ); this.subtitlesDownloadFormatSelectLabel = new Label({ labelText: localizationProvider.get("VOTSubtitlesDownloadFormat") }); this.subtitlesDownloadFormatSelect = new Select({ selectTitle: this.data.subtitlesDownloadFormat ?? localizationProvider.get("VOTSubtitlesDownloadFormat"), dialogTitle: localizationProvider.get("VOTSubtitlesDownloadFormat"), dialogParent: this.globalPortal, labelElement: this.subtitlesDownloadFormatSelectLabel.container, items: subtitlesFormats.map((format) => ({ label: format.toUpperCase(), value: format, selected: format === this.data.subtitlesDownloadFormat })) }); this.subtitlesHighlightWordsCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTHighlightWords"), checked: this.data.highlightWords }); const subtitlesSmartLayout2 = this.data.subtitlesSmartLayout ?? true; this.subtitlesSmartLayoutCheckbox = new Checkbox({ labelHtml: localizationProvider.get("subtitlesSmartLayout"), checked: subtitlesSmartLayout2 }); const subtitlesMaxLength = this.data.subtitlesMaxLength ?? 300; this.subtitlesMaxLengthSliderLabel = new SliderLabel({ labelText: localizationProvider.get("VOTSubtitlesMaxLength"), labelEOL: ":", value: subtitlesMaxLength, symbol: "" }); this.subtitlesMaxLengthSlider = new Slider({ labelHtml: this.subtitlesMaxLengthSliderLabel.container, value: subtitlesMaxLength, min: 50, max: 300 }); const subtitlesFontSize = this.data.subtitlesFontSize ?? 20; this.subtitlesFontSizeSliderLabel = new SliderLabel({ labelText: localizationProvider.get("VOTSubtitlesFontSize"), labelEOL: ":", value: subtitlesFontSize, symbol: "px" }); this.subtitlesFontSizeSlider = new Slider({ labelHtml: this.subtitlesFontSizeSliderLabel.container, value: subtitlesFontSize, min: 8, max: 50 }); const subtitlesOpacity = this.data.subtitlesOpacity ?? 20; this.subtitlesBackgroundOpacitySliderLabel = new SliderLabel({ labelText: localizationProvider.get("VOTSubtitlesOpacity"), labelEOL: ":", value: subtitlesOpacity, symbol: "%" }); this.subtitlesBackgroundOpacitySlider = new Slider({ labelHtml: this.subtitlesBackgroundOpacitySliderLabel.container, value: subtitlesOpacity, min: 0, max: 100 }); subtitlesSection.content.append( this.subtitlesDownloadFormatSelect.container, this.subtitlesHighlightWordsCheckbox.container, this.subtitlesSmartLayoutCheckbox.container, this.subtitlesMaxLengthSlider.container, this.subtitlesFontSizeSlider.container, this.subtitlesBackgroundOpacitySlider.container ); this.translateHotkeyButton = new HotkeyButton({ labelHtml: localizationProvider.get("translateVideo"), key: this.data.translationHotkey }); this.subtitlesHotkeyButton = new HotkeyButton({ labelHtml: localizationProvider.get("VOTSubtitles"), key: this.data.subtitlesHotkey }); hotkeysSection.content.append( this.translateHotkeyButton.container, this.subtitlesHotkeyButton.container ); this.proxyWorkerHostTextfield = new Textfield({ labelHtml: localizationProvider.get("VOTProxyWorkerHost"), value: this.data.proxyWorkerHost, placeholder: proxyWorkerHost }); const proxyEnabledLabels = [ localizationProvider.get("VOTTranslateProxyDisabled"), localizationProvider.get("VOTTranslateProxyEnabled"), localizationProvider.get("VOTTranslateProxyEverything") ]; const translateProxyEnabled = this.data.translateProxyEnabled ?? 0; const isTranslateProxyRequired = countryCode && proxyOnlyCountries.includes(countryCode); this.proxyTranslationStatusSelectLabel = new Label({ icon: isTranslateProxyRequired ? WARNING_ICON : void 0, labelText: localizationProvider.get("VOTTranslateProxyStatus") }); if (isTranslateProxyRequired) { this.proxyTranslationStatusSelectTooltip = new Tooltip({ target: this.proxyTranslationStatusSelectLabel.icon, content: localizationProvider.get("VOTTranslateProxyStatusDefault"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal }); } this.proxyTranslationStatusSelect = new Select({ selectTitle: proxyEnabledLabels[translateProxyEnabled], dialogTitle: localizationProvider.get("VOTTranslateProxyStatus"), dialogParent: this.globalPortal, labelElement: this.proxyTranslationStatusSelectLabel.container, items: proxyEnabledLabels.map((label, idx) => ({ label, value: idx.toString(), selected: idx === translateProxyEnabled, disabled: idx === 0 && isProxyOnlyExtension })) }); proxySection.content.append( this.proxyWorkerHostTextfield.container, this.proxyTranslationStatusSelect.container ); this.translateAPIErrorsCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTTranslateAPIErrors"), checked: this.data.translateAPIErrors ?? true }); this.translateAPIErrorsCheckbox.hidden = localizationProvider.lang === "ru"; this.useNewAudioPlayerCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTNewAudioPlayer"), checked: this.data.newAudioPlayer }); if (!this.videoHandler?.isAudioContextSupported) { this.useNewAudioPlayerCheckbox.disabled = true; this.useNewAudioPlayerTooltip = new Tooltip({ target: this.useNewAudioPlayerCheckbox.container, content: localizationProvider.get("VOTNeedWebAudioAPI"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal }); } const onlyBypassMediaCSPLabel = this.videoHandler?.site.needBypassCSP ? `${localizationProvider.get("VOTOnlyBypassMediaCSP")} (${localizationProvider.get("VOTMediaCSPEnabledOnSite")})` : localizationProvider.get("VOTOnlyBypassMediaCSP"); this.onlyBypassMediaCSPCheckbox = new Checkbox({ labelHtml: onlyBypassMediaCSPLabel, checked: this.data.onlyBypassMediaCSP, isSubCheckbox: true }); if (!this.videoHandler?.isAudioContextSupported) { this.onlyBypassMediaCSPTooltip = new Tooltip({ target: this.onlyBypassMediaCSPCheckbox.container, content: localizationProvider.get("VOTNeedWebAudioAPI"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal }); } this.onlyBypassMediaCSPCheckbox.disabled = !this.data.newAudioPlayer && !!this.videoHandler?.isAudioContextSupported; if (!this.data.newAudioPlayer) { this.onlyBypassMediaCSPCheckbox.hidden = true; } this.translationTextServiceLabel = new Label({ labelText: localizationProvider.get("VOTTranslationTextService"), icon: HELP_ICON }); const translationService = this.data.translationService ?? defaultTranslationService; this.translationTextServiceSelect = new Select({ selectTitle: localizationProvider.get(`services.${translationService}`), dialogTitle: localizationProvider.get("VOTTranslationTextService"), dialogParent: this.globalPortal, labelElement: this.translationTextServiceLabel.container, items: foswlyServices.map((service) => ({ label: localizationProvider.get(`services.${service}`), value: service, selected: service === translationService })) }); this.translationTextServiceTooltip = new Tooltip({ target: this.translationTextServiceLabel.icon, content: localizationProvider.get("VOTNotAffectToVoice"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal }); this.detectServiceLabel = new Label({ labelText: localizationProvider.get("VOTDetectService") }); const detectService = this.data.detectService ?? defaultDetectService; this.detectServiceSelect = new Select({ selectTitle: localizationProvider.get(`services.${detectService}`), dialogTitle: localizationProvider.get("VOTDetectService"), dialogParent: this.globalPortal, labelElement: this.detectServiceLabel.container, items: detectServices.map((service) => ({ label: localizationProvider.get(`services.${service}`), value: service, selected: service === detectService })) }); this.showPiPButtonCheckbox = new Checkbox({ labelHtml: localizationProvider.get("VOTShowPiPButton"), checked: this.data.showPiPButton }); this.showPiPButtonCheckbox.hidden = !isPiPAvailable(); const autoHideButtonDelaySec = Math.round( (this.data.autoHideButtonDelay ?? defaultAutoHideDelay) / 1e3 * 10 ) / 10; this.autoHideButtonDelaySliderLabel = new SliderLabel({ labelText: localizationProvider.get("autoHideButtonDelay"), labelEOL: ":", value: autoHideButtonDelaySec, symbol: ` ${localizationProvider.get("secs")}` }); this.autoHideButtonDelaySlider = new Slider({ labelHtml: this.autoHideButtonDelaySliderLabel.container, value: autoHideButtonDelaySec, min: 0.1, max: 3, step: 0.1 }); this.buttonPositionSelectLabel = new Label({ labelText: localizationProvider.get("buttonPosition"), icon: HELP_ICON }); const buttonPos = this.data.buttonPos ?? "default"; this.buttonPositionSelect = new Select({ selectTitle: localizationProvider.get(`position.${buttonPos}`), dialogTitle: localizationProvider.get("buttonPosition"), labelElement: this.buttonPositionSelectLabel.container, dialogParent: this.globalPortal, items: positions.map((position2) => ({ label: localizationProvider.get(`position.${position2}`), value: position2, selected: position2 === buttonPos })) }); this.buttonPositionTooltip = new Tooltip({ target: this.buttonPositionSelectLabel.icon, content: localizationProvider.get("minButtonPositionContainer"), position: "bottom", backgroundColor: "var(--vot-helper-ondialog)", parentElement: this.globalPortal }); this.menuLanguageSelectLabel = new Label({ labelText: localizationProvider.get("VOTMenuLanguage") }); this.menuLanguageSelect = new Select({ selectTitle: localizationProvider.get( `langs.${localizationProvider.langOverride}` ), dialogTitle: localizationProvider.get("VOTMenuLanguage"), labelElement: this.menuLanguageSelectLabel.container, dialogParent: this.globalPortal, items: Select.genLanguageItems( localizationProvider.getAvailableLangs(), localizationProvider.langOverride ) }); this.bugReportButton = UI.createOutlinedButton( localizationProvider.get("VOTBugReport") ); this.resetSettingsButton = UI.createButton( localizationProvider.get("resetSettings") ); miscSection.content.append( this.translateAPIErrorsCheckbox.container, this.useNewAudioPlayerCheckbox.container, this.onlyBypassMediaCSPCheckbox.container ); translationSection.content.append( this.translationTextServiceSelect.container, this.detectServiceSelect.container ); appearanceSection.content.append( this.showPiPButtonCheckbox.container, this.autoHideButtonDelaySlider.container, this.buttonPositionSelect.container, this.menuLanguageSelect.container ); const envInfo = getEnvironmentInfo(); const versionInfo = UI.createInformation( `${localizationProvider.get("VOTVersion")}:`, envInfo.scriptVersion || GM_info.script.version || localizationProvider.get("notFound") ); const buildAuthors = String("Toil, SashaXser, MrSoczekXD, mynovelhost, sodapng"); const authorsInfo = UI.createInformation( `${localizationProvider.get("VOTAuthors")}:`, GM_info.script.author || buildAuthors || localizationProvider.get("notFound") ); const loaderInfo = UI.createInformation( `${localizationProvider.get("VOTLoader")}:`, envInfo.loader ); const userBrowserInfo = UI.createInformation( `${localizationProvider.get("VOTBrowser")}:`, `${envInfo.browser} (${envInfo.os})` ); const localeUpdatedAt = new Date( (this.data.localeUpdatedAt ?? 0) * 1e3 ).toLocaleString(); const localeHashValue = this.data.localeHash ?? localizationProvider.get("notFound"); const localeInfoValue = b`${localeHashValue}
(${localizationProvider.get( "VOTUpdatedAt" )} ${localeUpdatedAt})`; const localeInfo = UI.createInformation( `${localizationProvider.get("VOTLocaleHash")}:`, localeInfoValue ); const updateLocaleFilesButton = UI.createOutlinedButton( localizationProvider.get("VOTUpdateLocaleFiles") ); updateLocaleFilesButton.addEventListener("click", async () => { await votStorage.set("localeHash", ""); await localizationProvider.update(true); globalThis.location.reload(); }); aboutSection.content.append( versionInfo.container, authorsInfo.container, loaderInfo.container, userBrowserInfo.container, localeInfo.container, updateLocaleFilesButton ); this.dialog.footerContainer.append( this.bugReportButton, this.resetSettingsButton ); this.initialized = true; return this; } initUIEvents() { if (!this.isInitialized()) { throw new Error("[VOT] SettingsView isn't initialized"); } this.accountButton.addEventListener("click", async () => { if (votStorage.isSupportOnlyLS) return; if (this.accountButton.loggedIn) { await votStorage.delete("account"); this.data.account = {}; return this.updateAccountInfo(); } globalThis.open(authServerUrl, "_blank")?.focus(); }); this.accountButton.addEventListener("click:secret", async () => { const dialog = new Dialog({ titleHtml: localizationProvider.get("VOTLoginViaToken"), isTemp: true }); this.globalPortal.appendChild(dialog.container); const tokenInfoEl = UI.createEl( "vot-block", void 0, localizationProvider.get("VOTYandexTokenInfo") ); const tokenTextfield = new Textfield({ labelHtml: localizationProvider.get("VOTYandexToken"), value: this.data.account?.token }); tokenTextfield.addEventListener("change", async (token) => { this.data.account = token ? { expires: Date.now() + 3153418e4, token } : {}; await votStorage.set("account", this.data.account); this.updateAccountInfo(); }); dialog.bodyContainer.append(tokenInfoEl, tokenTextfield.container); dialog.open(); }); this.accountButton.addEventListener("refresh", async () => { if (votStorage.isSupportOnlyLS) return; this.data.account = await votStorage.get("account", {}); this.updateAccountInfo(); }); this.bindPersistedSetting({ control: this.autoTranslateCheckbox, event: "change", apply: (checked) => { this.data.autoTranslate = checked; }, storageKey: "autoTranslate", readPersistedValue: () => this.data.autoTranslate, logLabel: "autoTranslate", dispatch: (checked) => this.events["change:autoTranslate"].dispatch(checked) }); this.bindPersistedSetting({ control: this.autoSubtitlesCheckbox, event: "change", apply: (checked) => { this.data.autoSubtitles = checked; }, storageKey: "autoSubtitles", readPersistedValue: () => this.data.autoSubtitles, logLabel: "autoSubtitles", dispatch: (checked) => this.events["change:autoSubtitles"].dispatch(checked) }); this.dontTranslateLanguagesCheckbox.addEventListener( "change", async (checked) => { this.data.enabledDontTranslateLanguages = checked; this.dontTranslateLanguagesSelect.disabled = !checked; await votStorage.set( "enabledDontTranslateLanguages", this.data.enabledDontTranslateLanguages ); } ); this.dontTranslateLanguagesSelect.addEventListener( "selectItem", async (values) => { this.data.dontTranslateLanguages = values; await votStorage.set( "dontTranslateLanguages", this.data.dontTranslateLanguages ); } ); this.bindPersistedSetting({ control: this.autoSetVolumeCheckbox, event: "change", apply: (checked) => { this.data.enabledAutoVolume = checked; this.autoSetVolumeSlider.disabled = !checked; this.smartDuckingCheckbox.disabled = !checked; }, storageKey: "enabledAutoVolume", readPersistedValue: () => this.data.enabledAutoVolume, logLabel: "enabledAutoVolume", afterPersist: async () => this.videoHandler?.setupAudioSettings?.() }); this.bindPersistedSetting({ control: this.smartDuckingCheckbox, event: "change", apply: (checked) => { this.data.enabledSmartDucking = checked; }, storageKey: "enabledSmartDucking", readPersistedValue: () => this.data.enabledSmartDucking, logLabel: "enabledSmartDucking" }); this.bindPersistedSetting({ control: this.autoSetVolumeSlider, event: "input", apply: (value) => { this.data.autoVolume = this.autoSetVolumeSliderLabel.value = value; }, storageKey: "autoVolume", readPersistedValue: () => this.data.autoVolume, logLabel: "autoVolume" }); this.bindPersistedSetting({ control: this.showVideoVolumeSliderCheckbox, event: "change", apply: (checked) => { this.data.showVideoSlider = checked; }, storageKey: "showVideoSlider", readPersistedValue: () => this.data.showVideoSlider, logLabel: "showVideoVolumeSlider", dispatch: (checked) => this.events["change:showVideoVolume"].dispatch(checked) }); this.bindPersistedSetting({ control: this.audioBoosterCheckbox, event: "change", apply: (checked) => { this.data.audioBooster = checked; }, storageKey: "audioBooster", readPersistedValue: () => this.data.audioBooster, logLabel: "audioBooster", dispatch: (checked) => this.events["change:audioBooster"].dispatch(checked) }); this.bindPersistedSetting({ control: this.syncVolumeCheckbox, event: "change", apply: (checked) => { this.data.syncVolume = checked; }, storageKey: "syncVolume", readPersistedValue: () => this.data.syncVolume, logLabel: "syncVolume" }); this.bindPersistedSetting({ control: this.downloadWithNameCheckbox, event: "change", apply: (checked) => { this.data.downloadWithName = checked; }, storageKey: "downloadWithName", readPersistedValue: () => this.data.downloadWithName, logLabel: "downloadWithName" }); this.bindPersistedSetting({ control: this.sendNotifyOnCompleteCheckbox, event: "change", apply: (checked) => { this.data.sendNotifyOnComplete = checked; }, storageKey: "sendNotifyOnComplete", readPersistedValue: () => this.data.sendNotifyOnComplete, logLabel: "sendNotifyOnComplete" }); this.bindPersistedSetting({ control: this.useLivelyVoiceCheckbox, event: "change", apply: (checked) => { this.data.useLivelyVoice = checked; }, storageKey: "useLivelyVoice", readPersistedValue: () => this.data.useLivelyVoice, logLabel: "useLivelyVoice", dispatch: (checked) => this.events["change:useLivelyVoice"].dispatch(checked) }); this.bindPersistedSetting({ control: this.useAudioDownloadCheckbox, event: "change", apply: (checked) => { this.data.useAudioDownload = checked; }, storageKey: "useAudioDownload", readPersistedValue: () => this.data.useAudioDownload, logLabel: "useAudioDownload" }); this.bindPersistedSetting({ control: this.subtitlesDownloadFormatSelect, event: "selectItem", apply: (item) => { this.data.subtitlesDownloadFormat = item; }, storageKey: "subtitlesDownloadFormat", readPersistedValue: () => this.data.subtitlesDownloadFormat, logLabel: "subtitlesDownloadFormat" }); this.bindPersistedSetting({ control: this.subtitlesHighlightWordsCheckbox, event: "change", apply: (checked) => { this.data.highlightWords = checked; }, storageKey: "highlightWords", readPersistedValue: () => this.data.highlightWords, logLabel: "highlightWords", dispatch: (checked) => this.events["change:subtitlesHighlightWords"].dispatch(checked) }); this.subtitlesSmartLayoutCheckbox?.addEventListener("change", (checked) => { if (this.suppressSubtitlesSmartLayoutCheckboxChange) return; this.setSubtitlesSmartLayout(checked); }); this.subtitlesMaxLengthSlider.addEventListener("input", (value) => { this.subtitlesMaxLengthSliderLabel.value = value; if ((this.data.subtitlesSmartLayout ?? true) === true) { this.setSubtitlesSmartLayout(false); } this.data.subtitlesMaxLength = value; this.scheduleStoragePersist( "subtitlesMaxLength", this.data.subtitlesMaxLength ); this.events["input:subtitlesMaxLength"].dispatch(value); }); this.subtitlesFontSizeSlider.addEventListener("input", (value) => { this.subtitlesFontSizeSliderLabel.value = value; if ((this.data.subtitlesSmartLayout ?? true) === true) { this.setSubtitlesSmartLayout(false); } this.data.subtitlesFontSize = value; this.scheduleStoragePersist( "subtitlesFontSize", this.data.subtitlesFontSize ); this.events["input:subtitlesFontSize"].dispatch(value); }); this.subtitlesBackgroundOpacitySlider.addEventListener("input", (value) => { this.subtitlesBackgroundOpacitySliderLabel.value = value; this.data.subtitlesOpacity = value; this.scheduleStoragePersist( "subtitlesOpacity", this.data.subtitlesOpacity ); this.events["input:subtitlesBackgroundOpacity"].dispatch(value); }); this.bindPersistedSetting({ control: this.translateHotkeyButton, event: "change", apply: (key) => { this.data.translationHotkey = key; }, storageKey: "translationHotkey", readPersistedValue: () => this.data.translationHotkey, logLabel: "translationHotkey" }); this.bindPersistedSetting({ control: this.subtitlesHotkeyButton, event: "change", apply: (key) => { this.data.subtitlesHotkey = key; }, storageKey: "subtitlesHotkey", readPersistedValue: () => this.data.subtitlesHotkey, logLabel: "subtitlesHotkey" }); this.proxyWorkerHostTextfield.addEventListener("change", async (value) => { this.data.proxyWorkerHost = value || proxyWorkerHost; await votStorage.set("proxyWorkerHost", this.data.proxyWorkerHost); debug.log( "proxyWorkerHost value changed. New value:", this.data.proxyWorkerHost ); this.events["change:proxyWorkerHost"].dispatch(value); }); this.proxyTranslationStatusSelect.addEventListener( "selectItem", async (item) => { this.data.translateProxyEnabled = Number.parseInt( item, 10 ); await votStorage.set( "translateProxyEnabled", this.data.translateProxyEnabled ); await votStorage.set("translateProxyEnabledDefault", false); debug.log( "translateProxyEnabled value changed. New value:", this.data.translateProxyEnabled ); this.events["select:proxyTranslationStatus"].dispatch(item); } ); this.bindPersistedSetting({ control: this.translateAPIErrorsCheckbox, event: "change", apply: (checked) => { this.data.translateAPIErrors = checked; }, storageKey: "translateAPIErrors", readPersistedValue: () => this.data.translateAPIErrors, logLabel: "translateAPIErrors" }); this.bindPersistedSetting({ control: this.useNewAudioPlayerCheckbox, event: "change", apply: (checked) => { this.data.newAudioPlayer = checked; this.onlyBypassMediaCSPCheckbox.disabled = this.onlyBypassMediaCSPCheckbox.hidden = !checked; }, storageKey: "newAudioPlayer", readPersistedValue: () => this.data.newAudioPlayer, logLabel: "newAudioPlayer", dispatch: (checked) => this.events["change:useNewAudioPlayer"].dispatch(checked) }); this.bindPersistedSetting({ control: this.onlyBypassMediaCSPCheckbox, event: "change", apply: (checked) => { this.data.onlyBypassMediaCSP = checked; }, storageKey: "onlyBypassMediaCSP", readPersistedValue: () => this.data.onlyBypassMediaCSP, logLabel: "onlyBypassMediaCSP", dispatch: (checked) => this.events["change:onlyBypassMediaCSP"].dispatch(checked) }); this.bindPersistedSetting({ control: this.translationTextServiceSelect, event: "selectItem", apply: (item) => { this.data.translationService = item; }, storageKey: "translationService", readPersistedValue: () => this.data.translationService, logLabel: "translationService", dispatch: (item) => this.events["select:translationTextService"].dispatch(item) }); this.bindPersistedSetting({ control: this.detectServiceSelect, event: "selectItem", apply: (item) => { this.data.detectService = item; }, storageKey: "detectService", readPersistedValue: () => this.data.detectService, logLabel: "detectService" }); this.bindPersistedSetting({ control: this.showPiPButtonCheckbox, event: "change", apply: (checked) => { this.data.showPiPButton = checked; }, storageKey: "showPiPButton", readPersistedValue: () => this.data.showPiPButton, logLabel: "showPiPButton", dispatch: (checked) => this.events["change:showPiPButton"].dispatch(checked) }); this.autoHideButtonDelaySlider.addEventListener("input", (value) => { this.autoHideButtonDelaySliderLabel.value = value; const newDelay = Math.round(value * 1e3); this.data.autoHideButtonDelay = newDelay; this.scheduleStoragePersist( "autoHideButtonDelay", this.data.autoHideButtonDelay ); this.events["input:autoHideButtonDelay"].dispatch(value); }); this.bindPersistedSetting({ control: this.buttonPositionSelect, event: "selectItem", apply: (item) => { this.data.buttonPos = item; }, storageKey: "buttonPos", readPersistedValue: () => this.data.buttonPos, logLabel: "buttonPos", dispatch: (item) => this.events["select:buttonPosition"].dispatch(item) }); this.menuLanguageSelect.addEventListener("selectItem", async (item) => { const result = await localizationProvider.changeLang(item); if (!result) return; this.data.localeUpdatedAt = await votStorage.get("localeUpdatedAt", 0); this.events["select:menuLanguage"].dispatch(item); }); this.bugReportButton.addEventListener( "click", () => this.events["click:bugReport"].dispatch() ); this.resetSettingsButton.addEventListener( "click", () => this.events["click:resetSettings"].dispatch() ); return this; } addEventListener(type, listener) { this.events[type].addListener(listener); return this; } removeEventListener(type, listener) { this.events[type].removeListener(listener); return this; } doReleaseUI() { this.dialog?.remove(); for (const tooltip of [ this.accountButtonRefreshTooltip, this.accountButtonTokenTooltip, this.audioBoosterTooltip, this.useLivelyVoiceTooltip, this.useAudioDownloadCheckboxTooltip, this.useNewAudioPlayerTooltip, this.onlyBypassMediaCSPTooltip, this.translationTextServiceTooltip, this.proxyTranslationStatusSelectTooltip, this.buttonPositionTooltip ]) { tooltip?.release(); } } doReleaseUIEvents() { this.flushStoragePersists(); for (const event of Object.values(this.events)) event.clear(); } releaseUI(initialized = false) { if (!this.isInitialized()) throw new Error("[VOT] SettingsView isn't initialized"); this.doReleaseUI(); this.initialized = initialized; return this; } releaseUIEvents(initialized = false) { if (!this.isInitialized()) throw new Error("[VOT] SettingsView isn't initialized"); this.doReleaseUIEvents(); this.initialized = initialized; return this; } release() { if (!this.isInitialized()) return this; this.doReleaseUIEvents(); this.doReleaseUI(); this.initialized = false; return this; } updateAccountInfo() { if (!this.isInitialized()) throw new Error("[VOT] SettingsView isn't initialized"); const loggedIn = !!this.data.account?.token; this.accountButton.avatarId = this.data.account?.avatarId; this.useLivelyVoiceTooltip.hidden = this.accountButton.loggedIn = loggedIn; this.accountButton.username = this.data.account?.username; this.useLivelyVoiceCheckbox.disabled = !loggedIn; this.events["update:account"].dispatch(this.data.account); return this; } open() { if (!this.isInitialized()) throw new Error("[VOT] SettingsView isn't initialized"); return this.dialog.open(); } close() { if (!this.isInitialized()) throw new Error("[VOT] SettingsView isn't initialized"); return this.dialog.close(); } } const mapProcessedSubtitlesToSharedData = (data) => { const subtitles = data.subtitles.map((line) => ({ text: line.text, startMs: line.startMs, durationMs: line.durationMs, speakerId: line.speakerId, tokens: line.tokens.map((token) => ({ text: token.text, startMs: token.startMs, durationMs: token.durationMs })) })); return { containsTokens: subtitles.some((line) => line.tokens.length > 0), subtitles }; }; class UIManager { mount; initialized = false; videoHandler; intervalIdleChecker; data; votGlobalPortal; votOverlayView; votSettingsView; constructor({ mount, data = {}, videoHandler, intervalIdleChecker }) { this.mount = mount; this.videoHandler = videoHandler; this.data = data; this.intervalIdleChecker = intervalIdleChecker; } get root() { return this.mount.root; } get portalContainer() { return this.mount.portalContainer; } get tooltipLayoutRoot() { return this.mount.tooltipLayoutRoot; } isInitialized() { return this.initialized; } initUI() { if (this.isInitialized()) { throw new Error("[VOT] UIManager is already initialized"); } this.initialized = true; this.votGlobalPortal = UI.createPortal(); document.documentElement.appendChild(this.votGlobalPortal); this.votOverlayView = new OverlayView({ mount: this.mount, globalPortal: this.votGlobalPortal, data: this.data, videoHandler: this.videoHandler, intervalIdleChecker: this.intervalIdleChecker }); this.votOverlayView.initUI(this.data.buttonPos ?? "default"); this.votSettingsView = new SettingsView({ globalPortal: this.votGlobalPortal, data: this.data, videoHandler: this.videoHandler }); this.votSettingsView.initUI(); return this; } updateMount(mount) { this.mount = mount; this.votOverlayView?.updateMount?.(mount); return this; } initUIEvents() { if (!this.isInitialized()) { throw new Error("[VOT] UIManager isn't initialized"); } this.votOverlayView.initUIEvents(); this.votOverlayView.addEventListener("click:translate", async () => { await this.handleTranslationBtnClick(); }).addEventListener("click:pip", async () => { if (!this.videoHandler) { return; } const isPiPActive = this.videoHandler.video === document.pictureInPictureElement; await (isPiPActive ? document.exitPictureInPicture() : this.videoHandler.video.requestPictureInPicture()); }).addEventListener("click:settings", async () => { this.videoHandler?.subtitlesWidget?.releaseTooltip(); this.videoHandler?.overlayVisibility?.cancel(); this.videoHandler?.overlayVisibility?.show(); this.votSettingsView.open(); await exitFullscreen(); }).addEventListener("click:downloadTranslation", async () => { if (!this.votOverlayView.isInitialized() || !this.videoHandler?.downloadTranslationUrl || !this.videoHandler.videoData) { return; } const downloadButton = this.votOverlayView.downloadTranslationButton; const downloadUrl = this.videoHandler.downloadTranslationUrl; const filename = clearFileName( this.videoHandler.videoData.downloadTitle ); const isMobile = this.videoHandler.site.additionalData === "mobile"; if (downloadButton) { downloadButton.progress = 0; } try { if (isMobile) { const res2 = await GM_fetch(downloadUrl, { timeout: 0 }); if (!res2.ok) { throw new Error(`HTTP ${res2.status}`); } const blob = await buildTranslationBlob( res2, filename, (progress) => { if (downloadButton) { downloadButton.progress = progress; } } ); 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); 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; const res = await GM_fetch(downloadUrl, { timeout: 0 }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } await downloadTranslation(res, filename, (progress) => { downloadButton.progress = progress; }); } catch (err) { console.error("[VOT] Download translation failed:", err); globalThis.open(downloadUrl, "_blank")?.focus(); } finally { if (downloadButton) { downloadButton.progress = 0; } } }).addEventListener("click:downloadSubtitles", async () => { const videoHandler = this.videoHandler; if (!videoHandler?.yandexSubtitles || !videoHandler.videoData) { return; } const subsFormat = this.data.subtitlesDownloadFormat ?? "json"; const subsContent = convertSubs( mapProcessedSubtitlesToSharedData(videoHandler.yandexSubtitles), subsFormat ); const blob = new Blob( [ subsFormat === "json" ? JSON.stringify(subsContent) : subsContent ], { type: "text/plain" } ); const filename = this.data.downloadWithName ? clearFileName(videoHandler.videoData.downloadTitle) : `subtitles_${videoHandler.videoData.videoId}`; downloadBlob(blob, `${filename}.${subsFormat}`); }).addEventListener("input:videoVolume", (volume) => { if (!this.videoHandler) { return; } this.videoHandler.setVideoVolume(volume / 100); if (!this.data.syncVolume) { return; } this.videoHandler.syncVolumeWrapper("video", volume); }).addEventListener("input:translationVolume", (volume) => { if (!this.videoHandler) { return; } const nextVolume = volume ?? this.data.defaultVolume ?? 100; this.videoHandler.audioPlayer.player.volume = nextVolume / 100; if (!this.data.syncVolume) { return; } this.videoHandler.syncVolumeWrapper("translation", nextVolume); }).addEventListener("select:subtitles", async (data) => { await this.videoHandler?.changeSubtitlesLang(data); }); this.votSettingsView.initUIEvents(); this.votSettingsView.addEventListener("update:account", async (account) => { if (!this.videoHandler) { return; } this.videoHandler.votClient.apiToken = account?.token; }).addEventListener("change:autoTranslate", async (checked) => { if (checked && this.videoHandler && !this.videoHandler?.hasActiveSource()) { await this.handleTranslationBtnClick(); } }).addEventListener("change:autoSubtitles", async (checked) => { if (!checked || !this.videoHandler?.videoData?.videoId) { return; } await this.videoHandler.enableSubtitlesForCurrentLangPair(); }).addEventListener("change:showVideoVolume", () => { this.withInitializedOverlayView((overlayView) => { if (!overlayView.videoVolumeSlider || !overlayView.votButton) { return; } overlayView.videoVolumeSlider.container.hidden = !this.data.showVideoSlider || overlayView.votButton.status !== "success"; }); }).addEventListener("change:audioBooster", async () => { this.withInitializedOverlayView((overlayView) => { if (!overlayView.translationVolumeSlider) { return; } const currentVolume = overlayView.translationVolumeSlider.value; const maxVolume = this.data.audioBooster ? maxAudioVolume : 100; overlayView.translationVolumeSlider.max = maxVolume; overlayView.translationVolumeSlider.value = clamp$2( currentVolume, 0, maxVolume ); }); }).addEventListener("change:useLivelyVoice", () => { this.videoHandler?.stopTranslate(); }).addEventListener("change:subtitlesHighlightWords", (checked) => { this.withSubtitlesWidget((widget) => { widget.setHighlightWords(this.data.highlightWords ?? checked); }); }).addEventListener("change:subtitlesSmartLayout", (checked) => { this.withSubtitlesWidget((widget) => { widget.setSmartLayout(this.data.subtitlesSmartLayout ?? checked); }); }).addEventListener("input:subtitlesMaxLength", (value) => { this.withSubtitlesWidget((widget) => { widget.setMaxLength(this.data.subtitlesMaxLength ?? value); }); }).addEventListener("input:subtitlesFontSize", (value) => { this.withSubtitlesWidget((widget) => { widget.setFontSize(this.data.subtitlesFontSize ?? value); }); }).addEventListener("input:subtitlesBackgroundOpacity", (value) => { this.withSubtitlesWidget((widget) => { widget.setOpacity(this.data.subtitlesOpacity ?? value); }); }).addEventListener("change:proxyWorkerHost", (_value) => { if (!this.videoHandler) { return; } void this.videoHandler.handleProxySettingsChanged("proxyWorkerHost"); }).addEventListener("select:proxyTranslationStatus", () => { void this.videoHandler?.handleProxySettingsChanged( "proxyTranslationStatus" ); }).addEventListener("change:useNewAudioPlayer", () => { this.restartAudioPlayer(); }).addEventListener("change:onlyBypassMediaCSP", () => { this.restartAudioPlayer(); }).addEventListener("select:translationTextService", () => { this.withSubtitlesWidget((widget) => { widget.resetTranslationContext(true); }); }).addEventListener("change:showPiPButton", () => { this.withInitializedOverlayView((overlayView) => { if (!overlayView.votButton) { return; } overlayView.votButton.pipButton.hidden = overlayView.votButton.separator2.hidden = !overlayView.pipButtonVisible; }); }).addEventListener("select:buttonPosition", (item) => { this.withInitializedOverlayView((overlayView) => { const newPosition = this.data.buttonPos ?? item; overlayView.updateButtonLayout( newPosition, VOTButton.calcDirection(newPosition) ); }); }).addEventListener("select:menuLanguage", async () => { await this.reloadMenu(); }).addEventListener("click:bugReport", () => { if (!this.videoHandler) { return; } const params = new URLSearchParams( this.videoHandler.collectReportInfo() ).toString(); globalThis.open(`${repositoryUrl}/issues/new?${params}`, "_blank")?.focus(); }).addEventListener("click:resetSettings", async () => { const valuesForClear = await votStorage.list(); await Promise.all( valuesForClear.map(async (val) => await votStorage.delete(val)) ); await votStorage.set("compatVersion", actualCompatVersion); globalThis.location.reload(); }); } async reloadMenu() { if (!this.votOverlayView?.isInitialized()) { throw new Error("[VOT] OverlayView isn't initialized"); } const prevButtonOpacity = this.votOverlayView.votButton.opacity; const prevButtonHidden = this.votOverlayView.votButton.container.hidden; const prevMenuHidden = this.votOverlayView.votMenu.hidden; const prevButtonPos = this.data.buttonPos ?? "default"; const settingsWasOpen = this.votSettingsView?.dialog?.container?.hidden === false; this.videoHandler?.stopTranslation(); this.release(); this.initUI(); this.initUIEvents(); if (!this.videoHandler) { return this; } try { const { position: position2, direction } = this.votOverlayView.calcButtonLayout(prevButtonPos); this.votOverlayView.updateButtonLayout(position2, direction); this.votOverlayView.votMenu.hidden = prevMenuHidden; this.votOverlayView.votButton.container.hidden = prevButtonHidden; this.votOverlayView.votButton.opacity = prevButtonOpacity; } catch { } try { this.videoHandler.rebindOverlayVisibilityTargets(); } catch { } if (settingsWasOpen) { try { this.votSettingsView?.open(); } catch { } } await this.videoHandler.updateSubtitlesLangSelect(); const widget = this.videoHandler.subtitlesWidget; if (widget) { widget.setPortal(this.votOverlayView.votOverlayPortal); widget.resetTranslationContext(true); } return this; } async handleTranslationBtnClick() { if (!this.votOverlayView?.isInitialized()) { throw new Error("[VOT] OverlayView isn't initialized"); } if (!this.videoHandler) { return this; } if (this.videoHandler.hasActiveSource()) { this.videoHandler.stopTranslation(); return this; } if (this.votOverlayView.votButton.status === "error" && !this.votOverlayView.votButton.loading) { this.transformBtn("none", localizationProvider.get("translateVideo")); } if (this.votOverlayView.votButton.status !== "none" || this.votOverlayView.votButton.loading) { this.videoHandler.actionsAbortController.abort(); this.videoHandler.stopTranslation(); return this; } try { debug.log("[handleTranslationBtnClick] trying execute translation"); if (!this.videoHandler.videoData?.videoId) { throw new VOTLocalizedError("VOTNoVideoIDFound"); } if (this.videoHandler.site.host === "vk" && this.videoHandler.site.additionalData === "clips" || this.videoHandler.site.host === "douyin") { this.videoHandler.videoData = await this.videoHandler.getVideoData(); } debug.log( "[handleTranslationBtnClick] Run translateFunc", this.videoHandler.videoData.videoId ); await this.videoHandler.translateFunc( this.videoHandler.videoData.videoId, this.videoHandler.videoData.isStream, this.videoHandler.videoData.detectedLanguage, this.videoHandler.videoData.responseLanguage, this.videoHandler.videoData.translationHelp ); } catch (err) { if (err instanceof Error && err.name === "AbortError") { this.transformBtn("none", localizationProvider.get("translateVideo")); return this; } console.error("[VOT]", err); if (!(err instanceof Error)) { this.transformBtn("error", String(err)); return this; } const message = err.name === "VOTLocalizedError" ? err.localizedMessage : err.message; this.transformBtn("error", message); } return this; } isLoadingText(text) { const delayed = localizationProvider.get("TranslationDelayed"); return typeof text === "string" && (text.includes(localizationProvider.get("translationTake")) || (delayed ? text.includes(delayed) : false)); } transformBtn(status, text) { if (!this.votOverlayView?.isInitialized()) { throw new Error("[VOT] OverlayView isn't initialized"); } this.votOverlayView.votButton.status = status; this.votOverlayView.votButton.loading = status === "error" && this.isLoadingText(text); this.votOverlayView.votButton.setText(text); this.votOverlayView.votButtonTooltip.setContent(text); return this; } releaseUI(initialized = false) { if (!this.isInitialized()) { throw new Error("[VOT] UIManager isn't initialized"); } this.votOverlayView.releaseUI(true); this.votSettingsView.releaseUI(true); this.votGlobalPortal.remove(); this.initialized = initialized; return this; } releaseUIEvents(initialized = false) { if (!this.isInitialized()) { throw new Error("[VOT] UIManager isn't initialized"); } this.votOverlayView.releaseUIEvents(false); this.votSettingsView.releaseUIEvents(false); this.initialized = initialized; return this; } release() { if (!this.isInitialized()) { return this; } this.votOverlayView.release(); this.votSettingsView.release(); this.votGlobalPortal.remove(); this.initialized = false; return this; } withInitializedOverlayView(callback) { if (!this.votOverlayView?.isInitialized()) { return; } callback(this.votOverlayView); } withSubtitlesWidget(callback) { const widget = this.videoHandler?.subtitlesWidget; if (!widget) { return; } callback(widget); } restartAudioPlayer() { if (!this.videoHandler) { return; } this.videoHandler.stopTranslate(); this.videoHandler.createPlayer(); } } class OverlayVisibilityController { deps; hideDeadlineMs = 0; hideArmed = false; unsubscribeChecker; constructor(deps) { this.deps = deps; this.unsubscribeChecker = this.deps.checker.subscribe(() => { this.onCheckerTick(); }); } show() { const view = this.getView(); if (!view) { return null; } view.updateButtonOpacity(1); return view; } cancel() { this.hideDeadlineMs = 0; this.hideArmed = false; } release() { this.cancel(); this.unsubscribeChecker(); } queueAutoHide() { if (!this.show()) { return; } const delay = this.deps.getAutoHideDelay(); this.hideDeadlineMs = this.nowMs() + Math.max(0, delay); this.hideArmed = true; this.deps.checker.markActivity("overlay-queue-hide"); this.deps.checker.requestImmediateTick(); } handleOverlayInteraction(event) { const type = event?.type; if (!type) return; if (type === "focusin") { this.handleFocusIn(); return; } if (type.startsWith("pointer")) { this.cancel(); this.show(); this.deps.checker.markActivity("overlay-interaction"); event.stopPropagation?.(); return; } this.handleHostInteraction(event); } handleHostInteraction(event) { const type = event?.type; if (!type) return; if (type === "focusin") { this.handleFocusIn(); return; } if (type.startsWith("pointer")) { const target = event.target; if (this.deps.isInteractiveNode(target)) { event.stopPropagation?.(); } this.deps.checker.markActivity("overlay-host-pointer"); } this.queueAutoHide(); } scheduleHide(event) { if (!this.getView()) { return; } const currentTarget = event?.currentTarget; let relatedTarget = event?.relatedTarget ?? null; if (!relatedTarget && typeof event?.composedPath === "function") { const path = event.composedPath(); relatedTarget = path[1] ?? null; } const relatedNode = relatedTarget instanceof Node ? relatedTarget : null; const currentNode = currentTarget instanceof Node ? currentTarget : null; if (relatedNode && (currentNode?.contains(relatedNode) || this.deps.isInteractiveNode(relatedNode))) { return; } this.queueAutoHide(); } onCheckerTick() { if (!this.hideArmed || this.hideDeadlineMs <= 0) return; const now2 = this.nowMs(); if (now2 + 2 < this.hideDeadlineMs) { return; } this.hideArmed = false; let active = null; const canCheckFocus = typeof document !== "undefined" && typeof document.hasFocus === "function"; if (canCheckFocus && document.hasFocus()) { active = document.activeElement; } if (active && this.deps.isInteractiveNode(active)) { return; } const view = this.getView(); view?.updateButtonOpacity(0); } handleFocusIn() { this.cancel(); this.show(); this.deps.checker.markActivity("overlay-focus-in"); } getView() { const view = this.deps.getOverlayView(); return view?.isInitialized() ? view : null; } nowMs() { if (this.deps.nowMs) { return this.deps.nowMs(); } return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now(); } } const DEFAULT_PROFILE = { activeIntervalMs: 16, idleIntervalMs: 120, hiddenIntervalMs: 250, idleAfterMs: 180 }; function getDefaultRuntime() { return { nowMs: () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now(), setInterval: globalThis.setInterval.bind(globalThis), clearInterval: globalThis.clearInterval.bind(globalThis), queueMicrotask: (fn) => { if (typeof queueMicrotask === "function") { queueMicrotask(fn); return; } Promise.resolve().then(fn); }, isDocumentHidden: () => typeof document !== "undefined" && typeof document.hidden === "boolean" ? document.hidden : false }; } class IntervalIdleChecker { profile; runtime; subscribers = new Set(); timerId = null; running = false; destroyed = false; immediateQueued = false; currentMode = "active"; lastActivityAt; constructor(options = {}) { this.profile = { ...DEFAULT_PROFILE, ...options.profile }; this.runtime = { ...getDefaultRuntime(), ...options.runtime }; this.lastActivityAt = this.runtime.nowMs(); } start() { if (this.destroyed || this.running) return; this.running = true; this.lastActivityAt = this.runtime.nowMs(); this.runTick("start"); } stop() { if (!this.running) return; this.running = false; this.clearTimer(); this.immediateQueued = false; } destroy() { if (this.destroyed) return; this.stop(); this.subscribers.clear(); this.destroyed = true; } subscribe(fn) { if (this.destroyed) { return () => void 0; } this.subscribers.add(fn); return () => { this.subscribers.delete(fn); }; } markActivity(_source) { if (this.destroyed) return; this.lastActivityAt = this.runtime.nowMs(); if (!this.running) return; const nextMode = this.resolveMode(this.lastActivityAt); if (nextMode !== this.currentMode) { this.currentMode = nextMode; this.restartTimer(nextMode); } } requestImmediateTick() { if (this.destroyed || !this.running || this.immediateQueued) return; this.immediateQueued = true; this.runtime.queueMicrotask(() => { this.immediateQueued = false; if (this.destroyed || !this.running) return; this.runTick("immediate"); }); } resolveMode(nowMs) { if (this.runtime.isDocumentHidden()) { return "hidden"; } const inactiveFor = nowMs - this.lastActivityAt; return inactiveFor >= this.profile.idleAfterMs ? "idle" : "active"; } intervalForMode(mode) { if (mode === "hidden") return this.profile.hiddenIntervalMs; if (mode === "idle") return this.profile.idleIntervalMs; return this.profile.activeIntervalMs; } clearTimer() { if (this.timerId === null) return; this.runtime.clearInterval(this.timerId); this.timerId = null; } restartTimer(mode) { this.clearTimer(); const intervalMs = Math.max(1, this.intervalForMode(mode)); this.timerId = this.runtime.setInterval(() => { this.runTick("interval"); }, intervalMs); } runTick(source) { const nowMs = this.runtime.nowMs(); const nextMode = this.resolveMode(nowMs); if (nextMode !== this.currentMode || this.timerId === null) { this.currentMode = nextMode; this.restartTimer(nextMode); } const ctx = { nowMs, mode: nextMode, source }; for (const sub of this.subscribers) { try { sub(ctx); } catch { } } } } function createIntervalIdleChecker(profile) { return new IntervalIdleChecker({ profile }); } const now = () => Date.now(); function getScriptTitle() { return GM_info?.script?.name || "VOT"; } function safeL10n(key, fallback) { try { const value = localizationProvider?.get?.(key); return value || fallback; } catch { return fallback; } } function canSend(lastSentAt, key, cooldownMs) { if (!cooldownMs) return true; const prev = lastSentAt.get(key) ?? 0; return now() - prev >= cooldownMs; } function markSent(lastSentAt, key) { lastSentAt.set(key, now()); } function trySendViaUserscriptApi(details) { try { if (typeof GM_notification === "function") { GM_notification(details); return true; } const gmApi = globalThis.GM; if (gmApi !== void 0 && typeof gmApi.notification === "function") { const gmDetails = { text: details.text, title: details.title, image: details.image, onclick: details.onclick, ondone: details.ondone }; gmApi.notification(gmDetails); return true; } } catch (err) { } return false; } class Notifier { lastSentAt = new Map(); send(details, opts = {}) { try { const key = opts.key || details.tag || `${details.title ?? ""}|${details.text ?? ""}`; const cooldownMs = opts.cooldownMs ?? 0; if (!canSend(this.lastSentAt, key, cooldownMs)) return; const normalized = { ...details, title: details.title ?? getScriptTitle() }; const ok = trySendViaUserscriptApi(normalized); if (ok) { markSent(this.lastSentAt, key); } else { debug.log("[notify] unavailable", normalized); } } catch (err) { } } translationCompleted(host) { const text = safeL10n( "VOTTranslationCompletedNotify", "The translation on the {0} has been completed!" ).replace("{0}", host); this.send( { text, title: getScriptTitle(), timeout: 5e3, silent: true, tag: "VOTTranslationCompleted", onclick: () => { try { globalThis.focus(); } catch { } } }, { key: `translation_completed_${host}`, cooldownMs: 1e4 } ); } translationFailed(params) { const { videoId, message } = params; if (isAbortError(message)) return; const msg = getErrorMessage(message) || "Translation failed"; const title = getScriptTitle(); this.send( { text: msg, title, timeout: 8e3, silent: true, tag: `VOTtranslationFailed_${videoId || "unknown"}`, onclick: () => { try { globalThis.focus(); } catch { } } }, { key: `translation_failed_${videoId || "unknown"}`, cooldownMs: 3e4 } ); } } const AD_ATTRS = ["class", "id", "title"]; const ATTACH_SHADOW_HOOK_KEY = "__votAttachShadowHook"; function getOrInstallAttachShadowHook() { const g2 = globalThis; const existing = g2[ATTACH_SHADOW_HOOK_KEY]; if (existing?.original && existing.subscribers) return existing; const original = Element.prototype.attachShadow; if (typeof original !== "function") return null; const state = { original, subscribers: new Set() }; const patchedAttachShadow = function(init2) { const root = original.call(this, init2); for (const sub of state.subscribers) { try { sub(root); } catch (error2) { } } return root; }; try { Object.defineProperty(Element.prototype, "attachShadow", { configurable: true, enumerable: true, writable: true, value: patchedAttachShadow }); } catch { return null; } g2[ATTACH_SHADOW_HOOK_KEY] = state; return state; } function removeAttachShadowSubscriber(subscriber) { const g2 = globalThis; const state = g2[ATTACH_SHADOW_HOOK_KEY]; if (!state) return; state.subscribers.delete(subscriber); if (state.subscribers.size > 0) return; try { Object.defineProperty(Element.prototype, "attachShadow", { configurable: true, enumerable: true, writable: true, value: state.original }); } catch { Element.prototype.attachShadow = state.original; } delete g2[ATTACH_SHADOW_HOOK_KEY]; } class VideoObserver { static adKeywords = new Set([ "advertise", "advertisement", "promo", "sponsor", "banner", "commercial", "preroll", "midroll", "postroll", "ad-container", "sponsored" ]); seenVideos = new WeakSet(); activeVideos = new WeakSet(); observedRoots = new WeakSet(); pendingAdded = new Set(); pendingRemoved = new Set(); flushPending = false; static MAX_FLUSH_BUDGET_MS = 6; static MAX_NODES_PER_SLICE = 120; onVideoAdded = new EventImpl(); onVideoRemoved = new EventImpl(); observer = new MutationObserver( (muts) => this.onMutations(muts) ); intervalIdleChecker; checkerUnsubscribe = null; enabled = false; attachShadowSubscriber = null; onDocumentReady = null; onPageShow = () => { const root = document.documentElement; if (!root) return; this.pendingAdded.add(root); this.scheduleFlush(); }; constructor(intervalIdleChecker = createIntervalIdleChecker()) { this.intervalIdleChecker = intervalIdleChecker; } static containsAdKeyword(token) { for (const kw of VideoObserver.adKeywords) { if (token === kw || token.includes(kw)) { return true; } } return false; } isAdRelated(element) { for (const attr of AD_ATTRS) { const rawValue = element.getAttribute(attr); if (!rawValue) continue; const value = rawValue.toLowerCase(); const tokens = attr === "class" ? value.split(/\s+/) : [value]; for (const token of tokens) { if (!token) continue; if (VideoObserver.containsAdKeyword(token)) { return true; } } } return false; } isInsideAd(video) { for (let p2 = video.parentElement; p2; p2 = p2.parentElement) { if (this.isAdRelated(p2)) return true; } return false; } getCapturedAudioTrackCount(video) { const candidate = video; const captureStream = candidate.captureStream ?? candidate.mozCaptureStream; if (typeof captureStream !== "function") return null; try { const stream = captureStream.call(video); return stream.getAudioTracks().length; } catch { return null; } } isLikelySilentDecorativeVideo(video) { if (!(video.muted || video.defaultMuted)) return false; if (!video.autoplay || !video.loop) return false; if (video.controls) return false; const v2 = video; if (typeof v2.mozHasAudio === "boolean") { return !v2.mozHasAudio; } if ("audioTracks" in v2 && typeof v2.audioTracks?.length === "number") { if (v2.audioTracks.length > 0) return false; const capturedTrackCount2 = this.getCapturedAudioTrackCount(video); if (capturedTrackCount2 !== null) { return capturedTrackCount2 === 0; } return true; } const capturedTrackCount = this.getCapturedAudioTrackCount(video); if (capturedTrackCount !== null) { return capturedTrackCount === 0; } return false; } hasAudio(video) { const v2 = video; if (video.srcObject instanceof MediaStream) { return video.srcObject.getAudioTracks().length > 0; } if (typeof v2.mozHasAudio === "boolean") return v2.mozHasAudio; if (typeof v2.webkitAudioDecodedByteCount === "number" && v2.webkitAudioDecodedByteCount > 0) { return true; } if ("audioTracks" in v2 && typeof v2.audioTracks?.length === "number") { if (v2.audioTracks.length > 0) { return true; } } if (this.isLikelySilentDecorativeVideo(video)) { return false; } return true; } isValidVideo(video) { if (this.isAdRelated(video)) return false; if (this.isInsideAd(video)) return false; if (!this.hasAudio(video)) { return false; } return true; } observeRoot(root) { if (this.observedRoots.has(root)) return; this.observedRoots.add(root); this.observer.observe(root, { childList: true, subtree: true }); } scan(root) { if (root instanceof HTMLVideoElement) { this.trackVideo(root); return; } if (root.nodeType !== Node.ELEMENT_NODE && root.nodeType !== Node.DOCUMENT_FRAGMENT_NODE && root.nodeType !== Node.DOCUMENT_NODE) { return; } const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { const el = node; return el.tagName === "VIDEO" || el.shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } }); while (walker.nextNode()) { const el = walker.currentNode; if (el instanceof HTMLVideoElement) { this.trackVideo(el); continue; } const sr = el.shadowRoot; if (sr) { this.observeRoot(sr); this.scan(sr); } } } trackVideo(video) { if (this.seenVideos.has(video)) return; this.seenVideos.add(video); const tryValidate = () => { if (this.isValidVideo(video)) { if (!this.activeVideos.has(video)) { this.activeVideos.add(video); this.onVideoAdded.dispatch(video); } } }; if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { tryValidate(); } else { video.addEventListener("loadeddata", tryValidate, { once: true }); const handlePlay = () => { if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { tryValidate(); } }; video.addEventListener("play", handlePlay, { once: true, passive: true }); } video.addEventListener( "emptied", () => { if (!video.isConnected) { this.untrackVideo(video); } }, { passive: true } ); } untrackVideo(video) { if (this.activeVideos.has(video)) { this.onVideoRemoved.dispatch(video); this.activeVideos.delete(video); } this.seenVideos.delete(video); } collectVideos(node) { const set = new Set(); const addAll = (videos) => { for (const v2 of videos) set.add(v2); }; if (node instanceof HTMLVideoElement) set.add(node); if (node instanceof ShadowRoot) { addAll(node.querySelectorAll("video")); } if (node instanceof Element) { addAll(node.querySelectorAll("video")); const sr = node.shadowRoot; if (sr) addAll(sr.querySelectorAll("video")); } const pn = node; if (pn?.querySelectorAll) { addAll(pn.querySelectorAll("video")); } return Array.from(set); } getNowMs() { if (typeof performance !== "undefined" && typeof performance.now === "function") { return performance.now(); } return Date.now(); } isSliceBudgetReached(startMs, processed) { if (processed >= VideoObserver.MAX_NODES_PER_SLICE) return true; return this.getNowMs() - startMs >= VideoObserver.MAX_FLUSH_BUDGET_MS; } processPendingAdded(startMs) { let processed = 0; while (this.pendingAdded.size > 0) { const next = this.pendingAdded.values().next(); if (next.done) break; this.pendingAdded.delete(next.value); this.scan(next.value); processed += 1; if (this.isSliceBudgetReached(startMs, processed)) { break; } } return processed; } processPendingRemoved(startMs, processed) { let processedCount = processed; while (this.pendingRemoved.size > 0) { if (this.isSliceBudgetReached(startMs, processedCount)) { break; } const next = this.pendingRemoved.values().next(); if (next.done) break; this.pendingRemoved.delete(next.value); for (const video of this.collectVideos(next.value)) { if (!video.isConnected) this.untrackVideo(video); } processedCount += 1; } return processedCount; } flushSlice = () => { if (!this.enabled) { this.pendingAdded.clear(); this.pendingRemoved.clear(); this.flushPending = false; return; } const startMs = this.getNowMs(); const processedAdded = this.processPendingAdded(startMs); this.processPendingRemoved(startMs, processedAdded); this.flushPending = this.pendingAdded.size > 0 || this.pendingRemoved.size > 0; if (this.flushPending) { this.intervalIdleChecker.requestImmediateTick(); } }; onCheckerTick = () => { if (!this.flushPending) return; this.flushSlice(); }; scheduleFlush = () => { if (!this.enabled) return; this.flushPending = true; this.intervalIdleChecker.requestImmediateTick(); }; installAttachShadowHook() { if (this.attachShadowSubscriber) return; const state = getOrInstallAttachShadowHook(); if (!state) return; const subscriber = (root) => { if (!this.enabled) return; this.observeRoot(root); this.pendingAdded.add(root); this.scheduleFlush(); }; state.subscribers.add(subscriber); this.attachShadowSubscriber = subscriber; } uninstallAttachShadowHook() { if (!this.attachShadowSubscriber) return; removeAttachShadowSubscriber(this.attachShadowSubscriber); this.attachShadowSubscriber = null; } enqueueAddedNode(node) { if (node.nodeType === Node.ELEMENT_NODE) { const shadowRoot = node.shadowRoot; if (shadowRoot) this.observeRoot(shadowRoot); } this.pendingAdded.add(node); } enqueueMutation(mutation) { for (const node of mutation.addedNodes) { this.enqueueAddedNode(node); } for (const node of mutation.removedNodes) { this.pendingRemoved.add(node); } } onMutations(mutations) { for (const mutation of mutations) { if (mutation.type !== "childList") continue; this.enqueueMutation(mutation); } if (this.pendingAdded.size > 0 || this.pendingRemoved.size > 0) this.scheduleFlush(); } enable() { if (this.enabled) return; this.enabled = true; this.checkerUnsubscribe?.(); this.checkerUnsubscribe = this.intervalIdleChecker.subscribe(() => { this.onCheckerTick(); }); this.intervalIdleChecker.start(); this.intervalIdleChecker.markActivity("video-observer-enable"); this.installAttachShadowHook(); globalThis.addEventListener("pageshow", this.onPageShow, { passive: true }); const root = document.documentElement; if (root) { this.observeRoot(root); this.scan(root); return; } const onReady = () => { const r2 = document.documentElement; if (!r2) return; document.removeEventListener("readystatechange", onReady); this.onDocumentReady = null; if (!this.enabled) return; this.observeRoot(r2); this.scan(r2); }; this.onDocumentReady = onReady; document.addEventListener("readystatechange", onReady); if (typeof queueMicrotask === "function") queueMicrotask(onReady); else Promise.resolve().then(onReady); } disable() { if (!this.enabled) return; this.enabled = false; globalThis.removeEventListener("pageshow", this.onPageShow); if (this.onDocumentReady) { document.removeEventListener("readystatechange", this.onDocumentReady); this.onDocumentReady = null; } this.uninstallAttachShadowHook(); this.observer.disconnect(); this.flushPending = false; this.checkerUnsubscribe?.(); this.checkerUnsubscribe = null; this.intervalIdleChecker.stop(); this.pendingAdded.clear(); this.pendingRemoved.clear(); this.seenVideos = new WeakSet(); this.activeVideos = new WeakSet(); this.observedRoots = new WeakSet(); } } const defaultPlatformConfig = { allowTouchMoveHandler: true, disableContainerDrag: false }; const platformOverrides = { xvideos: { allowTouchMoveHandler: false }, youtube: { disableContainerDrag: true } }; function getPlatformEventConfig(host) { if (!host) { return defaultPlatformConfig; } const overrides = platformOverrides[host] ?? {}; return { ...defaultPlatformConfig, ...overrides }; } function createScopedListeners(signal) { const add = (element, event, handler, options) => { element.addEventListener(event, handler, { signal, ...options }); }; const addMany = (element, events, handler, options) => { for (const event of events) { add(element, event, handler, options); } }; return { add, addMany }; } function bindOverlayHoverFocusEvents(addMany, target, overlayVisibility) { addMany( target, ["pointerenter", "focusin"], (event) => overlayVisibility.handleOverlayInteraction(event) ); addMany( target, ["pointermove"], (event) => overlayVisibility.handleOverlayInteraction(event), { passive: true } ); addMany( target, ["pointerleave", "focusout"], (event) => overlayVisibility.scheduleHide(event) ); } function toPercentInt(value, fallback = 0) { const numeric = typeof value === "number" ? value : Number(value); return Number.isFinite(numeric) ? clampPercentInt(numeric) : fallback; } function syncAudioTranslationVolumeFromVideo(self, videoPercent, options = {}) { if (options.skipYouTubeLikeHosts && isYouTubeLikeHost(self.site.host)) { return; } if (!self.data?.syncVolume || !self.audioPlayer?.player?.src) return; if (self.isLikelyInternalVideoVolumeChange(videoPercent)) return; self.syncVolumeWrapper("video", videoPercent); } function applyOverlayLayout(self, overlayView, heightPx) { const menu = overlayView.votMenu?.container; if (menu) { const height = heightPx ?? self.video.getBoundingClientRect().height; menu.style.setProperty("--vot-container-height", `${height}px`); } const { position: position2, direction } = overlayView.calcButtonLayout( self.data?.buttonPos ?? "default" ); overlayView.updateButtonLayout(position2, direction); } function isHotkeyMatch(userPressedKeys, hotkey) { if (!hotkey) return false; const pressedParts = formatKeysCombo(userPressedKeys).split("+").filter(Boolean); const hotkeyParts = hotkey.split("+").filter(Boolean); if (pressedParts.length !== hotkeyParts.length) return false; const pressedSet = new Set(pressedParts); return hotkeyParts.every((key) => pressedSet.has(key)); } function bindOverlayLayoutEvents(ctx) { const { self, overlayView, addMany } = ctx; self.resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { applyOverlayLayout(self, overlayView, entry.contentRect.height); } }); self.resizeObserver.observe(self.video); applyOverlayLayout(self, overlayView); addMany( document, ["fullscreenchange", "webkitfullscreenchange"], () => applyOverlayLayout(self, overlayView) ); } function bindYouTubeVolumeSync(ctx) { const { self } = ctx; if (!isDesktopYouTubeLikeSite(self.site)) return; self.syncVolumeObserver = new MutationObserver((mutations) => { if (!self.audioPlayer?.player?.src) return; let hasVolumeMutation = false; let lastObservedAriaValue = null; for (const mutation of mutations) { if (mutation.type !== "attributes" || mutation.attributeName !== "aria-valuenow") { continue; } hasVolumeMutation = true; const ariaValueNow = mutation.target instanceof Element ? mutation.target.getAttribute("aria-valuenow") : null; const parsedAriaValue = ariaValueNow != null ? Number.parseFloat(ariaValueNow) : Number.NaN; if (Number.isFinite(parsedAriaValue)) { lastObservedAriaValue = parsedAriaValue; } } if (!hasVolumeMutation) return; let videoPercent; if (lastObservedAriaValue != null) { videoPercent = toPercentInt(lastObservedAriaValue); } else { const fallbackVolume = self.isMuted() ? 0 : self.getVideoVolume(); videoPercent = toPercentInt(fallbackVolume * 100); } self.syncVideoVolumeSlider(); syncAudioTranslationVolumeFromVideo(self, videoPercent); }); const ytpVolumePanel = document.querySelector(".ytp-volume-panel"); if (!ytpVolumePanel) return; self.syncVolumeObserver.observe(ytpVolumePanel, { attributes: true, subtree: true, attributeFilter: ["aria-valuenow"] }); } function bindAudioTrackLanguageSync(ctx) { const { self } = ctx; if (self.site.host !== "youtube" || self.site.additionalData === "mobile") return; const syncAudioTrackLanguage = async () => { if (!self.videoData) return; const player22 = YoutubeHelper.getPlayer(); const availableTracks = player22?.getAvailableAudioTracks?.() ?? null; if (!Array.isArray(availableTracks) || availableTracks.length <= 1) return; const currentTrackInfo = player22?.getAudioTrack?.()?.getLanguageInfo?.(); const currentTrackId = currentTrackInfo?.id ?? void 0; const currentLanguage = currentTrackId && currentTrackId !== "und" ? currentTrackId.toLowerCase().split(/[-_.]/)[0] : YoutubeHelper.getLanguage(); if (!currentLanguage) return; if (currentLanguage === self.videoData.detectedLanguage) return; self.setSelectMenuValues(currentLanguage, self.videoData.responseLanguage); if (self.data?.autoTranslate && currentLanguage !== self.videoData.responseLanguage) { try { await self.uiManager.handleTranslationBtnClick(); } catch (error2) { } } }; const player2 = YoutubeHelper.getPlayer(); const listeners = ["onApiChange", "onStateChange"]; if (player2?.addEventListener) { for (const eventName of listeners) { try { player2.addEventListener(eventName, syncAudioTrackLanguage); } catch (error2) { } } } void syncAudioTrackLanguage(); self.abortController.signal.addEventListener( "abort", () => { if (!player2?.removeEventListener) return; for (const eventName of listeners) { try { player2.removeEventListener(eventName, syncAudioTrackLanguage); } catch (error2) { } } }, { once: true } ); } function bindGlobalDismissAndHotkeys(ctx) { const { self, overlayView, add, addMany, platformConfig } = ctx; add(document, "click", (event) => { const target = event.target; const button = overlayView.votButton?.container; const menu = overlayView.votMenu?.container; const settings = self.uiManager.votSettingsView?.dialog?.container; const tempDialog = document.querySelector(".vot-dialog-temp"); const isButton = target && button ? button.contains(target) : false; const isMenu = target && menu ? menu.contains(target) : false; const isVideo = target ? self.container.contains(target) : false; const isSettings = target && settings ? settings.contains(target) : false; const isTempDialog = target ? tempDialog?.contains(target) ?? false : false; if (isButton || isMenu || isSettings || isTempDialog) return; if (!isVideo) overlayView.updateButtonOpacity(0); if (menu && !menu.hidden) { menu.hidden = true; self.overlayVisibility?.queueAutoHide(); } }); const userPressedKeys = new Set(); const clearUserPressedKeys = () => userPressedKeys.clear(); add(document, "keydown", async (event) => { const keyboardEvent = event; if (keyboardEvent.repeat) return; userPressedKeys.add(keyboardEvent.code); const activeElement = document.activeElement; const activeTag = activeElement?.tagName?.toLowerCase?.() ?? ""; const isInputElement = ["input", "textarea"].includes(activeTag) || Boolean(activeElement?.isContentEditable); if (isInputElement) return; if (isHotkeyMatch(userPressedKeys, self.data?.translationHotkey)) { clearUserPressedKeys(); await self.uiManager.handleTranslationBtnClick(); return; } if (isHotkeyMatch(userPressedKeys, self.data?.subtitlesHotkey)) { clearUserPressedKeys(); await self.toggleSubtitlesForCurrentLangPair(); } }); add( document, "keyup", (event) => userPressedKeys.delete(event.code) ); add(document, "blur", clearUserPressedKeys); add(document, "visibilitychange", () => { if (document.hidden) clearUserPressedKeys(); }); add(globalThis, "blur", clearUserPressedKeys); const eventContainer = self.getEventContainer(); if (eventContainer) { addMany( eventContainer, ["pointerenter", "pointerdown"], (event) => self.overlayVisibility.handleHostInteraction(event) ); add( eventContainer, "pointermove", (event) => self.overlayVisibility.handleHostInteraction(event), { passive: true } ); add( eventContainer, "pointerleave", (event) => self.overlayVisibility.scheduleHide(event) ); } self.rebindOverlayVisibilityTargets(); if (platformConfig.allowTouchMoveHandler) { add( document, "touchmove", (event) => self.overlayVisibility.handleHostInteraction(event), { passive: true } ); } if (platformConfig.disableContainerDrag) { self.container.draggable = false; } } function bindVideoLifecycleEvents(ctx) { const { self, overlayView, add } = ctx; const safeSetCanPlay = async () => { try { await self.setCanPlay(); } catch (err) { } }; let setCanPlayQueued = false; const queueSetCanPlay = () => { if (setCanPlayQueued) return; setCanPlayQueued = true; queueMicrotask(async () => { setCanPlayQueued = false; await safeSetCanPlay(); }); }; add(self.video, "canplay", () => { if (self.site.host === "rutube" && self.video.src) return; queueSetCanPlay(); }); add(self.video, "emptied", async () => { let videoId; try { videoId = await getVideoID(self.site, { fetchFn: GM_fetch, video: self.video }); } catch { } if (self.video.src && self.videoData && videoId && videoId === self.videoData.videoId) { return; } resetAndHideLifecycle(self, overlayView, { clearVideoData: true, hideMenu: true }); }); if (!isMuteSyncDisabledHost(self.site.host)) { add(self.video, "volumechange", () => { self.syncVideoVolumeSlider(); const activeOverlayView = self.uiManager.votOverlayView; if (!activeOverlayView?.isInitialized()) return; const videoPercent = toPercentInt( activeOverlayView.videoVolumeSlider.value ); syncAudioTranslationVolumeFromVideo(self, videoPercent, { skipYouTubeLikeHosts: true }); }); } if (self.site.host === "youtube" && !self.site.additionalData) { add(document, "yt-page-data-updated", () => { if (!globalThis.location.pathname.includes("/shorts/")) return; queueSetCanPlay(); }); } } function initExtraEvents() { const overlayView = this.uiManager.votOverlayView; if (!overlayView?.subtitlesSelect) return; const { add, addMany } = createScopedListeners(this.abortController.signal); const ctx = { self: this, overlayView, platformConfig: getPlatformEventConfig(this.site.host), add, addMany }; bindOverlayLayoutEvents(ctx); bindYouTubeVolumeSync(ctx); bindAudioTrackLanguageSync(ctx); bindGlobalDismissAndHotkeys(ctx); bindVideoLifecycleEvents(ctx); } function rebindOverlayVisibilityTargets() { this.overlayVisibilityTargetsAbortController?.abort(); this.overlayVisibilityTargetsAbortController = new AbortController(); const { signal } = this.overlayVisibilityTargetsAbortController; const overlayButton = this.uiManager?.votOverlayView?.votButton?.container; const overlayMenu = this.uiManager?.votOverlayView?.votMenu?.container; if (!overlayButton || !overlayMenu || !this.overlayVisibility) return; const overlayVisibility = this.overlayVisibility; const { addMany } = createScopedListeners(signal); bindOverlayHoverFocusEvents(addMany, overlayButton, overlayVisibility); bindOverlayHoverFocusEvents(addMany, overlayMenu, overlayVisibility); } function isOverlayInteractiveNode(node) { if (!(node instanceof Node)) return false; const overlayView = this.uiManager?.votOverlayView; const buttonContainer = overlayView?.votButton?.container; const menuContainer = overlayView?.votMenu?.container; return buttonContainer instanceof Node && buttonContainer.contains(node) || menuContainer instanceof Node && menuContainer.contains(node); } function getAutoHideDelay() { const delay = this.data?.autoHideButtonDelay; return typeof delay === "number" && Number.isFinite(delay) ? delay : defaultAutoHideDelay; } function releaseExtraEvents() { this.resizeObserver?.disconnect(); this.overlayVisibilityTargetsAbortController?.abort(); this.overlayVisibilityTargetsAbortController = void 0; if (isDesktopYouTubeLikeSite(this.site)) { this.syncVolumeObserver?.disconnect(); } } let countryCode; function setCountryCode(next) { countryCode = next; } let countryCodeRequestInFlight = null; async function ensureCountryCode() { if (countryCode) { return; } countryCodeRequestInFlight ??= (async () => { try { const response = await GM_fetch( "https://cloudflare-dns.com/cdn-cgi/trace", { timeout: 7e3 } ); const trace = await response.text(); const loc = trace.split("\n").find((line) => line.startsWith("loc=")); setCountryCode(loc?.slice(4, 6).toUpperCase()); } catch (err) { console.error("[VOT] Error getting country:", err); } })().finally(() => { countryCodeRequestInFlight = null; }); await countryCodeRequestInFlight; } async function init() { if (this.initialized) return; const audioContextSupported = this.isAudioContextSupported; this.data = await votStorage.getValues({ autoTranslate: false, autoSubtitles: false, dontTranslateLanguages: [calculatedResLang], enabledDontTranslateLanguages: true, enabledAutoVolume: true, enabledSmartDucking: true, autoVolume: defaultAutoVolume, buttonPos: "default", showVideoSlider: true, syncVolume: false, downloadWithName: isSupportGMXhr, sendNotifyOnComplete: false, subtitlesMaxLength: 300, subtitlesSmartLayout: true, highlightWords: false, subtitlesFontSize: 20, subtitlesOpacity: 20, subtitlesDownloadFormat: "srt", responseLanguage: calculatedResLang, defaultVolume: 100, onlyBypassMediaCSP: audioContextSupported, newAudioPlayer: audioContextSupported, showPiPButton: false, translateAPIErrors: true, translationService: defaultTranslationService, detectService: defaultDetectService, translationHotkey: null, subtitlesHotkey: null, m3u8ProxyHost, proxyWorkerHost, translateProxyEnabled: 0, translateProxyEnabledDefault: true, audioBooster: false, useLivelyVoice: false, autoHideButtonDelay: defaultAutoHideDelay, useAudioDownload: isUnsafeWindowAllowed || typeof IS_EXTENSION !== "undefined" && IS_EXTENSION, compatVersion: "", account: {}, localeHash: "", localeUpdatedAt: 0 }); if (this.data.compatVersion !== actualCompatVersion) { this.data = await updateConfig(this.data); await votStorage.set("compatVersion", actualCompatVersion); } try { if (calculatedResLang === "en" && this.data?.enabledDontTranslateLanguages && Array.isArray(this.data?.dontTranslateLanguages) && this.data.dontTranslateLanguages.length === 1 && this.data.dontTranslateLanguages[0] === "en" && typeof this.data.responseLanguage === "string" && this.data.responseLanguage !== "en") { const responseLang = this.data.responseLanguage; this.data.dontTranslateLanguages = [responseLang]; await votStorage.set( "dontTranslateLanguages", this.data.dontTranslateLanguages ); } } catch { } this.uiManager.data = this.data; console.log("[VOT] data from db:", this.data); if (!this.data.translateProxyEnabled && isProxyOnlyExtension) { this.data.translateProxyEnabled = 1; } await ensureCountryCode(); if (countryCode && proxyOnlyCountries.includes(countryCode) && this.data.translateProxyEnabledDefault) { this.data.translateProxyEnabled = 2; } debug.log( "translateProxyEnabled", this.data.translateProxyEnabled, this.data.translateProxyEnabledDefault ); this.initVOTClient(); this.uiManager.initUI(); this.uiManager.initUIEvents(); if (this.uiManager.votOverlayView?.votButton?.container) { this.uiManager.votOverlayView.votButton.container.hidden = true; } this.createPlayer(); this.translateToLang = this.data.responseLanguage ?? "ru"; this.initExtraEvents(); this.initialized = true; } const DEFAULT_LOCALE = "und"; const segmenterCache = new Map(); const hasNativeSegmenter = () => typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"; const splitTextRegexp = /[\p{L}\p{N}]+|[^\p{L}\p{N}]+/gu; const wordLikeRegexp = /[\p{L}\p{N}]/u; const canonicalizeLocale = (locale) => { if (typeof Intl === "undefined") return DEFAULT_LOCALE; if (!locale) return DEFAULT_LOCALE; try { const canonical = Intl.getCanonicalLocales(locale)[0]; return canonical || DEFAULT_LOCALE; } catch { return DEFAULT_LOCALE; } }; const resolveSegmenterLocale = (locale) => { const canonicalLocale = canonicalizeLocale(locale); if (canonicalLocale === DEFAULT_LOCALE) return void 0; const supported = Intl.Segmenter.supportedLocalesOf([canonicalLocale]); return supported[0]; }; const getSegmenter = (locale) => { if (!hasNativeSegmenter()) return null; const resolvedLocale = resolveSegmenterLocale(locale); const cacheKey = resolvedLocale ?? DEFAULT_LOCALE; const cached = segmenterCache.get(cacheKey); if (cached) return cached; const segmenter = new Intl.Segmenter(resolvedLocale, { granularity: "word" }); segmenterCache.set(cacheKey, segmenter); return segmenter; }; const segmentTextFallback = (text) => { const result = []; splitTextRegexp.lastIndex = 0; for (const match of text.matchAll(splitTextRegexp)) { const segment = match[0]; if (!segment) continue; result.push({ text: segment, index: match.index ?? 0, isWordLike: wordLikeRegexp.test(segment) }); } return result; }; const segmentText = (text, locale) => { if (!text) return []; const segmenter = getSegmenter(locale); if (!segmenter) { return segmentTextFallback(text); } const segments = segmenter.segment(text); const result = []; for (const part of segments) { result.push({ text: part.segment, index: part.index, isWordLike: Boolean(part.isWordLike) }); } return result; }; const isSubtitleDescriptor = (arg) => Boolean( arg && typeof arg === "object" && "format" in arg && "source" in arg && "url" in arg ); const pickDescriptorFromVideoData = (videoData, requestLang, spokenLang) => { const list = videoData.subtitles; if (!Array.isArray(list) || list.length === 0) return null; const desiredLang = requestLang ?? spokenLang; if (desiredLang) { const translated = list.find( (subtitle) => subtitle.language === desiredLang && typeof subtitle.translatedFromLanguage === "string" ); if (translated) return translated; const original = list.find((subtitle) => subtitle.language === desiredLang); if (original) return original; } return list[0] ?? null; }; const appendYoutubePoTokenParams = (inputUrl) => { const poToken = YoutubeHelper.getPoToken(); if (!poToken) return inputUrl; const deviceParamsRaw = YoutubeHelper.getDeviceParams(); const normalizedDeviceParams = deviceParamsRaw.replace(/^[?&]+/u, ""); try { const parsed = new URL(inputUrl); parsed.searchParams.set("potc", "1"); parsed.searchParams.set("pot", poToken); if (normalizedDeviceParams) { const deviceParams = new URLSearchParams(normalizedDeviceParams); for (const [key, value] of deviceParams.entries()) { parsed.searchParams.set(key, value); } } return parsed.toString(); } catch { const separator = inputUrl.includes("?") ? "&" : "?"; const serializedDeviceParams = normalizedDeviceParams ? `&${normalizedDeviceParams}` : ""; return `${inputUrl}${separator}potc=1&pot=${encodeURIComponent(poToken)}${serializedDeviceParams}`; } }; const compareNumbers = (left, right) => left - right; const compareStrings = (left, right) => { if (left < right) return -1; if (left > right) return 1; return 0; }; const compareRankArrays = (left, right) => { const length = Math.min(left.length, right.length); for (let i2 = 0; i2 < length; i2 += 1) { const diff = left[i2] - right[i2]; if (diff !== 0) return diff; } if (left.length !== right.length) { return left.length - right.length; } return 0; }; const getYandexTranslationKindRank = (isYandex, requestLanguageRank, isTranslated) => { if (!isYandex) return 0; if (requestLanguageRank === 0) { return isTranslated ? 1 : 0; } return isTranslated ? 0 : 1; }; const getTranslatedFromRequestRank = (isYandex, isTranslated, translatedFromLanguage, requestLang) => { if (!isYandex || !isTranslated) return 0; return translatedFromLanguage === requestLang ? 0 : 1; }; const buildSubtitleRank = (descriptor, requestLang) => { const isYandex = descriptor.source === "yandex"; const sourceRank = isYandex ? 0 : 1; const uiLanguageRank = descriptor.language === lang ? 0 : 1; const isTranslated = Boolean(descriptor.translatedFromLanguage); const requestLanguageRank = requestLang && descriptor.language === requestLang ? 0 : 1; const translationKindRank = getYandexTranslationKindRank( isYandex, requestLanguageRank, isTranslated ); const translatedFromRequestRank = getTranslatedFromRequestRank( isYandex, isTranslated, descriptor.translatedFromLanguage, requestLang ); const originalRequestLanguageRank = isYandex && !isTranslated ? requestLanguageRank : 0; const nonYandexAutogeneratedRank = isYandex ? 0 : Number(Boolean(descriptor.isAutoGenerated)); return [ sourceRank, uiLanguageRank, translationKindRank, translatedFromRequestRank, originalRequestLanguageRank, nonYandexAutogeneratedRank ]; }; const sortSubtitles = (subtitles, requestLang) => { const ranked = subtitles.map((descriptor, index) => ({ descriptor, index })); ranked.sort((left, right) => { const leftRank = buildSubtitleRank(left.descriptor, requestLang); const rightRank = buildSubtitleRank(right.descriptor, requestLang); const rankDiff = compareRankArrays(leftRank, rightRank); if (rankDiff !== 0) return rankDiff; const descriptorOrder = compareStrings(left.descriptor.language, right.descriptor.language) || compareStrings( left.descriptor.translatedFromLanguage ?? "", right.descriptor.translatedFromLanguage ?? "" ) || compareStrings(left.descriptor.source, right.descriptor.source) || compareStrings(left.descriptor.url, right.descriptor.url) || compareNumbers( Number(Boolean(left.descriptor.isAutoGenerated)), Number(Boolean(right.descriptor.isAutoGenerated)) ); if (descriptorOrder !== 0) return descriptorOrder; return compareNumbers(left.index, right.index); }); return ranked.map((entry) => entry.descriptor); }; const resolveTokenWordLike = (value, text) => { if (typeof value === "boolean") return value; return Boolean(text.trim()); }; const sanitizeToken = (token) => { if (!token || typeof token !== "object") { return { text: "", startMs: 0, durationMs: 0, isWordLike: false }; } const raw = token; const text = typeof raw.text === "string" ? raw.text : ""; return { text, startMs: typeof raw.startMs === "number" ? raw.startMs : 0, durationMs: typeof raw.durationMs === "number" ? raw.durationMs : 0, isWordLike: resolveTokenWordLike(raw.isWordLike, text) }; }; const sanitizeLine = (line) => { if (!line || typeof line !== "object") { return { text: "", startMs: 0, durationMs: 0, speakerId: "0", tokens: [] }; } const raw = line; const tokens = Array.isArray(raw.tokens) ? raw.tokens.map(sanitizeToken) : []; return { text: typeof raw.text === "string" ? raw.text : "", startMs: typeof raw.startMs === "number" ? raw.startMs : 0, durationMs: typeof raw.durationMs === "number" ? raw.durationMs : 0, speakerId: typeof raw.speakerId === "string" ? raw.speakerId : "0", tokens }; }; const ensureProcessedSubtitles = (input) => { if (!input || typeof input !== "object") { return { subtitles: [] }; } const payload = input; const subtitles = Array.isArray(payload.subtitles) ? payload.subtitles.map(sanitizeLine) : []; return { subtitles }; }; const stripHtmlToText = (value) => { if (!value.includes("<")) return value; if (typeof document === "undefined") return value; const template = document.createElement("template"); template.innerHTML = value; return template.content.textContent ?? ""; }; const getYoutubeEventDurationMs = (event, nextEvent) => { if (!nextEvent) return Math.max(0, event.dDurationMs); if (event.tStartMs + event.dDurationMs <= nextEvent.tStartMs) { return Math.max(0, event.dDurationMs); } return Math.max(0, nextEvent.tStartMs - event.tStartMs); }; const buildYoutubeSourceTokens = (event, segs, durationMs) => { const sourceTokens = []; let text = ""; let remainingDuration = durationMs; for (let j = 0; j < segs.length; j += 1) { const segment = segs[j]; const rawText = typeof segment.utf8 === "string" ? segment.utf8 : ""; if (!rawText) continue; const offset = Math.max(0, segment.tOffsetMs ?? 0); let segmentDuration = durationMs; const nextSegment = segs[j + 1]; if (nextSegment?.tOffsetMs !== void 0) { const nextOffset = Math.max(offset, nextSegment.tOffsetMs); segmentDuration = Math.max(0, nextOffset - offset); remainingDuration = Math.max(remainingDuration - segmentDuration, 0); } let tokenDuration = Math.max(0, remainingDuration); if (nextSegment) { tokenDuration = Math.max(0, segmentDuration); } sourceTokens.push({ text: rawText, startMs: event.tStartMs + offset, durationMs: tokenDuration, isWordLike: Boolean(rawText.trim()) }); text += rawText; } return { text, sourceTokens }; }; const hasPositiveDuration = (token) => token.durationMs > 0; const normalizeLineText = (line) => { if (line.text) return line.text; if (!line.tokens.length) return ""; return line.tokens.map((token) => token.text).join(""); }; const allocateTimingsByLength = (texts, startMs, durationMs) => { if (!texts.length) return []; const safeDuration = Math.max(0, durationMs); const weights = texts.map((text) => Math.max(text.length, 1)); const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); const prefixWeights = new Array(weights.length + 1).fill(0); for (let i2 = 0; i2 < weights.length; i2 += 1) { prefixWeights[i2 + 1] = prefixWeights[i2] + weights[i2]; } const timings = []; for (let i2 = 0; i2 < texts.length; i2 += 1) { const from = Math.round(safeDuration * prefixWeights[i2] / totalWeight); const to = i2 === texts.length - 1 ? safeDuration : Math.round(safeDuration * prefixWeights[i2 + 1] / totalWeight); timings.push({ startMs: startMs + from, durationMs: Math.max(0, to - from) }); } return timings; }; const collectSourceTimedWords = (sourceTokens, locale) => { const timedWords = []; for (const token of sourceTokens) { if (!token.text || !hasPositiveDuration(token)) continue; const segmentedWords = segmentText(token.text, locale).filter( (segment) => segment.isWordLike && segment.text.trim() ); if (!segmentedWords.length) continue; const segmentTimings = allocateTimingsByLength( segmentedWords.map((segment) => segment.text), token.startMs, token.durationMs ); timedWords.push(...segmentTimings); } return timedWords; }; const buildLineTokens = (line, descriptor) => { const lineText = normalizeLineText(line); if (!lineText) return []; const locale = descriptor.language; const segments = segmentText(lineText, locale); if (!segments.length) return []; const baseTimings = allocateTimingsByLength( segments.map((segment) => segment.text), line.startMs, line.durationMs ); const nextTokens = segments.map((segment, index) => ({ text: segment.text, startMs: baseTimings[index]?.startMs ?? line.startMs, durationMs: baseTimings[index]?.durationMs ?? 0, isWordLike: segment.isWordLike })); const sourceTimedWords = collectSourceTimedWords(line.tokens ?? [], locale); if (!sourceTimedWords.length) return nextTokens; const wordIndices = nextTokens.reduce((indices, token, index) => { if (token.isWordLike && token.text.trim()) indices.push(index); return indices; }, []); if (!wordIndices.length) return nextTokens; const totalTargetWords = wordIndices.length; for (let i2 = 0; i2 < totalTargetWords; i2 += 1) { const targetIndex = wordIndices[i2]; const sourceIndex = Math.floor( i2 * sourceTimedWords.length / totalTargetWords ); const sourceTiming = sourceTimedWords[Math.min(sourceIndex, sourceTimedWords.length - 1)]; nextTokens[targetIndex] = { ...nextTokens[targetIndex], startMs: sourceTiming.startMs, durationMs: sourceTiming.durationMs }; } return nextTokens; }; const fetchRawSubtitles = async (url, format) => { const response = await GM_fetch(url, { timeout: 7e3 }); if (format === "vtt" || format === "srt") { const text = await response.text(); return convertSubs(text, "json"); } return response.json(); }; const normalizeFetchedSubtitles = (rawSubtitles, descriptor) => { if (descriptor.source === "youtube") { return SubtitlesProcessor.formatYoutubeSubtitles( rawSubtitles, Boolean(descriptor.isAutoGenerated) ); } const normalized = ensureProcessedSubtitles(rawSubtitles); if (descriptor.source === "vk") { return SubtitlesProcessor.cleanJsonSubtitles(normalized); } return normalized; }; const processFetchedSubtitles = (subtitles, descriptor) => ({ subtitles: SubtitlesProcessor.processTokens(subtitles, descriptor) }); const buildYandexSubtitles = (response) => { const subtitles = []; const seenOriginal = new Set(); for (const subtitle of response.subtitles ?? []) { if (subtitle.language && !seenOriginal.has(subtitle.language)) { seenOriginal.add(subtitle.language); subtitles.push({ source: "yandex", format: "json", language: subtitle.language, url: subtitle.url }); } if (!subtitle.translatedLanguage) continue; subtitles.push({ source: "yandex", format: "json", language: subtitle.translatedLanguage, translatedFromLanguage: subtitle.language, url: subtitle.translatedUrl ?? subtitle.url }); } return subtitles; }; const SubtitlesProcessor = { processTokens(subtitles, descriptor) { const lines = []; for (const line of subtitles.subtitles) { const text = normalizeLineText(line); const tokens = buildLineTokens( { ...line, text }, descriptor ); lines.push({ ...line, text, tokens }); } return lines; }, formatYoutubeSubtitles(subtitles, isAsr = false) { const events = subtitles.events ?? []; if (!events.length) { console.error("[VOT] Invalid YouTube subtitles format:", subtitles); return { subtitles: [] }; } const processed = []; for (let i2 = 0; i2 < events.length; i2 += 1) { const event = events[i2]; const segs = event.segs; if (!segs?.length) continue; const nextEvent = events[i2 + 1]; const durationMs = getYoutubeEventDurationMs(event, nextEvent); const { text, sourceTokens } = buildYoutubeSourceTokens( event, segs, durationMs ); const normalizedText = text.trim(); if (!normalizedText) continue; processed.push({ text: normalizedText, startMs: event.tStartMs, durationMs, speakerId: "0", tokens: isAsr ? sourceTokens : [] }); } return { subtitles: processed }; }, cleanJsonSubtitles(subtitles) { return { subtitles: subtitles.subtitles.map((line) => ({ ...line, text: stripHtmlToText(line.text), tokens: line.tokens.map((token) => ({ ...token, text: stripHtmlToText(token.text) })) })) }; }, async fetchSubtitles(descriptorOrVideoData, requestLang, spokenLang) { const descriptor = isSubtitleDescriptor(descriptorOrVideoData) ? descriptorOrVideoData : pickDescriptorFromVideoData( descriptorOrVideoData, requestLang, spokenLang ); if (!descriptor) { return { subtitles: [] }; } const { source, format } = descriptor; let { url } = descriptor; if (source === "youtube") { url = appendYoutubePoTokenParams(url); } try { const rawSubtitles = await fetchRawSubtitles(url, format); const normalized = normalizeFetchedSubtitles(rawSubtitles, descriptor); const subtitlesWithTokens = processFetchedSubtitles( normalized, descriptor ); debug.log("[VOT] Processed subtitles:", subtitlesWithTokens); return subtitlesWithTokens; } catch (error2) { console.error("[VOT] Failed to process subtitles:", error2); return { subtitles: [] }; } }, async getSubtitles(client, videoData) { const { host, url, detectedLanguage: requestLang, videoId, duration, subtitles: extraSubtitles = [] } = videoData; try { const requestPayload = { videoData: { host, url, videoId, duration }, requestLang }; const response = await Promise.race([ client.getSubtitles(requestPayload), timeout(5e3, "Timeout") ]); const res = response; debug.log("[VOT] Subtitles response:", res); if (res.waiting) { console.error("[VOT] Failed to get Yandex subtitles"); } const yandexSubs = buildYandexSubtitles(res); const all = [...yandexSubs, ...extraSubtitles]; return sortSubtitles(all, requestLang); } catch (error2) { let message = "Error in getSubtitles function"; if (error2 instanceof Error && error2.message === "Timeout") { message = "Failed to get Yandex subtitles: timeout"; } console.error(`[VOT] ${message}`, error2); throw error2; } } }; const AUDIO_SOURCE_PREFIX = "https://vtrans.s3-private.mds.yandex.net/tts/prod/"; const AUDIO_PROXY_PATH_PREFIX = "/video-translation/audio-proxy/"; const SUBTITLE_SOURCE_PREFIX = "https://brosubs.s3-private.mds.yandex.net/vtrans/"; const SUBTITLE_PROXY_PATH_PREFIX = "/video-subtitles/subtitles-proxy/"; function getProxyHost(host) { return host ?? proxyWorkerHost; } function isProxyRoutingEnabled(config2) { return config2.translateProxyEnabled === 2; } function proxifyYandexAudioUrl(audioUrl, config2) { if (!isProxyRoutingEnabled(config2) || !audioUrl.startsWith(AUDIO_SOURCE_PREFIX)) { return audioUrl; } return audioUrl.replace( AUDIO_SOURCE_PREFIX, `https://${getProxyHost(config2.proxyWorkerHost)}${AUDIO_PROXY_PATH_PREFIX}` ); } function unproxifyYandexAudioUrl(audioUrl) { const str = String(audioUrl || ""); if (!str) return str; try { const url = new URL(str); if (!url.pathname.startsWith(AUDIO_PROXY_PATH_PREFIX)) { return str; } url.host = "vtrans.s3-private.mds.yandex.net"; url.pathname = `/tts/prod/${url.pathname.slice(AUDIO_PROXY_PATH_PREFIX.length).replace(/^\/+/, "")}`; url.protocol = "https:"; return url.toString(); } catch { return str; } } function isYandexAudioUrlOrProxy(url, config2) { return url.startsWith(AUDIO_SOURCE_PREFIX) || url.startsWith( `https://${getProxyHost(config2.proxyWorkerHost)}${AUDIO_PROXY_PATH_PREFIX}` ); } function proxifyYandexSubtitlesUrl(subtitlesUrl, config2) { if (!isProxyRoutingEnabled(config2) || !subtitlesUrl.startsWith(SUBTITLE_SOURCE_PREFIX)) { return subtitlesUrl; } const subtitlesPath = subtitlesUrl.slice(SUBTITLE_SOURCE_PREFIX.length); return `https://${getProxyHost(config2.proxyWorkerHost)}${SUBTITLE_PROXY_PATH_PREFIX}${subtitlesPath}`; } const VALID_SUBTITLE_FORMATS = new Set([ "srt", "vtt", "json" ]); function asSubtitleDescriptor(value) { if (!value || typeof value !== "object") { return null; } const descriptor = value; if (typeof descriptor.source !== "string" || typeof descriptor.language !== "string" || typeof descriptor.url !== "string" || typeof descriptor.format !== "string" || !VALID_SUBTITLE_FORMATS.has( descriptor.format )) { return null; } return { source: descriptor.source, format: descriptor.format, language: descriptor.language, url: descriptor.url, translatedFromLanguage: typeof descriptor.translatedFromLanguage === "string" ? descriptor.translatedFromLanguage : void 0, isAutoGenerated: typeof descriptor.isAutoGenerated === "boolean" ? descriptor.isAutoGenerated : void 0 }; } function getIndexedSubtitleDescriptors(subtitles) { const descriptors = []; for (let index = 0; index < subtitles.length; index += 1) { const descriptor = asSubtitleDescriptor(subtitles[index]); if (!descriptor) { continue; } descriptors.push({ descriptor, index }); } return descriptors; } function findSubtitleDescriptorByIndex(subtitles, index) { return getIndexedSubtitleDescriptors(subtitles).find( (item) => item.index === index )?.descriptor ?? null; } function createDisabledSubtitlesOption() { return { label: localizationProvider.get("VOTSubtitlesDisabled"), value: "disabled", selected: true, disabled: false }; } function getSelectedSubtitlesValue(selectedValues) { return selectedValues[Symbol.iterator]().next().value; } function buildSubtitleLabel(subtitle) { const languageLabel = localizationProvider.getLangLabel(subtitle.language); const translatedFromLabel = subtitle.translatedFromLanguage ? ` ${localizationProvider.get("VOTTranslatedFrom")} ${localizationProvider.getLangLabel( subtitle.translatedFromLanguage )}` : ""; const sourceSuffix = subtitle.source === "yandex" ? "" : `, ${globalThis.location.hostname}`; const autogeneratedSuffix = subtitle.isAutoGenerated ? ` (${localizationProvider.get("VOTAutogenerated")})` : ""; return `${languageLabel}${translatedFromLabel}${sourceSuffix}${autogeneratedSuffix}`; } function normalizeLang(lang2) { return (lang2 ?? "").toLowerCase(); } function baseLang(lang2) { const normalized = normalizeLang(lang2); return normalized.split(/[-_]/)[0]; } function langMatches(candidate, desired) { if (!candidate || !desired) return false; const cand = normalizeLang(candidate); const want = normalizeLang(desired); return cand === want || baseLang(cand) === baseLang(want); } function pickBestSubtitlesIndex(subtitles, fromLang, toLang) { if (!subtitles.length) return null; const from = normalizeLang(fromLang); const to = normalizeLang(toLang); const fromIsAuto = from === "auto" || from === ""; const fromBase = baseLang(from); const toBase = baseLang(to); const isYandex = (s2) => s2.source === "yandex"; const isAutoGenerated = (s2) => Boolean(s2.isAutoGenerated); const matchesPair = (s2, wantFrom, wantTo) => { if (!langMatches(s2.language, wantTo)) return false; if (fromIsAuto) return true; return langMatches(s2.translatedFromLanguage, wantFrom); }; const isSameLangOriginal = (s2, lang2) => { if (!langMatches(s2.language, lang2)) return false; if (!s2.translatedFromLanguage) return true; return langMatches(s2.translatedFromLanguage, lang2); }; const find = (predicate) => subtitles.find(({ descriptor }) => predicate(descriptor))?.index ?? null; const findOtherTarget = () => { const otherTargetManual = find( (s2) => !isYandex(s2) && langMatches(s2.language, to) && !isAutoGenerated(s2) ); if (otherTargetManual != null) return otherTargetManual; return find( (s2) => !isYandex(s2) && langMatches(s2.language, to) && isAutoGenerated(s2) ); }; const yandexPair = find((s2) => isYandex(s2) && matchesPair(s2, from, to)); if (yandexPair != null) return yandexPair; if (!fromIsAuto && fromBase && toBase && fromBase === toBase) { const nativeManual = find( (s2) => isSameLangOriginal(s2, to) && !isAutoGenerated(s2) ); if (nativeManual != null) return nativeManual; const nativeAuto = find( (s2) => isSameLangOriginal(s2, to) && isAutoGenerated(s2) ); if (nativeAuto != null) return nativeAuto; const otherTarget2 = findOtherTarget(); if (otherTarget2 != null) return otherTarget2; const yandexTargetSameLang = find( (s2) => isYandex(s2) && langMatches(s2.language, to) ); if (yandexTargetSameLang != null) return yandexTargetSameLang; } const yandexTarget = find((s2) => isYandex(s2) && langMatches(s2.language, to)); if (yandexTarget != null) return yandexTarget; const otherPair = find((s2) => !isYandex(s2) && matchesPair(s2, from, to)); if (otherPair != null) return otherPair; const otherTarget = findOtherTarget(); if (otherTarget != null) return otherTarget; return null; } async function changeSubtitlesLang(subs) { const overlayView = this.uiManager.votOverlayView; if (!overlayView?.subtitlesSelect || !overlayView.downloadSubtitlesButton) { return this; } overlayView.subtitlesSelect.setSelectedValue(subs); if (subs === "disabled") { if (this.hasSubtitlesWidget()) { this.subtitlesWidget?.setContent(null); } overlayView.downloadSubtitlesButton.hidden = true; this.yandexSubtitles = null; return this; } const subtitlesIndex = Number.parseInt(subs, 10); const descriptor = findSubtitleDescriptorByIndex( this.subtitles, subtitlesIndex ); if (!descriptor) { overlayView.downloadSubtitlesButton.hidden = true; this.yandexSubtitles = null; return this; } let subtitlesObj = { ...descriptor }; const proxiedSubtitlesUrl = proxifyYandexSubtitlesUrl(subtitlesObj.url, { translateProxyEnabled: this.data?.translateProxyEnabled, proxyWorkerHost: this.data?.proxyWorkerHost }); if (proxiedSubtitlesUrl !== subtitlesObj.url) { subtitlesObj = { ...subtitlesObj, url: proxiedSubtitlesUrl }; debug.log(`[VOT] Subs proxied via ${subtitlesObj.url}`); } this.yandexSubtitles = await SubtitlesProcessor.fetchSubtitles(subtitlesObj); this.getSubtitlesWidget().setContent( this.yandexSubtitles, subtitlesObj.language ); overlayView.downloadSubtitlesButton.hidden = false; return this; } async function updateSubtitlesLangSelect() { const overlayView = this.uiManager.votOverlayView; if (!overlayView?.subtitlesSelect) { return; } const subtitleDescriptors = getIndexedSubtitleDescriptors(this.subtitles); if (subtitleDescriptors.length === 0) { const updatedOptions2 = [ createDisabledSubtitlesOption() ]; overlayView.subtitlesSelect.updateItems(updatedOptions2); await this.changeSubtitlesLang(updatedOptions2[0].value); return; } const updatedOptions = [ createDisabledSubtitlesOption(), ...subtitleDescriptors.map(({ descriptor, index }) => ({ label: buildSubtitleLabel(descriptor), value: String(index), selected: false, disabled: false })) ]; overlayView.subtitlesSelect.updateItems(updatedOptions); await this.changeSubtitlesLang(updatedOptions[0].value); } async function enableSubtitlesForCurrentLangPair() { const overlayView = this.uiManager.votOverlayView; if (!overlayView?.subtitlesSelect) return this; if (!Array.isArray(this.subtitles) || this.subtitles.length === 0) { try { await this.loadSubtitles(); } catch { return this; } } const fromLang = this.videoData?.detectedLanguage ?? this.translateFromLang; const toLang = this.videoData?.responseLanguage ?? this.translateToLang; const bestIdx = pickBestSubtitlesIndex( getIndexedSubtitleDescriptors(this.subtitles), fromLang, toLang ); if (bestIdx == null) { return this; } const currentValue = getSelectedSubtitlesValue( overlayView.subtitlesSelect.selectedValues ); if (currentValue === String(bestIdx)) { return this; } await this.changeSubtitlesLang(String(bestIdx)); return this; } async function toggleSubtitlesForCurrentLangPair() { const overlayView = this.uiManager.votOverlayView; if (!overlayView?.subtitlesSelect) return this; const currentValue = getSelectedSubtitlesValue( overlayView.subtitlesSelect.selectedValues ); if (currentValue && currentValue !== "disabled") { await this.changeSubtitlesLang("disabled"); return this; } await this.enableSubtitlesForCurrentLangPair(); return this; } async function loadSubtitles() { if (!this.videoData?.videoId) { console.error( `[VOT] ${localizationProvider.getDefault("VOTNoVideoIDFound")}` ); this.subtitles = []; return; } const cacheKey = this.getSubtitlesCacheKey( this.videoData.videoId, this.videoData.detectedLanguage, this.videoData.responseLanguage ); try { let cachedSubs = this.cacheManager.getSubtitles(cacheKey); if (!cachedSubs) { let inflight = this.subtitlesLoadPromises.get(cacheKey); if (inflight === void 0) { inflight = SubtitlesProcessor.getSubtitles( this.votClient, this.videoData ); this.subtitlesLoadPromises.set(cacheKey, inflight); } try { cachedSubs = await inflight; this.cacheManager.setSubtitles(cacheKey, cachedSubs); } finally { if (this.subtitlesLoadPromises.get(cacheKey) === inflight) { this.subtitlesLoadPromises.delete(cacheKey); } } } this.subtitles = cachedSubs; } catch (error2) { console.error("[VOT] Failed to load subtitles:", error2); this.subtitles = []; } await this.updateSubtitlesLangSelect(); } const SMART_DUCKING_DEFAULT_CONFIG = { tickMs: 50, thresholdOnRms: 0.012, thresholdOffRms: 9e-3, rmsAttackTauMs: 60, rmsReleaseTauMs: 240, holdMs: 520, attackTauMs: 110, releaseTauMs: 600, maxDownPerSec: 3.5, maxUpPerSec: 0.9, rmsMissingGraceMs: 200, maxDtMs: 250, externalBaselineDelta01: 0.02, unduckTolerance01: 0.01, volumeStep01: VIDEO_VOLUME_STEP_01, applyDeltaThreshold01: VIDEO_VOLUME_STEP_01 / 2 }; function initSmartDuckingRuntime(baseline) { return { isDucked: false, speechGateOpen: false, rmsEnvelope: 0, baseline: normalizeVolume01(baseline), lastApplied: void 0, lastTickAt: 0, lastSoundAt: 0, rmsMissingSinceAt: null }; } function resetSmartDuckingRuntime() { return initSmartDuckingRuntime(); } function computeSmartDuckingStep(input, runtime, config2 = SMART_DUCKING_DEFAULT_CONFIG) { const nextRuntime = normalizeRuntime(runtime); const volumeOnStart = normalizeVolume01(input.volumeOnStart); if (!input.translationActive || !input.enabledAutoVolume) { return { kind: "stop", runtime: nextRuntime, restoreVolume: nextRuntime.baseline ?? volumeOnStart }; } const now2 = Number.isFinite(input.nowMs) ? input.nowMs : Date.now(); const prevTickAt = nextRuntime.lastTickAt || now2; const dtMs = clamp$2(now2 - prevTickAt, 0, config2.maxDtMs); const dtSec = dtMs / 1e3; nextRuntime.lastTickAt = now2; const hasRms = isFiniteNumber(input.rms); const rmsValue = hasRms && typeof input.rms === "number" ? clamp$2(input.rms, 0, 1) : 0; const prevEnv = clamp$2(nextRuntime.rmsEnvelope, 0, 1); const envTauMs = rmsValue > prevEnv ? config2.rmsAttackTauMs : config2.rmsReleaseTauMs; const envAlpha = envTauMs > 0 ? 1 - Math.exp(-dtMs / envTauMs) : 1; nextRuntime.rmsEnvelope = clamp$2( prevEnv + (rmsValue - prevEnv) * envAlpha, 0, 1 ); let gateOpen = nextRuntime.speechGateOpen; if (!input.smartEnabled) { gateOpen = true; nextRuntime.lastSoundAt = now2; nextRuntime.rmsMissingSinceAt = null; } else if (input.audioIsPlaying && !hasRms) { nextRuntime.rmsMissingSinceAt ??= now2; if (gateOpen) { nextRuntime.lastSoundAt = now2; } if (nextRuntime.rmsMissingSinceAt !== null && now2 - nextRuntime.rmsMissingSinceAt >= config2.rmsMissingGraceMs) { gateOpen = true; nextRuntime.lastSoundAt = now2; } } else { nextRuntime.rmsMissingSinceAt = null; if (!gateOpen) { if (input.audioIsPlaying && nextRuntime.rmsEnvelope >= config2.thresholdOnRms) { gateOpen = true; nextRuntime.lastSoundAt = now2; } } else if (input.audioIsPlaying && nextRuntime.rmsEnvelope >= config2.thresholdOffRms) { nextRuntime.lastSoundAt = now2; } else if (now2 - nextRuntime.lastSoundAt > config2.holdMs) { gateOpen = false; } } nextRuntime.speechGateOpen = gateOpen; const currentVideoVolume = normalizeVolume01(input.currentVideoVolume); if (!isFiniteNumber(currentVideoVolume)) { return { kind: "noop", runtime: nextRuntime }; } if (nextRuntime.isDucked && isFiniteNumber(nextRuntime.lastApplied) && Math.abs(currentVideoVolume - nextRuntime.lastApplied) > config2.externalBaselineDelta01) { nextRuntime.baseline = currentVideoVolume; } if (!nextRuntime.isDucked) { nextRuntime.baseline = currentVideoVolume; } const baseline = nextRuntime.baseline ?? volumeOnStart ?? currentVideoVolume; nextRuntime.baseline = baseline; if (!input.hostVideoActive) { nextRuntime.lastApplied = currentVideoVolume; return { kind: "noop", runtime: nextRuntime }; } const duckingTarget01 = normalizeVolume01(input.duckingTarget01) ?? baseline; const duckedTarget = Math.min(baseline, duckingTarget01); let desired = baseline; if (gateOpen) { if (!nextRuntime.isDucked) { nextRuntime.baseline = baseline; nextRuntime.isDucked = true; } desired = duckedTarget; } else if (nextRuntime.isDucked && Math.abs(baseline - currentVideoVolume) < config2.unduckTolerance01) { nextRuntime.isDucked = false; } const tauMs = desired < currentVideoVolume ? config2.attackTauMs : config2.releaseTauMs; const alpha = tauMs > 0 ? 1 - Math.exp(-dtMs / tauMs) : 1; let nextVolume = currentVideoVolume + (desired - currentVideoVolume) * alpha; const maxDelta = (desired < currentVideoVolume ? config2.maxDownPerSec : config2.maxUpPerSec) * dtSec; if (Number.isFinite(maxDelta) && maxDelta > 0) { nextVolume = clamp$2( nextVolume, currentVideoVolume - maxDelta, currentVideoVolume + maxDelta ); } nextVolume = clamp$2(nextVolume, 0, 1); const quantized = snapVolume01Towards( nextVolume, currentVideoVolume, desired, config2.volumeStep01 ); if (!isFiniteNumber(nextRuntime.lastApplied) || Math.abs(quantized - nextRuntime.lastApplied) >= config2.applyDeltaThreshold01) { nextRuntime.lastApplied = quantized; return { kind: "apply", runtime: nextRuntime, volume01: quantized }; } return { kind: "noop", runtime: nextRuntime }; } function normalizeRuntime(runtime) { return { isDucked: Boolean(runtime.isDucked), speechGateOpen: Boolean(runtime.speechGateOpen), rmsEnvelope: clamp$2(runtime.rmsEnvelope ?? 0, 0, 1), baseline: normalizeVolume01(runtime.baseline), lastApplied: normalizeVolume01(runtime.lastApplied), lastTickAt: isFiniteNumber(runtime.lastTickAt) ? runtime.lastTickAt : 0, lastSoundAt: isFiniteNumber(runtime.lastSoundAt) ? runtime.lastSoundAt : 0, rmsMissingSinceAt: isFiniteNumber(runtime.rmsMissingSinceAt) ? runtime.rmsMissingSinceAt : null }; } function normalizeVolume01(value) { if (!isFiniteNumber(value)) return void 0; return clamp$2(value, 0, 1); } function isFiniteNumber(value) { return typeof value === "number" && Number.isFinite(value); } function readSmartDuckingRuntime(handler) { return { isDucked: handler.smartVolumeIsDucked, speechGateOpen: handler.smartVolumeSpeechGateOpen, rmsEnvelope: handler.smartVolumeRmsEnvelope, baseline: handler.smartVolumeDuckingBaseline, lastApplied: handler.smartVolumeLastApplied, lastTickAt: handler.smartVolumeLastTickAt, lastSoundAt: handler.smartVolumeLastSoundAt, rmsMissingSinceAt: handler.smartVolumeRmsMissingSinceAt }; } function writeSmartDuckingRuntime(handler, runtime) { handler.smartVolumeIsDucked = runtime.isDucked; handler.smartVolumeSpeechGateOpen = runtime.speechGateOpen; handler.smartVolumeRmsEnvelope = runtime.rmsEnvelope; handler.smartVolumeDuckingBaseline = runtime.baseline; handler.smartVolumeLastApplied = runtime.lastApplied; handler.smartVolumeLastTickAt = runtime.lastTickAt; handler.smartVolumeLastSoundAt = runtime.lastSoundAt; handler.smartVolumeRmsMissingSinceAt = runtime.rmsMissingSinceAt; } function stopSmartVolumeDucking(handler, options = {}) { const { restoreVolume } = options; if (typeof handler.smartVolumeDuckingInterval === "number") { clearInterval(handler.smartVolumeDuckingInterval); handler.smartVolumeDuckingInterval = void 0; } const baseline = typeof restoreVolume === "number" ? restoreVolume : handler.smartVolumeDuckingBaseline ?? handler.volumeOnStart; if (typeof baseline === "number" && (typeof restoreVolume === "number" || handler.smartVolumeIsDucked)) { try { handler.setVideoVolume(baseline); } catch { } } writeSmartDuckingRuntime(handler, resetSmartDuckingRuntime()); } function startSmartVolumeDucking(handler) { if (typeof globalThis === "undefined") return; if (typeof handler.smartVolumeDuckingInterval === "number") return; writeSmartDuckingRuntime( handler, initSmartDuckingRuntime(handler.getVideoVolume()) ); handler.smartVolumeDuckingInterval = globalThis.setInterval(() => { smartDuckingTick(handler); }, SMART_DUCKING_DEFAULT_CONFIG.tickMs); } function getTranslatedAudioRms() { const player2 = this.audioPlayer?.player; const rms = player2?.getAudioRms?.(); if (typeof rms === "number" && Number.isFinite(rms)) return rms; const analyser = player2?.analyser; if (!analyser) return void 0; try { if (typeof analyser.getFloatTimeDomainData === "function") { let floatData = player2?.analyserFloatData; if (floatData?.length !== analyser.fftSize) { floatData = new Float32Array(analyser.fftSize); player2.analyserFloatData = floatData; } analyser.getFloatTimeDomainData(floatData); let sum2 = 0; for (const value of floatData) { sum2 += value * value; } return Math.sqrt(sum2 / floatData.length); } let data = player2?.analyserData; if (data?.length !== analyser.fftSize) { data = new Uint8Array(analyser.fftSize); player2.analyserData = data; } analyser.getByteTimeDomainData(data); let sum = 0; for (const rawValue of data) { const normalizedValue = (rawValue - 128) / 128; sum += normalizedValue * normalizedValue; } return Math.sqrt(sum / data.length); } catch { return void 0; } } function smartDuckingTick(handler) { const player2 = handler.audioPlayer?.player; const media = player2?.getMediaElement?.() ?? player2?.audio ?? player2?.audioElement; const audioIsPlaying = !!media && !media.paused && !media.muted && (media.volume ?? 1) > 1e-3; const now2 = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now(); const currentVideoVolume = handler.getVideoVolume(); const hostVideo = handler.video; const hostVideoActive = !(hostVideo && (hostVideo.paused || hostVideo.ended)); const dynamicDuckingTarget = clamp$2(handler.data?.autoVolume ?? defaultAutoVolume, 0, 100) / 100; handler.smartVolumeDuckingTarget = dynamicDuckingTarget; const decision = computeSmartDuckingStep( { nowMs: now2, translationActive: handler.hasActiveSource(), enabledAutoVolume: Boolean(handler.data?.enabledAutoVolume), smartEnabled: handler.data?.enabledSmartDucking ?? true, audioIsPlaying, rms: audioIsPlaying ? getTranslatedAudioRms.call(handler) : 0, currentVideoVolume, hostVideoActive, duckingTarget01: dynamicDuckingTarget, volumeOnStart: handler.volumeOnStart }, readSmartDuckingRuntime(handler), SMART_DUCKING_DEFAULT_CONFIG ); switch (decision.kind) { case "stop": stopSmartVolumeDucking(handler, { restoreVolume: decision.restoreVolume }); return; case "apply": handler.setVideoVolume(decision.volume01); writeSmartDuckingRuntime(handler, decision.runtime); return; case "noop": writeSmartDuckingRuntime(handler, decision.runtime); return; default: { throw new TypeError("Unhandled smart ducking decision"); } } } async function validateAudioUrl(audioUrl, actionContext) { if (this.isActionStale(actionContext)) return audioUrl; try { const fetchOpts = this.isMultiMethodS3(audioUrl) ? { method: "HEAD" } : { headers: { range: "bytes=0-0" } }; const response = await GM_fetch(audioUrl, fetchOpts); if (this.isActionStale(actionContext)) return audioUrl; debug.log("Test audio response", response); if (response.ok) { debug.log("Valid audioUrl", audioUrl); return audioUrl; } debug.log("Yandex returned not valid audio, trying to fix..."); if (!this.videoData) { debug.log("Skip audio fix - videoData is not available"); return audioUrl; } if (this.isActionStale(actionContext)) return audioUrl; const fromLang = this.videoData.detectedLanguage || this.translateFromLang; const translateRes = await requestTranslationAudio( this.translationHandler, { videoData: this.videoData, requestLang: fromLang, responseLang: this.translateToLang, translationHelp: this.videoData.translationHelp, useAudioDownload: Boolean(this.data?.useAudioDownload), signal: this.actionsAbortController.signal } ); if (!translateRes) { debug.log("Failed to retranslate audio - using original url"); return audioUrl; } this.setSelectMenuValues( this.videoData.detectedLanguage, this.videoData.responseLanguage ); this.scheduleTranslationRefresh(); audioUrl = translateRes.url; debug.log("Fixed audio audioUrl", audioUrl); } catch (err) { } return audioUrl; } function scheduleTranslationRefresh() { if (!this.videoData || this.videoData.isStream) { return; } if (!this.hasActiveSource()) return; clearTimeout(this.translationRefreshTimeout); const refreshDelayMs = Math.max(3e4, YANDEX_TTL_MS - 5 * 60 * 1e3); this.translationRefreshTimeout = setTimeout(() => { this.refreshTranslationAudio().catch((error2) => { }); }, refreshDelayMs); } async function requestApplyAndCacheTranslation(self, options) { const translateRes = await requestAndApplyTranslation({ requester: self.translationHandler, request: { videoData: options.videoData, requestLang: options.requestLang, responseLang: options.responseLang, translationHelp: options.translationHelp, useAudioDownload: Boolean(self.data?.useAudioDownload), signal: self.actionsAbortController.signal }, actionContext: options.actionContext, isActionStale: (ctx) => self.isActionStale(ctx), updateTranslation: (url, ctx) => self.updateTranslation(url, ctx), scheduleTranslationRefresh: () => self.scheduleTranslationRefresh() }); if (!translateRes) return null; if (options.onBeforeCache) { await options.onBeforeCache(translateRes); } setTranslationCacheValue({ cacheKey: options.cacheKey, setTranslation: (key, value) => self.cacheManager.setTranslation(key, value), videoId: options.cacheVideoId, requestLang: options.cacheRequestLang, responseLang: options.cacheResponseLang, fallbackUrl: translateRes.url, downloadTranslationUrl: self.downloadTranslationUrl, usedLivelyVoice: translateRes.usedLivelyVoice }); return translateRes; } async function refreshTranslationAudio() { if (!this.videoData || this.videoData.isStream) { return; } if (!this.hasActiveSource()) return; if (this.isRefreshingTranslation) return; const videoId = this.videoData.videoId; if (!videoId) return; if (this.actionsAbortController?.signal?.aborted) { this.resetActionsAbortController("refreshTranslationAudio"); } this.isRefreshingTranslation = true; const actionContext = { gen: this.actionsGeneration, videoId }; const normalizedTranslationHelp = normalizeTranslationHelp( this.videoData.translationHelp ); try { const translateRes = await requestApplyAndCacheTranslation(this, { videoData: this.videoData, requestLang: this.translateFromLang, responseLang: this.translateToLang, translationHelp: normalizedTranslationHelp, actionContext, cacheKey: this.getTranslationCacheKey( videoId, this.translateFromLang, this.translateToLang, normalizedTranslationHelp ), cacheVideoId: videoId, cacheRequestLang: this.translateFromLang, cacheResponseLang: this.translateToLang }); if (!translateRes) return; } finally { this.isRefreshingTranslation = false; } } function proxifyAudio(audioUrl) { const proxiedAudioUrl = proxifyYandexAudioUrl(audioUrl, { translateProxyEnabled: this.data?.translateProxyEnabled, proxyWorkerHost: this.data?.proxyWorkerHost }); return proxiedAudioUrl; } function unproxifyAudio(audioUrl) { return unproxifyYandexAudioUrl(audioUrl); } async function handleProxySettingsChanged(reason = "proxySettingsChanged") { try { debug.log(`[VOT] ${reason}: clearing translation cache`); this.cacheManager.clear(); this.activeTranslation = null; } catch { } try { this.resetActionsAbortController(reason); } catch { } 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) { } } function isMultiMethodS3(url) { return isYandexAudioUrlOrProxy(url, { proxyWorkerHost: this.data?.proxyWorkerHost }); } async function updateTranslation(audioUrl, actionContext) { if (this.isActionStale(actionContext)) return; if (!this.audioPlayer) { this.createPlayer(); } const normalizedTargetUrl = this.proxifyAudio(this.unproxifyAudio(audioUrl)); const currentSource = this.audioPlayer.player.currentSrc || this.audioPlayer.player.src || ""; const normalizedCurrentUrl = this.proxifyAudio( this.unproxifyAudio(currentSource) ); let nextAudioUrl = normalizedTargetUrl; if (normalizedTargetUrl !== normalizedCurrentUrl) { nextAudioUrl = await this.validateAudioUrl( normalizedTargetUrl, actionContext ); } if (this.isActionStale(actionContext)) return; const shouldInitPlayer = this.audioPlayer.player.src !== nextAudioUrl; if (shouldInitPlayer) { this.audioPlayer.player.src = nextAudioUrl; } try { if (shouldInitPlayer) { this.audioPlayer.init(); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); this.transformBtn("error", msg); } this.setupAudioSettings(); this.transformBtn("success", localizationProvider.get("disableTranslate")); this.afterUpdateTranslation(nextAudioUrl); } async function translateFunc(VIDEO_ID, _isStream, requestLang, responseLang, translationHelp) { this.videoValidator(); if (this.actionsAbortController?.signal?.aborted) { this.resetActionsAbortController("translateFunc"); } const overlayView = this.uiManager.votOverlayView; if (!overlayView?.votButton) { return; } overlayView.votButton.loading = true; this.hadAsyncWait = false; this.volumeOnStart = this.getVideoVolume(); if (!VIDEO_ID) { await this.updateTranslationErrorMsg( new VOTLocalizedError("VOTNoVideoIDFound"), this.actionsAbortController.signal ); return; } const videoData = this.videoData; if (!videoData) { await this.updateTranslationErrorMsg( new VOTLocalizedError("VOTNoVideoIDFound"), this.actionsAbortController.signal ); return; } const normalizedTranslationHelp = normalizeTranslationHelp(translationHelp); const cacheKey = this.getTranslationCacheKey( VIDEO_ID, requestLang, responseLang, normalizedTranslationHelp ); const activeKey = `video_${cacheKey}`; if (this.activeTranslation?.key === activeKey) { await this.activeTranslation.promise; return; } const actionContext = { gen: this.actionsGeneration, videoId: VIDEO_ID }; const translationPromise = (async () => { if (this.isActionStale(actionContext)) { return; } const reqLang = requestLang; const resLang = responseLang; const applyTranslationUrl = async (url) => await updateTranslationAndSchedule({ url, actionContext, isActionStale: (ctx) => this.isActionStale(ctx), updateTranslation: (nextUrl, ctx) => this.updateTranslation(nextUrl, ctx), scheduleTranslationRefresh: () => this.scheduleTranslationRefresh() }); const cachedEntry = this.cacheManager.getTranslation(cacheKey); if (cachedEntry?.url) { const updated = await applyTranslationUrl(cachedEntry.url); if (!updated) return; return; } const translateRes = await requestApplyAndCacheTranslation(this, { videoData, requestLang: reqLang, responseLang: resLang, translationHelp: normalizedTranslationHelp, actionContext, cacheKey, cacheVideoId: VIDEO_ID, cacheRequestLang: requestLang, cacheResponseLang: responseLang, onBeforeCache: async () => { const subsCacheKey = this.videoData ? this.getSubtitlesCacheKey( VIDEO_ID, this.videoData.detectedLanguage, this.videoData.responseLanguage ) : null; const cachedSubs = subsCacheKey ? this.cacheManager.getSubtitles(subsCacheKey) : null; if (!cachedSubs?.some( (item) => item.source === "yandex" && item.translatedFromLanguage === videoData.detectedLanguage && item.language === videoData.responseLanguage )) { if (subsCacheKey) this.cacheManager.deleteSubtitles(subsCacheKey); this.subtitles = []; } } }); if (!translateRes) { return; } })(); this.activeTranslation = { key: activeKey, promise: translationPromise }; try { return await translationPromise; } catch (err) { this.hadAsyncWait = notifyTranslationFailureIfNeeded({ aborted: this.actionsAbortController.signal.aborted, translateApiErrorsEnabled: Boolean(this.data?.translateAPIErrors), hadAsyncWait: this.hadAsyncWait, videoId: VIDEO_ID, error: err, notify: (params) => this.notifier.translationFailed(params) }); throw err; } finally { if (this.activeTranslation?.promise === translationPromise) { this.activeTranslation = null; } } } function isYouTubeHosts() { return isTranslationDownloadHost(this.site.host); } function setupAudioSettings() { if (typeof this.data?.defaultVolume === "number") { this.audioPlayer.player.volume = this.data.defaultVolume / 100; } if (this.data?.enabledAutoVolume) { this.smartVolumeDuckingTarget = clamp$2(this.data.autoVolume ?? defaultAutoVolume, 0, 100) / 100; startSmartVolumeDucking(this); } else { stopSmartVolumeDucking(this, { restoreVolume: this.smartVolumeDuckingBaseline ?? this.volumeOnStart }); } } const RESPONSE_LANG_SET = new Set(availableTTS); const isResponseLang = (value) => RESPONSE_LANG_SET.has(value); class VideoHandler { video; container; site; translateFromLang = "auto"; translateToLang = calculatedResLang; data; videoData; firstPlay = true; audioContext; votClient; audioPlayer; abortController; actionsAbortController; actionsGeneration = 0; notifier = new Notifier(); cacheManager; subtitlesLoadPromises = new Map(); downloadTranslationUrl = null; translationRefreshTimeout; isRefreshingTranslation = false; autoRetry; votOpts; volumeOnStart; volumeLinkState = { initialized: false, lastVideoPercent: 0, lastTranslationPercent: 0 }; internalVideoVolumeSetAt = 0; internalVideoVolumeSetPercent = null; internalVideoVolumeSuppressionMs = 250; smartVolumeDuckingInterval; smartVolumeDuckingTarget = 0.2; smartVolumeDuckingBaseline; smartVolumeLastApplied; smartVolumeLastTickAt = 0; smartVolumeLastSoundAt = 0; smartVolumeRmsMissingSinceAt = null; smartVolumeRmsEnvelope = 0; smartVolumeSpeechGateOpen = false; smartVolumeIsDucked = false; longWaitingResCount = 0; hadAsyncWait = false; subtitles = []; subtitlesWidget; activeTranslation = null; interactionChecker; uiManager; overlayVisibility; overlayVisibilityTargetsAbortController; translationOrchestrator; lifecycleController; translationHandler; videoManager; yandexSubtitles = null; resizeObserver; syncVolumeObserver; initialized = false; mountCache; errorTranslationCache = new Map(); getOverlayMountPoints(container = this.container) { const base = this.site.host === "youtube" && this.site.additionalData !== "mobile" ? container.parentElement ?? container : container; const cache = this.mountCache; if (cache?.container === container && cache.base === base && (cache.root.isConnected ?? document.documentElement.contains(cache.root))) { return { root: cache.root, portalContainer: cache.portalContainer }; } const root = resolveInteractiveMount(base); const portalContainer = root; this.mountCache = { container, base, root, portalContainer }; return { root, portalContainer }; } getOverlayMount(container = this.container) { const { root, portalContainer } = this.getOverlayMountPoints(container); return { root, portalContainer, tooltipLayoutRoot: this.tooltipLayoutRoot }; } getTranslationCacheKey(videoId, from, to, translationHelp) { const requestLangForApi = this.getRequestLangForTranslation( from, to ); const useLivelyVoice = this.isLivelyVoiceAllowed(requestLangForApi, to) && this.data?.useLivelyVoice; const helpStr = translationHelp === void 0 || translationHelp === null ? "" : stableStringify(translationHelp); const helpHash = helpStr ? fnv1a32ToKeyPart(helpStr) : "0"; return `${videoId}_${requestLangForApi}_${to}_${useLivelyVoice}_${helpHash}`; } getSubtitlesCacheKey(videoId, detectedLanguage, responseLanguage) { return `${videoId}_${detectedLanguage}_${responseLanguage}_${Boolean(this.data?.useLivelyVoice)}`; } isActionStale(actionContext) { if (!actionContext) return false; return this.actionsGeneration !== actionContext.gen || this.videoData?.videoId !== actionContext.videoId; } resetActionsAbortController(reason) { try { this.actionsAbortController?.abort(reason); } catch { } this.actionsAbortController = new AbortController(); this.actionsGeneration++; if (this.data) { this.initVOTClient(); } } constructor(video, container, site) { this.video = video; this.container = container; this.site = site; this.abortController = new AbortController(); this.actionsAbortController = new AbortController(); this.cacheManager = new CacheManager(); this.interactionChecker = createIntervalIdleChecker(); this.interactionChecker.start(); const self = () => this; const mount = this.getOverlayMount(container); this.uiManager = new UIManager({ mount, data: this.data, videoHandler: this, intervalIdleChecker: this.interactionChecker }); this.overlayVisibility = new OverlayVisibilityController({ checker: this.interactionChecker, getOverlayView: () => this.uiManager.votOverlayView ?? null, getAutoHideDelay: () => this.getAutoHideDelay(), isInteractiveNode: (node) => this.isOverlayInteractiveNode(node) }); this.translationOrchestrator = new TranslationOrchestrator({ isFirstPlay: () => this.firstPlay, setFirstPlay: (next) => { this.firstPlay = next; }, isAutoTranslateEnabled: () => Boolean(this.data?.autoTranslate), getVideoId: () => this.videoData?.videoId, scheduleAutoTranslate: () => this.runAutoTranslate(), isMobileYouTubeMuted: () => this.site.host === "youtube" && this.site.additionalData === "mobile" && this.video.muted, setMuteWatcher: (callback) => { let done = false; const fireOnce = () => { if (done) return; done = true; this.video.removeEventListener("volumechange", onVolumeChange); callback(); }; const onVolumeChange = () => { if (!this.video.muted) { fireOnce(); } }; this.video.addEventListener("volumechange", onVolumeChange, { signal: this.abortController.signal }); queueMicrotask(() => { if (!this.video.muted) { fireOnce(); } }); } }); const lifecycleHost = { get video() { return self().video; }, get site() { return self().site; }, get container() { return self().container; }, set container(value) { self().container = value; self().uiManager.updateMount(self().getOverlayMount(value)); }, get firstPlay() { return self().firstPlay; }, set firstPlay(value) { self().firstPlay = value; }, stopTranslation: () => this.stopTranslation(), get uiManager() { return self().uiManager; }, getVideoData: () => this.getVideoData(), cacheManager: { getSubtitles: (key) => self().cacheManager.getSubtitles(key) ?? [] }, getSubtitlesCacheKey: (videoId, detectedLanguage, responseLanguage) => this.getSubtitlesCacheKey(videoId, detectedLanguage, responseLanguage), updateSubtitlesLangSelect: () => this.updateSubtitlesLangSelect(), enableSubtitlesForCurrentLangPair: () => this.enableSubtitlesForCurrentLangPair(), setSelectMenuValues: (from, to) => this.setSelectMenuValues(from, to), get translateToLang() { return self().translateToLang; }, set translateToLang(value) { if (isResponseLang(value)) self().translateToLang = value; }, get data() { return self().data ?? {}; }, get subtitles() { return self().subtitles; }, set subtitles(value) { self().subtitles = value; }, get videoData() { return self().videoData; }, set videoData(value) { self().videoData = value; }, get actionsAbortController() { return self().actionsAbortController; }, set actionsAbortController(value) { self().actionsAbortController = value; }, resetActionsAbortController: (reason) => this.resetActionsAbortController(reason), initVOTClient: () => this.initVOTClient(), translationOrchestrator: this.translationOrchestrator, resetSubtitlesWidget: () => this.resetSubtitlesWidget(), queueOverlayAutoHide: () => this.overlayVisibility?.queueAutoHide() }; this.lifecycleController = new VideoLifecycleController(lifecycleHost); this.translationHandler = new VOTTranslationHandler(this); this.videoManager = new VOTVideoManager(this); } getSubtitlesWidget() { if (!this.subtitlesWidget) { const overlayPortal = this.uiManager.votOverlayView?.votOverlayPortal; if (!overlayPortal) { throw new Error( "VOT UI is not initialized yet (missing overlay portal)" ); } this.subtitlesWidget = new SubtitlesWidget( this.video, this.portalContainer, overlayPortal, this.interactionChecker, this.tooltipLayoutRoot ); if (this.data) { this.subtitlesWidget.setSmartLayout( typeof this.data.subtitlesSmartLayout === "boolean" ? this.data.subtitlesSmartLayout : true ); if (typeof this.data.subtitlesMaxLength === "number") { this.subtitlesWidget.setMaxLength(this.data.subtitlesMaxLength); } if (typeof this.data.highlightWords === "boolean") { this.subtitlesWidget.setHighlightWords(this.data.highlightWords); } if (typeof this.data.subtitlesFontSize === "number") { this.subtitlesWidget.setFontSize(this.data.subtitlesFontSize); } if (typeof this.data.subtitlesOpacity === "number") { this.subtitlesWidget.setOpacity(this.data.subtitlesOpacity); } } } return this.subtitlesWidget; } hasSubtitlesWidget() { return Boolean(this.subtitlesWidget); } resetSubtitlesWidget() { if (this.hasSubtitlesWidget()) { this.subtitlesWidget?.release(); this.subtitlesWidget = void 0; } } get uiRoot() { return this.getOverlayMountPoints().root; } get portalContainer() { return this.getOverlayMountPoints().portalContainer; } get tooltipLayoutRoot() { switch (this.site.host) { case "kickstarter": { return document.getElementById("react-project-header") ?? void 0; } case "custom": { return void 0; } default: { return this.container; } } } getEventContainer() { if (!this.site.eventSelector) return this.container; return document.querySelector(this.site.eventSelector) ?? this.container; } async runAutoTranslate() { try { this.videoManager.videoValidator(); await this.uiManager.handleTranslationBtnClick(); } catch (err) { console.error("[VOT]", err); throw err; } } getAudioContext() { if (this.audioContext) return this.audioContext; if (!this.isAudioContextSupported) return void 0; try { this.audioContext = initAudioContext(); return this.audioContext; } catch (err) { console.warn("[VOT] Failed to init AudioContext, falling back:", err); return void 0; } } get isAudioContextSupported() { return globalThis.AudioContext !== void 0 || globalThis.webkitAudioContext !== void 0; } getPreferAudio() { if (!this.getAudioContext()) return true; if (!this.data) return true; if (!this.data.newAudioPlayer) return true; if (this.videoData?.isStream) return true; if (this.data.newAudioPlayer && !this.data.onlyBypassMediaCSP) return false; return !this.site.needBypassCSP; } createPlayer() { const preferAudio = this.getPreferAudio(); this.audioPlayer = new Chaimu({ video: this.video, debug: Boolean(false), fetchFn: GM_fetch, fetchOpts: { timeout: 0 }, preferAudio }); return this; } isLikelyInternalVideoVolumeChange(observedPercent) { if (this.internalVideoVolumeSetPercent === null) return false; const ageMs = Date.now() - this.internalVideoVolumeSetAt; if (ageMs > this.internalVideoVolumeSuppressionMs) return false; return Math.abs(observedPercent - this.internalVideoVolumeSetPercent) <= 1; } callModule(impl, ...args) { return impl.call(this, ...args); } async callModuleAsync(impl, ...args) { return await impl.call(this, ...args); } async init() { return await init.call(this); } initVOTClient() { this.votOpts = { fetchFn: GM_fetch, fetchOpts: { signal: this.actionsAbortController.signal }, apiToken: this.data?.account?.token, hostVOT: votBackendUrl, host: this.data?.translateProxyEnabled ? this.data?.proxyWorkerHost ?? proxyWorkerHost : workerHost }; this.votClient = new (this.data?.translateProxyEnabled ? VOTWorkerClient2 : VOTClient2)(this.votOpts); return this; } transformBtn(status, text) { this.uiManager.transformBtn(status, text); return this; } hasActiveSource() { return !!this.audioPlayer?.player?.src; } initExtraEvents() { return this.callModule(initExtraEvents); } rebindOverlayVisibilityTargets = rebindOverlayVisibilityTargets; setCanPlay() { return this.lifecycleController.setCanPlay(); } isOverlayInteractiveNode(node) { return this.callModule(isOverlayInteractiveNode, node); } getAutoHideDelay() { return this.callModule(getAutoHideDelay); } changeSubtitlesLang = changeSubtitlesLang; updateSubtitlesLangSelect = updateSubtitlesLangSelect; loadSubtitles = loadSubtitles; async enableSubtitlesForCurrentLangPair() { return await this.callModuleAsync(enableSubtitlesForCurrentLangPair); } async toggleSubtitlesForCurrentLangPair() { return await this.callModuleAsync(toggleSubtitlesForCurrentLangPair); } getRequestLangForTranslation(requestLang, responseLang) { if (this.data?.useLivelyVoice && this.data?.account?.token && responseLang === "ru") { return "en"; } return requestLang; } isLivelyVoiceAllowed(requestLang = this.videoData?.detectedLanguage ?? "auto", responseLang = this.videoData?.responseLanguage ?? this.translateToLang) { const requestLangForApi = this.getRequestLangForTranslation( requestLang, responseLang ); if (requestLangForApi !== "en" || responseLang !== "ru") { return false; } if (!this.data?.account?.token) { return false; } return true; } getVideoVolume() { return this.videoManager.getVideoVolume(); } setVideoVolume(volume) { const snapped = snapVolume01(volume); this.internalVideoVolumeSetAt = Date.now(); this.internalVideoVolumeSetPercent = volume01ToPercent(snapped); this.videoManager.setVideoVolume(snapped); return this; } onVideoVolumeSliderSynced(volumePercent) { const normalized = clampPercentInt(volumePercent); if (!this.volumeLinkState.initialized) { this.volumeLinkState.lastVideoPercent = normalized; return; } if (this.data?.syncVolume && this.hasActiveSource() && !this.isLikelyInternalVideoVolumeChange(normalized)) { return; } this.volumeLinkState.lastVideoPercent = normalized; } isMuted() { return this.videoManager.isMuted(); } syncVideoVolumeSlider() { this.videoManager.syncVideoVolumeSlider(); } setSelectMenuValues(from, to) { this.videoManager.setSelectMenuValues( from, to ); } syncVolumeWrapper(fromType, newVolume) { const overlayView = this.uiManager.votOverlayView; if (!overlayView?.isInitialized()) { return; } const videoSlider = overlayView.videoVolumeSlider; const translationSlider = overlayView.translationVolumeSlider; if (!videoSlider || !translationSlider) { return; } 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 delta2 = newVolume - prevVideo; this.volumeLinkState.lastVideoPercent = newVolume; if (!Number.isFinite(delta2) || delta2 === 0) { return; } const currentTranslation = Number(translationSlider.value); const nextTranslation = clampInt( currentTranslation + delta2, translationSlider.min, translationSlider.max ); translationSlider.value = nextTranslation; this.volumeLinkState.lastTranslationPercent = nextTranslation; if (this.audioPlayer?.player) { this.audioPlayer.player.volume = nextTranslation / 100; } return; } const prevTranslation = this.volumeLinkState.lastTranslationPercent; const delta = newVolume - prevTranslation; this.volumeLinkState.lastTranslationPercent = newVolume; if (!Number.isFinite(delta) || delta === 0) { return; } const currentVideo = Number(videoSlider.value); const nextVideo = clampPercentInt(currentVideo + delta); videoSlider.value = nextVideo; this.volumeLinkState.lastVideoPercent = nextVideo; this.setVideoVolume(nextVideo / 100); } async getVideoData() { return await this.videoManager.getVideoData(); } videoValidator() { return this.videoManager.videoValidator(); } stopTranslate() { if (this.audioPlayer?.player) { try { this.audioPlayer.player.removeVideoEvents(); this.audioPlayer.player.clear(); this.audioPlayer.player.src = ""; } catch (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}`); const restoreVolume = typeof this.smartVolumeDuckingBaseline === "number" ? this.smartVolumeDuckingBaseline : this.volumeOnStart; stopSmartVolumeDucking(this, { restoreVolume }); this.volumeOnStart = void 0; if (this.autoRetry !== void 0) { clearTimeout(this.autoRetry); this.autoRetry = void 0; } if (this.translationRefreshTimeout !== void 0) { clearTimeout(this.translationRefreshTimeout); this.translationRefreshTimeout = void 0; } this.resetActionsAbortController("stopTranslate"); } async updateTranslationErrorMsg(errorMessage, signal) { if (signal?.aborted) { return; } const translationTake2 = localizationProvider.get("translationTake"); const lang2 = localizationProvider.lang; this.longWaitingResCount = errorMessage === localizationProvider.get("translationTakeAboutMinute") ? this.longWaitingResCount + 1 : 0; debug.log("longWaitingResCount", this.longWaitingResCount); if (this.longWaitingResCount > minLongWaitingCount) { errorMessage = new VOTLocalizedError("TranslationDelayed"); } if (errorMessage?.name === "VOTLocalizedError") { this.transformBtn("error", errorMessage.localizedMessage); } else if (errorMessage instanceof Error) { this.transformBtn("error", errorMessage?.message); } else if (this.data?.translateAPIErrors && lang2 !== "ru" && !errorMessage?.includes(translationTake2)) { const overlayView = this.uiManager.votOverlayView; if (!overlayView?.votButton) { return; } const messageStr = Array.isArray(errorMessage) ? errorMessage.join(" ") : String(errorMessage); const cacheKey = `${lang2}:${messageStr}`; const cached = this.errorTranslationCache.get(cacheKey); if (cached) { this.transformBtn("error", cached); } else { overlayView.votButton.loading = true; const translatedMessage = await translate(messageStr, "ru", lang2); const translatedText = Array.isArray(translatedMessage) ? translatedMessage.join("\n") : String(translatedMessage); if (signal?.aborted) { return; } this.errorTranslationCache.set(cacheKey, translatedText); if (this.errorTranslationCache.size > 50) { const oldestKey = this.errorTranslationCache.keys().next().value; if (oldestKey) this.errorTranslationCache.delete(oldestKey); } this.transformBtn("error", translatedText); } if (signal?.aborted) { return; } } else { const msg = Array.isArray(errorMessage) ? errorMessage.join("\n") : String(errorMessage ?? ""); this.transformBtn("error", msg); } if (signal?.aborted) { return; } if ([ "Подготавливаем перевод", "Видео передано в обработку", "Ожидаем перевод видео", "Загружаем переведенное аудио" ].includes(errorMessage)) { if (this.uiManager.votOverlayView?.votButton) { this.uiManager.votOverlayView.votButton.loading = true; } } } afterUpdateTranslation(audioUrl) { const overlayView = this.uiManager.votOverlayView; if (!overlayView?.votButton) { return; } const isSuccess = overlayView.votButton.container.dataset.status === "success"; if (overlayView.videoVolumeSlider) { overlayView.videoVolumeSlider.hidden = !this.data?.showVideoSlider || !isSuccess; } if (overlayView.translationVolumeSlider) { overlayView.translationVolumeSlider.hidden = !isSuccess; } if (overlayView.videoVolumeSlider && overlayView.translationVolumeSlider) { this.volumeLinkState.lastVideoPercent = Number( overlayView.videoVolumeSlider.value ); this.volumeLinkState.lastTranslationPercent = Number( overlayView.translationVolumeSlider.value ); this.volumeLinkState.initialized = true; } else { this.volumeLinkState.initialized = false; } if (this.videoData && !this.videoData.isStream) { if (overlayView.downloadTranslationButton) { overlayView.downloadTranslationButton.hidden = false; } this.downloadTranslationUrl = audioUrl; } debug.log( "afterUpdateTranslation downloadTranslationUrl", this.downloadTranslationUrl ); if (this.data?.sendNotifyOnComplete && this.hadAsyncWait && isSuccess) { this.notifier.translationCompleted(globalThis.location.hostname); this.hadAsyncWait = false; } } async validateAudioUrl(audioUrl, actionContext) { return await this.callModuleAsync( validateAudioUrl, audioUrl, actionContext ); } scheduleTranslationRefresh() { this.callModule(scheduleTranslationRefresh); } refreshTranslationAudio = refreshTranslationAudio; proxifyAudio(audioUrl) { return this.callModule(proxifyAudio, audioUrl); } unproxifyAudio(audioUrl) { return this.callModule(unproxifyAudio, audioUrl); } handleProxySettingsChanged = handleProxySettingsChanged; isMultiMethodS3(url) { return this.callModule(isMultiMethodS3, url); } updateTranslation = updateTranslation; async translateFunc(VIDEO_ID, isStream, requestLang, responseLang, translationHelp) { return await translateFunc.call( this, VIDEO_ID, isStream, requestLang, responseLang, translationHelp ); } isYouTubeHosts() { return this.callModule(isYouTubeHosts); } setupAudioSettings() { return this.callModule(setupAudioSettings); } stopTranslation = () => { this.translationOrchestrator?.reset(); this.overlayVisibility?.cancel(); this.stopTranslate(); this.syncVideoVolumeSlider(); }; handleSrcChanged() { return this.lifecycleController.handleSrcChanged(); } async release() { this.initialized = false; try { this.stopTranslation(); } catch (err) { } this.lifecycleController?.teardown(); this.abortController?.abort(); this.abortController = new AbortController(); this.overlayVisibility?.release(); this.releaseExtraEvents(); if (this.hasSubtitlesWidget()) { this.subtitlesWidget?.release(); this.subtitlesWidget = void 0; } this.interactionChecker?.destroy(); this.uiManager.release(); } collectReportInfo() { const info2 = getEnvironmentInfo(); const detectedLanguage = this.videoData?.detectedLanguage ?? "unknown"; const responseLanguage = this.videoData?.responseLanguage ?? "unknown"; const additionalInfo = `
Autogenerated by VOT:
  • OS: ${info2.os}
  • Browser: ${info2.browser}
  • Loader: ${info2.loader}
  • Script version: ${info2.scriptVersion}
  • URL: ${info2.url}
  • Lang: ${detectedLanguage} -> ${responseLanguage} (Lively voice: ${this.data?.useLivelyVoice ?? false} | Audio download: ${this.data?.useAudioDownload ?? false})
  • Player: ${this.data?.newAudioPlayer ? "New" : "Old"} (CSP only: ${this.data?.onlyBypassMediaCSP ?? false})
  • Proxying mode: ${this.data?.translateProxyEnabled ?? 0}
`; const template = `1-bug-report-${localizationProvider.lang === "ru" ? "ru" : "en"}.yml`; return { assignees: "ilyhalight", template, os: info2.os, "script-version": info2.scriptVersion, "additional-info": additionalInfo }; } releaseExtraEvents = releaseExtraEvents; } const videoObserverChecker = createIntervalIdleChecker(); const videoObserver = new VideoObserver(videoObserverChecker); const videosWrappers = new WeakMap(); let servicesCache = null; const bootState = getOrCreateBootState(); function getFrameContext() { return { frame: isIframe() ? "iframe" : "top", host: globalThis.location.hostname || "unknown", path: globalThis.location.pathname || "/" }; } function logBootstrap(message, details) { const ctx = getFrameContext(); const payload = { host: ctx.host, path: ctx.path }; if (details) { Object.assign(payload, details); } console.log(`[VOT][bootstrap][${ctx.frame}] ${message}`, payload); } function getServicesCached() { if (!servicesCache) { servicesCache = getService(); } return servicesCache; } function findContainer(site, video) { if (!site.selector) { return video.parentElement; } const matched = findConnectedContainerBySelector(video, site.selector); if (site.shadowRoot) ; if (matched) { return matched; } return null; } async function main() { const bootstrapMode = resolveBootstrapMode({ isIframe: isIframe(), href: String(globalThis.location.href || ""), origin: globalThis.location.origin, hash: globalThis.location.hash, iframeHash: IFRAME_HASH }); if (bootstrapMode === "iframe-helper") { logBootstrap("Starting iframe helper runtime"); return initAudioDownloaderIframe(); } if (bootstrapMode === "skip") { logBootstrap("Skipping bootstrap for non-runnable iframe"); return; } logBootstrap("Loading extension"); if (bootstrapMode === "top-full") { await ensureRuntimeActivated("top-frame", logBootstrap); } else { logBootstrap("Lazy iframe bootstrap enabled; waiting for video detection"); } bindObserverListeners({ videoObserver, videosWrappers, ensureRuntimeActivated: async (reason) => await ensureRuntimeActivated(reason, logBootstrap), getServicesCached, findContainer, createVideoHandler: (video, container, site) => new VideoHandler(video, container, site) }); videoObserver.enable(); } if (bootState.status === "booting" || bootState.status === "booted") { logBootstrap("bootstrap already initialized, skipping duplicate run", { status: bootState.status }); } else { const runBootstrap = async () => { try { await main(); bootState.status = "booted"; } catch (e2) { bootState.status = "failed"; bootState.error = e2; console.error("[VOT]", e2); } }; bootState.status = "booting"; bootState.promise = runBootstrap(); } }) }; })); System.register("./__vite-browser-external-2Ng8QIWW-Xya9USxv.js", [], (function (exports, module) { 'use strict'; return { execute: (function () { const __viteBrowserExternal = exports("default", {}); }) }; })); System.import("./__entry.js", "./");