SubFetch for YouTube por dentro: el PoToken, el mundo MAIN y una SPA que no avisa cuando cambia de vídeo
Cómo funciona una extensión de Chrome que descarga en .srt los subtítulos de YouTube y proyecta los tuyos sobre el reproductor. Respuestas 200 vacías por culpa del PoToken, dejar que el reproductor pida la pista y escuchar la respuesta desde el mundo MAIN, todo lo que se rompe cuando YouTube cambia de vídeo sin recargar la página y un overlay de subtítulos sincronizado fotograma a fotograma.
SubFetch for YouTube, que empezó llamándose YouTube Subtitles, descarga en .srt los subtítulos de un vídeo de YouTube, sean los que subió el autor, los automáticos o una traducción, desde un botón que se coloca junto a Me gusta y Compartir. También hace lo contrario: cargas un .srt o un .vtt tuyo y lo pinta sobre el vídeo, con el tamaño, la tipografía y los colores que necesites. Este proyecto no tiene artículo para usuarios, porque de momento no hay nada que instalar: la extensión no está publicada. Este artículo cuenta cómo está hecha y, sobre todo, lo que fue saliendo mal.
La idea parecía de una tarde. YouTube mete en cada página de vídeo un objeto con la lista de pistas y una URL para cada una. Pides esa URL, conviertes el JSON a SRT y lo descargas. Esa versión funcionó en un día. Después YouTube empezó a devolver respuestas vacías, la arreglé con una arquitectura que no se parecía en nada a la primera, y cuando por fin descargaba bien, descubrí que solo lo hacía si recargabas la pestaña. Hacer que funcionara navegando por YouTube como lo hace todo el mundo, de vídeo en vídeo sin recargar, fue la parte más larga del proyecto.
Por qué aún no está publicada
YouTube solo entrega los subtítulos a peticiones que llevan un token de atestación, el PoToken, que genera el JavaScript del propio reproductor. La extensión no lo falsifica ni lo calcula. Lo que hace es pedirle al reproductor que cargue una pista, como si hubieras pulsado tú el botón CC, y quedarse con la respuesta. Es lo menos agresivo que se puede hacer, y aun así es automatizar el reproductor, que es una zona en la que conviene ir con cuidado.
Si algún día la publico, lo primero era el nombre. Las normas de la Chrome Web Store no dejan que el nombre de una extensión empiece por "YouTube" ni que sugiera un producto oficial de Google. La fórmula aceptada es un nombre propio seguido de "for YouTube™", así que YouTube Subtitles pasó a ser SubFetch for YouTube™, con iconos nuevos sin el logotipo de YouTube y la ficha de la tienda escrita en ocho idiomas.
Lo que más me frena es mantenerla. Todo lo que hace depende de cosas que YouTube no documenta ni promete mantener: métodos del reproductor como setOption('captions', …) y getPlayerResponse(), eventos internos como yt-navigate-finish e ids del DOM como #top-level-buttons-computed. Ya se rompió una vez sin aviso, y volverá a pasar. Para uso propio lo arreglo cuando lo noto. Publicada, cada cambio de YouTube la dejaría rota para todos hasta que saliera una versión nueva y la tienda la aprobara.
Por ahora es un proyecto que uso yo y que me sirvió para aprender. El código del artículo son fragmentos para explicar cada problema. No hay repositorio público ni versión empaquetada.
La forma de la extensión
Es una extensión Manifest V3 hecha con WXT, la interfaz en Vue 3 con Tailwind CSS 4, todo en TypeScript, con vue-i18n para la interfaz (empezó en español e inglés y ahora va en ocho idiomas) y Vitest para los tests. Al final quedaron cinco piezas:
- El popup, la primera interfaz. Tiene dos pestañas: Subtítulos lista las pistas del vídeo de la pestaña activa y las descarga, y Accesibilidad carga subtítulos propios y ajusta cómo se ven.
- El botón en la página, un componente Vue montado dentro de un Shadow DOM en la barra de acciones del vídeo, con su propio menú.
- El overlay de subtítulos, otro componente Vue en otro Shadow DOM, este dentro del propio reproductor.
- El content script (
content.ts), en el mundo aislado de la extensión. Monta el botón y el overlay, atiende al popup y hace de puente. - El interceptor (
player-interceptor.content.ts), en el mundo de la propia página. Habla con el reproductor y escucha su tráfico de red.
No hay service worker. El popup habla directamente con la pestaña, y la pestaña tiene todo lo que hace falta.
La lógica está fuera de los componentes, en utils/: youtube.ts lee y parsea la respuesta del reproductor, srtFormatter.ts convierte JSON3 a SRT, download.ts construye el nombre del fichero y lo descarga, translation.ts arma la URL traducida, interceptor.ts envuelve la red y maneja el reproductor, e interceptorTypes.ts tiene los contratos de los mensajes. Los subtítulos propios tienen su carpeta, utils/subtitles/, con el parser y la sincronización. Casi todos los tests van contra esos módulos.
La primera versión: leer lo que YouTube ya ha puesto en la página
Cada página /watch de YouTube lleva incrustado un objeto enorme, ytInitialPlayerResponse, con todo lo que el reproductor necesita para arrancar. Dentro, en captions.playerCaptionsTracklistRenderer, están las pistas de subtítulos (captionTracks) y el catálogo de idiomas a los que YouTube sabe traducir (translationLanguages). Cada pista trae su idioma, un nombre visible, si es traducible, si es automática (kind: 'asr') y una baseUrl firmada que apunta a /api/timedtext.
El content script lo busca primero en la variable global y, si no está, en el texto de los <script> de la página:
const PLAYER_RESPONSE_RE = /var ytInitialPlayerResponse\s*=\s*(\{.+?\});/;
export function readPlayerResponseFromDom(win: Window = window): RawPlayerResponse | null {
const globalResponse = (win as unknown as { ytInitialPlayerResponse?: RawPlayerResponse })
.ytInitialPlayerResponse;
if (globalResponse) return globalResponse;
// Scan only <script> nodes: serializing the whole document (innerHTML) on a
// loaded YouTube page allocates several MB and breaks NFR-1.
for (const script of Array.from(win.document.scripts)) {
const text = script.textContent;
if (!text) continue;
const parsed = parsePlayerResponseFromText(text);
if (parsed) return parsed;
}
return null;
}
La primera versión hacía el regex sobre document.documentElement.innerHTML. Funcionaba, pero serializar el DOM entero de una página de YouTube ya cargada genera varios megas de texto cada vez que abres el popup, y uno de los requisitos que me puse era no notarse en la reproducción. Recorrer solo los <script> da el mismo resultado sin serializar nada.
El regex tiene un riesgo que dejé apuntado en la documentación y nunca llegué a ver: el .+? no es codicioso, así que corta en el primer }; que encuentra. Si algún texto dentro del JSON contuviera esos dos caracteres seguidos, el JSON quedaría cortado y JSON.parse fallaría. En ese caso el resultado es un error controlado, no unos datos corruptos, y por eso lo dejé así.
parsePlayerResponse traduce ese objeto a algo manejable y decide los errores. Si playabilityStatus.status no es OK (vídeo privado, de pago, con restricción de edad), devuelve UNPLAYABLE con el motivo que da YouTube, que se enseña tal cual porque viene ya en el idioma del usuario. Sin pistas, NO_CAPTIONS. Los errores son claves de i18n, no texto, para que el popup los traduzca.
Del JSON3 al SRT
Pidiendo la baseUrl con fmt=json3, YouTube devuelve una lista de eventos con un inicio, una duración y trozos de texto:
export function convertJson3ToSrt(data: YoutubeJson3): string {
// Real timedtext responses can be `{}` or otherwise malformed (e.g. an
// expired signed baseUrl), so `events` is not guaranteed at runtime.
if (!Array.isArray(data?.events)) return '';
let srt = '';
let index = 1;
for (const event of data.events) {
if (!event.segs || !event.tStartMs && event.tStartMs !== 0) continue;
const text = event.segs.map((s) => s.utf8).join('').trim();
if (!text || text === '\n') continue;
const startTime = formatMsToSRT(event.tStartMs);
const endTime = formatMsToSRT(event.tStartMs + (event.dDurationMs || 2000));
srt += `${index}\n${startTime} --> ${endTime}\n${text}\n\n`;
index++;
}
return srt;
}
La especificación que escribí al empezar traía esta función casi entera, con un fallo: comprobaba !event.tStartMs, y como 0 es falso, el subtítulo que empieza en el milisegundo cero desaparecía. Lo corregí ya en la implementación. Hay eventos sin segs (YouTube los usa para marcar ventanas y saltos de línea) y eventos que solo traen un \n, que también se saltan. La numeración solo avanza con los que se escriben, porque un SRT con huecos en la numeración lo rechazan algunos reproductores.
La comprobación de events llegó después, con respuestas reales. Una baseUrl caducada, o cualquier otro fallo, puede devolver {} en lugar de un error. Con eso la función devuelve una cadena vacía, y quien la llama lo trata como un error de red.
Mensajes entre el popup y la pestaña
El popup pregunta al content script con browser.tabs.sendMessage, y al principio el listener devolvía una promesa:
browser.runtime.onMessage.addListener(async (message) => {
if (message?.type === 'GET_CAPTION_TRACKS') return getCaptionData();
});
En Firefox eso funciona porque su browser está basado en promesas. Pero el browser global de WXT no es el webextension-polyfill, como yo creía y como llegué a poner en la documentación. Es un alias fino de chrome. Y Chrome, hasta hace muy poco, ignoraba la promesa devuelta por un listener: el popup se quedaba esperando una respuesta que nunca llegaba. Lo fiable es devolver true para dejar el canal abierto y contestar con sendResponse:
browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === 'GET_CAPTION_TRACKS') {
getCaptionData().then(sendResponse);
return true;
}
// ...
});
En el mismo cambio salió otro caso. Una pestaña de YouTube que ya estaba abierta antes de instalar la extensión no tiene el content script, porque Chrome solo lo inyecta al cargar la página. tabs.sendMessage falla con "Receiving end does not exist", y el popup se quedaba en "Cargando..." para siempre. Ahora la carga está envuelta, el error se ve y la lista no se borra cuando falla una descarga, para que puedas probar otra pista.
El muro: 200 OK y el cuerpo vacío
Con todo eso la extensión funcionó un día. Al volver a ella semanas después, cada descarga terminaba en "No se pudo conectar con YouTube". En la pestaña de red, la petición a timedtext respondía 200 OK, sin error, sin redirección y con cero bytes.
Mi primera hipótesis fue el origen. El popup hace la petición desde chrome-extension://…, y YouTube podía estar rechazando cualquier cosa que no saliera de youtube.com. Así que moví el fetch al content script, que corre en la página y tiene su origen. Arquitectónicamente era más correcto, y lo dejé. Pero la respuesta siguió llegando vacía.
Lo que dio con la causa fue comparar. Activé los subtítulos en el reproductor, busqué la petición a timedtext que hacía YouTube y la puse al lado de la baseUrl que yo sacaba de ytInitialPlayerResponse. Eran casi iguales, salvo por dos parámetros que la del reproductor tenía y la mía no: potc=1 y pot=<una cadena larga>.
pot es el PoToken (Proof of Origin Token). Lo genera BotGuard, el sistema antibots de Google, que se ejecuta dentro del JavaScript del reproductor. Es una prueba de que la petición sale de un reproductor real en un navegador real. YouTube empezó a exigirlo para servir subtítulos, y lo hizo de la forma más difícil de diagnosticar: sin un 403, sin un mensaje de error, con un 200 vacío. Desde el exterior, una URL sin token se comporta como un vídeo sin subtítulos.
Lo apunté todo en el ROADMAP del proyecto y lo dejé ahí. Pasaron seis semanas entre ese commit y el siguiente.
Las dos salidas
Había dos maneras de conseguir una respuesta con token:
- Generar el token yo. Ejecutar el reto de BotGuard fuera del reproductor, que es lo que hacen algunas herramientas de descarga. Es frágil (Google cambia el reto cuando quiere) y, sobre todo, es exactamente saltarse la protección: replicar fuera del reproductor lo que está diseñado para que solo pueda hacer el reproductor.
- Dejar que lo genere el reproductor. El reproductor ya sabe pedir subtítulos con su token cuando el usuario pulsa CC. Si consigo que pida la pista que quiero y escucho la respuesta, no tengo que saber nada de BotGuard.
Elegí la segunda. Para hacerla hacía falta algo que el content script normal no puede hacer.
El mundo MAIN
Un content script corre en un mundo aislado: ve el mismo DOM que la página, pero tiene su propio window y su propio JavaScript. Si el content script envuelve window.fetch, envuelve el suyo, no el que usa YouTube. Y el elemento #movie_player que ve es un elemento del DOM sin los métodos que el JavaScript de YouTube le ha añadido, así que setOption no existe.
Manifest V3 permite inyectar un content script en el mundo de la página, MAIN. Con WXT es una opción más:
export default defineContentScript({
matches: ['*://*.youtube.com/*'],
world: 'MAIN',
runAt: 'document_start',
main() {
// ...
},
});
document_start es imprescindible. El script tiene que envolver fetch antes de que el código de YouTube se guarde una referencia al original. Si llega tarde, YouTube seguirá llamando al fetch de siempre y el envoltorio no verá nada.
El precio del mundo MAIN es que ahí no hay APIs de extensión: ni browser.runtime ni almacenamiento ni descargas. Es código de la página a todos los efectos. Por eso hacen falta dos scripts: el de MAIN habla con el reproductor y el aislado habla con la extensión.
Escuchar sin molestar
El envoltorio de fetch deja pasar sin tocar todo lo que no le interesa, y de lo que le interesa lee una copia:
const wrappedFetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
let url = '';
// ... sacar la URL de un string, una URL o un Request
if (!url || (!url.includes('/api/timedtext') && !url.includes('/youtubei/v1/player'))) {
return init !== undefined
? Reflect.apply(originalFetch, window, [input, init])
: Reflect.apply(originalFetch, window, [input]);
}
const response = init !== undefined
? await Reflect.apply(originalFetch, window, [input, init])
: await Reflect.apply(originalFetch, window, [input]);
if (url.includes('/api/timedtext')) {
const text = await response.clone().text();
if (text) {
const data = JSON.parse(text);
for (const handler of fetchHandlers) handler(data, url);
}
}
return response;
};
Tres detalles de esta función vienen de fallos concretos:
response.clone(). El cuerpo de una respuesta solo se puede leer una vez. Si lo leo yo, YouTube recibe una respuesta ya consumida y el reproductor se rompe. La copia se lee y el original sigue intacto.Reflect.apply(originalFetch, window, …). Llamar aoriginalFetch(input)a secas pierde elthis, yfetchlanza "Illegal invocation" si no se llama sobrewindow. Pasarinitsolo cuando existe evita cambiar la aridad de llamadas que YouTube hace con un solo argumento.- Una marca para no envolver dos veces. El envoltorio lleva un
Symboly, siwindow.fetchya lo tiene, no se vuelve a envolver. Sin eso, si el script se ejecuta dos veces en la misma página, los envoltorios se apilan y cada respuesta se procesa varias veces.
El reproductor no usa fetch
Con el interceptor de fetch montado, pedía una pista, el reproductor la cargaba (los subtítulos aparecían en el vídeo) y el interceptor no veía nada. La petición estaba en la pestaña de red, con su pot, pero no pasaba por fetch. El reproductor de YouTube pide los subtítulos con XMLHttpRequest.
Así que hay un segundo interceptor, sobre el prototipo de XMLHttpRequest. open apunta la URL en la propia instancia y send engancha un listener si es una de subtítulos:
XHR.prototype.open = function (this: any, method: string, url: string | URL, ...rest: any[]) {
this.__yt_sub_url = typeof url === 'string' ? url : url?.toString?.() || '';
return Reflect.apply(originalOpen, this, [method, url, ...rest]);
};
XHR.prototype.send = function (this: any, body?: any) {
const url = this.__yt_sub_url;
if (url && url.includes('/api/timedtext')) {
let settled = false;
const handleResponse = () => {
if (settled || this.readyState !== 4) return;
settled = true;
// leer response según responseType y avisar a los handlers
};
this.addEventListener('readystatechange', handleResponse, false);
this.addEventListener('load', handleResponse, false);
}
return Reflect.apply(originalSend, this, [body]);
};
Escucho readystatechange y load a la vez para no depender de cuál de los dos atiende el reproductor, y el settled evita procesar la misma respuesta dos veces. La lectura cubre los tres responseType posibles: json (ya viene parseado), texto (responseText) y cualquier otro, con un intento de JSON.parse. La respuesta del XHR se lee sin consumir nada, así que aquí no hace falta clonar.
Me quedé con los dos interceptores. Nada garantiza que el reproductor no vuelva a usar fetch en una versión futura, y el de fetch lo necesito igualmente para otra cosa que cuento más abajo.
Pedirle la pista al reproductor
triggerPlayerFetch recibe el reproductor y la pista que quiero y devuelve una promesa que se resuelve cuando el interceptor ve llegar la respuesta. Antes de tocar nada guarda el estado:
const previousTrack = player.getOption?.('captions', 'track') || {};
const previousTranslation = player.getOption?.('captions', 'translationLanguage') ?? null;
const wasSubtitlesOn = isSubtitlesActive();
Después carga el módulo de subtítulos, elige la pista y enciende los subtítulos:
player.loadModule?.('captions');
// If the target track is already the active track, toggle track to force a fresh fetch
if (previousTrack?.languageCode === targetTrack.languageCode && !targetTrack.tlang) {
player.setOption('captions', 'track', {});
}
const trackOption: Record<string, any> = { languageCode: targetTrack.languageCode };
if (targetTrack.kind) trackOption.kind = targetTrack.kind;
player.setOption('captions', 'track', trackOption);
if (targetTrack.tlang) {
player.setOption('captions', 'translationLanguage', { languageCode: targetTrack.tlang });
}
turnSubtitlesOn();
Cada línea de este bloque existe por un caso que fallaba:
- Si la pista ya está activa, el reproductor no la vuelve a pedir. La tiene en memoria. Ponerle una pista vacía y luego la buena obliga a una petición nueva que el interceptor pueda ver.
kinddistingue pistas del mismo idioma. Un vídeo puede tener inglés subido por el autor e inglés automático. Con sololanguageCode, el reproductor elige el que quiere. Conkind: 'asr'elige el automático. Este fallo lo arreglé en el mismo commit que el XHR, porque hasta que el interceptor no veía respuestas no me di cuenta de que a veces bajaba la pista equivocada.- La traducción es una opción aparte. Se fija la pista original y encima el idioma de traducción. El reproductor añade el
tlanga su propia petición, con su token. - Encender los subtítulos. Con los subtítulos apagados,
setOptioncambia la pista pero el reproductor no la descarga hasta que hace falta enseñarla.turnSubtitlesOnprueba tres caminos por orden:toggleSubtitlesOn()si existe,toggleSubtitles()si están apagados, y como último recurso un clic en.ytp-subtitles-buttonsi suaria-pressedesfalse. Ninguno de los tres está documentado, así que me quedé con los tres.
Cuando llega la respuesta, o a los 6 segundos si no llega, se deshace todo:
promise.resolveWith = (data: any) => {
if (isSettled) return;
isSettled = true;
if (timer) clearTimeout(timer);
if (!wasSubtitlesOn) turnSubtitlesOff();
player.setOption?.('captions', 'track', previousTrack);
player.setOption?.('captions', 'translationLanguage', previousTranslation);
resolvePromise({ ok: true, data });
};
En la primera versión solo se restauraba la pista, y una revisión del código encontró dos huecos. El primero: si descargabas una traducción, el reproductor se quedaba traduciendo el vídeo que estabas viendo, porque la traducción no se restauraba. El segundo, más sutil: la respuesta y el timeout podían llegar casi a la vez. Sin isSettled, los dos restauraban el estado y el segundo podía hacerlo sobre un estado que ya no era el de antes. Ahora el primero que llega gana y el otro no hace nada.
Hay un efecto que no se puede evitar con este método: si tenías los subtítulos apagados, durante un instante se encienden con la pista pedida y se vuelven a apagar. Es el reproductor cargando lo que le he pedido.
Lo que el interceptor ya ha visto
El interceptor no solo escucha cuando hay una descarga pendiente. Cada respuesta de subtítulos que pasa se guarda en cachedTrack, con su idioma y el id del vídeo. Si tienes los subtítulos en español puestos mientras ves el vídeo y descargas el español, no hace falta tocar el reproductor: la respuesta, con token, ya está en memoria y se devuelve directamente.
Las traducciones no se cachean. Las respuestas con tlang se dejan fuera, porque cachedTrack solo guarda el idioma de origen y una traducción al francés del inglés acabaría servida como si fuera el inglés.
Solo hay una descarga en curso a la vez. Si llega una segunda mientras la primera está esperando, se contesta con error en el acto. Con dos peticiones a la vez, las dos cambiarían la pista del reproductor, las dos guardarían como "estado previo" lo que dejó la otra y la respuesta de una podría resolver la promesa de la otra. Una cola habría sido más amable, pero en la interfaz solo se puede pulsar un botón a la vez y el caso no merecía la complejidad.
Un puente entre dos mundos
El script de MAIN no puede hablar con la extensión y el aislado no puede hablar con el reproductor. Lo que comparten es el window de la página como canal de mensajes, a través de window.postMessage:
Cualquier script de la página, y cualquier otra extensión con un script en MAIN, ve estos mensajes. Por eso cada uno lleva un source (yt-sub-fetch-cs en un sentido y yt-sub-fetch-main en el otro), cada receptor descarta lo que no viene de window o no trae su source, y cada petición tiene un requestId generado con crypto.randomUUID(). El content script solo acepta la respuesta que tiene su id:
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data?.source !== 'yt-sub-fetch-main') return;
const res = event.data as SubtitleFetchResponseMessage;
if (res.type !== 'SUBTITLE_FETCH_RESPONSE' || res.requestId !== requestId) return;
window.removeEventListener('message', onMessage);
if (timeoutId) clearTimeout(timeoutId);
// ...
};
Esto no es seguridad de verdad, y conviene no venderlo como tal. El source es un texto que cualquiera puede escribir, y un script de la página podría mandar una respuesta falsa. Lo que evita es que los mensajes de otro se confundan con los míos. Para esta extensión el peor caso de una respuesta falsa es un .srt con texto inventado, y lo acepté.
El requestId sí importa. Sin él, una respuesta tardía de una descarga que ya había dado timeout resolvería la siguiente descarga con los subtítulos de la pista anterior.
Los mensajes están tipados en interceptorTypes.ts, y los dos lados importan los mismos tipos:
export interface TriggerSubtitleFetchMessage {
source: 'yt-sub-fetch-cs';
type: 'TRIGGER_SUBTITLE_FETCH';
requestId: string;
track: SubtitleTrackTarget;
}
export interface SubtitleFetchResponseMessage {
source: 'yt-sub-fetch-main';
type: 'SUBTITLE_FETCH_RESPONSE';
requestId: string;
ok: boolean;
data?: any;
error?: { code: 'NETWORK_ERROR' | 'PLAYER_UNAVAILABLE' };
}
El popup no supo nada de este cambio. Sigue mandando FETCH_SUBTITLE con la baseUrl y recibe un SRT. El content script saca de la URL el lang, el kind y el tlang que necesita el reproductor y lo convierte en una SubtitleTrackTarget. La baseUrl ya no se pide nunca: solo sirve para saber qué pista quieres.
Con esto la extensión volvió a descargar. Al menos al abrir un vídeo directamente o al recargar la página.
La parte larga: una SPA que no avisa
YouTube es una aplicación de una sola página. Cuando haces clic en un vídeo desde la portada, en una recomendación o en el siguiente de una lista, la página no se recarga: el router de YouTube cambia la URL con history.pushState, pide los datos del nuevo vídeo por su API y reutiliza casi todo el DOM. Cargar un vídeo recargando la pestaña y llegar a él navegando son, para una extensión, dos situaciones completamente distintas.
Al recargar, todo empieza de cero. Chrome inyecta los content scripts, ytInitialPlayerResponse corresponde al vídeo que ves, el DOM es nuevo y el botón se monta en una barra recién creada. Por eso todo funcionaba al pulsar F5. Navegando, cada una de esas cosas se rompe por separado. Los voy contando en el orden en que los encontré.
Estado 1: el content script que nunca llega
El primer síntoma: entrabas a YouTube por la portada, hacías clic en un vídeo y el popup decía que no había ningún vídeo activo. O que no se podía conectar.
Los dos content scripts declaraban matches: ['*://*.youtube.com/watch*']. Chrome evalúa ese patrón cuando el documento carga, no cuando cambia la URL. Si el documento que se cargó fue youtube.com/, ninguno de los dos se inyectó, y un pushState a /watch?v=… no cambia eso. Para Chrome sigues en la misma página.
La solución es inyectarlos en todo YouTube y decidir dentro si estás en un vídeo:
export function isWatchPageUrl(url: string): boolean {
try {
const parsed = new URL(url);
return (
/(^|\.)youtube\.com$/.test(parsed.hostname) &&
parsed.pathname === '/watch' &&
parsed.searchParams.has('v')
);
} catch {
return false;
}
}
Para el interceptor de MAIN esto es todavía más importante. Tiene que estar en document_start del documento que realmente se carga, que puede ser la portada, una búsqueda o un canal, para que su envoltorio de fetch esté puesto cuando el usuario llegue a un vídeo.
Estado 2: los subtítulos del vídeo anterior
Con los scripts ya inyectados, el popup listaba pistas. Pero eran las del primer vídeo que habías abierto. Y el título también.
ytInitialPlayerResponse es, como dice su nombre, la respuesta inicial. YouTube la escribe una vez en el HTML del documento y, al navegar, no la vuelve a tocar. El reproductor recibe los datos del vídeo nuevo por otra vía y la variable global se queda con los del primero.
La primera defensa fue comprobar que el vídeo coincide y, si no, descargar el HTML de la página actual:
const currentVideoId = extractVideoId(win.location.href);
let raw = readPlayerResponseFromDom(win);
// YouTube's client-side router doesn't update this global on SPA navigation,
// so a mismatched videoId means it's left over from the previous video.
if (raw && currentVideoId && raw.videoDetails?.videoId !== currentVideoId) {
raw = null;
}
if (!raw) {
raw = await fetchPlayerResponseFromHtml(win.location.href, fetchImpl);
}
Funciona, porque el HTML de /watch?v=… siempre trae el vídeo correcto. Pero es descargar una página de YouTube entera, cientos de kilobytes, para leer un objeto que el navegador ya tiene. Y además el content script aislado lee la variable global desde su mundo, donde a veces ni siquiera existe.
La mejor fuente es la que usa el propio reproductor. Al navegar, YouTube pide los datos del vídeo a /youtubei/v1/player, y la respuesta tiene exactamente la misma forma que ytInitialPlayerResponse. El envoltorio de fetch del mundo MAIN ya estaba ahí, así que solo hubo que escuchar esa URL también. Esta es la otra razón por la que el interceptor de fetch se quedó junto al de XHR.
Sin embargo, en la carga inicial esa petición no existe: los datos vienen incrustados en el HTML. Así que la respuesta del reproductor se busca en cadena, de la más fiable a la más cara:
- Lo interceptado de
/youtubei/v1/player, si es del vídeo actual. player.getPlayerResponse(), un método del reproductor que devuelve lo que tiene cargado. Existe en la mayoría de versiones, pero no está garantizado.window.ytInitialPlayerResponseleído desdeMAIN, solo si suvideoIdcoincide con el del reproductor.- El HTML de la página, desde el content script, si el mundo
MAINno contesta en 500 ms.
El content script pide al de MAIN la respuesta con GET_PLAYER_RESPONSE y el id del vídeo que ve en la URL. El de MAIN contesta si tiene algo, y solo si el vídeo coincide:
if (!cachedPlayerResponse || (req.videoId && cachedPlayerResponse?.videoDetails?.videoId
&& cachedPlayerResponse.videoDetails.videoId !== req.videoId)) {
const liveResponse = player?.getPlayerResponse?.();
if (liveResponse) cachedPlayerResponse = liveResponse;
}
const responseOk = cachedPlayerResponse &&
(!req.videoId || !currentVideoId || req.videoId === currentVideoId);
Los 500 ms son el tiempo que doy al mundo MAIN para contestar. Normalmente contesta en unos pocos milisegundos. Si no lo hace, es que el script no está (por ejemplo, en una pestaña abierta antes de instalar la extensión) y se pasa al HTML.
El título necesitó un parche aparte. Algunas respuestas del reproductor llegan sin videoDetails.title, y el nombre del fichero acababa siendo video_es_auto.srt. Si falta, se toma de player.getVideoData().title y, como último recurso, de document.title sin el " - YouTube" del final.
Estado 3: cachés que sobreviven al vídeo
Tener cachés en MAIN (cachedPlayerResponse y cachedTrack) creó el problema contrario. Navegabas a otro vídeo y, durante un momento, la caché seguía teniendo el anterior. Si abrías el menú en ese momento, salían las pistas del vídeo de antes. Y si el idioma coincidía, la descarga devolvía los subtítulos del vídeo anterior directamente desde cachedTrack.
Hay dos cosas que lo arreglan. La primera es vaciar las cachés al empezar una navegación, no al terminarla:
const onNavStart = () => {
cachedTrack = null;
cachedPlayerResponse = null;
};
document.addEventListener('yt-navigate-start', onNavStart);
window.addEventListener('yt-navigate-start', onNavStart);
Entre yt-navigate-start y la llegada de los datos nuevos, la caché está vacía y se cae a getPlayerResponse() o al HTML. Es más lento, pero no da datos equivocados.
La segunda es que todo lo cacheado lleva el id del vídeo y se compara con el del reproductor antes de usarlo. Las dos cosas son necesarias. El evento puede no llegar (lo cuento ahora), y el id puede no estar en una respuesta concreta. Con las dos a la vez, para que salga un dato equivocado tendrían que fallar ambas.
Al terminar la navegación, yt-navigate-finish intenta rellenar la caché desde el reproductor si el interceptor no ha visto pasar la petición, porque YouTube a veces reutiliza una respuesta que ya tenía (por ejemplo, al volver atrás) y entonces no hay petición que interceptar.
Estado 4: eventos que llegan a otro sitio
yt-navigate-start, yt-navigate-finish y yt-page-data-updated son eventos personalizados que dispara la aplicación de YouTube, hecha con Polymer. No están documentados. La primera versión los escuchaba en window, porque era lo que había visto en otros proyectos, y a veces no llegaban.
YouTube los dispara sobre elementos del documento, y según la versión burbujean o no hasta window. Ahora todos se escuchan en los dos sitios:
document.addEventListener('yt-navigate-finish', onNavEvent);
window.addEventListener('yt-navigate-finish', onNavEvent);
document.addEventListener('yt-page-data-updated', onNavEvent);
window.addEventListener('yt-page-data-updated', onNavEvent);
document.addEventListener('yt-navigate-start', onNavEvent);
window.addEventListener('yt-navigate-start', onNavEvent);
window.addEventListener('popstate', onNavEvent);
Escuchar dos veces significa que el mismo evento puede ejecutar el manejador dos veces. Eso obliga a que el manejador sea idempotente: llamarlo una vez o diez con la misma URL tiene que dar el mismo resultado. Es la regla que acabó guiando todo el montaje del botón.
Estado 5: el botón en la barra de acciones
El botón Subtítulos se monta con createShadowRootUi de WXT, en línea, al final de #top-level-buttons-computed, que es el contenedor donde YouTube pone Me gusta, Compartir y compañía. Montarlo una vez es fácil. Mantenerlo durante una sesión de navegación fue lo que más iteraciones llevó: cinco commits seguidos en el mismo día, cada uno arreglando lo que dejaba pasar el anterior.
Primera versión: esperar al contenedor y rendirse. Si al cargar no estaba la barra (YouTube la pinta tarde), un MutationObserver esperaba a que apareciera y, a los 10 segundos, se desconectaba para no quedarse observando para siempre. En una conexión lenta, o con un vídeo que tardaba en cargar los metadatos, la barra aparecía a los 11 segundos y el botón no salía. Y al navegar a otro vídeo, el observador ya no existía.
Segunda versión: un observador permanente. Un único MutationObserver sobre todo el body, que no se desconecta. El DOM de YouTube cambia sin parar (comentarios, recomendaciones, el propio reproductor), así que el observador se dispara cientos de veces por segundo. Hacer la comprobación en cada mutación habría sido un desastre, así que se agrupa en un solo requestAnimationFrame:
globalObserver = new MutationObserver(() => {
if (checkFrameId !== null) return;
checkFrameId = requestAnimationFrame(() => {
checkFrameId = null;
void checkNavigation(ctx);
});
});
globalObserver.observe(root, { childList: true, subtree: true });
Como mucho, una comprobación por fotograma, por muchas mutaciones que haya.
Tercera versión: el contenedor equivocado. Con el observador permanente, el botón a veces aparecía y a veces no, y a veces aparecía dos veces. YouTube no siempre destruye la página anterior al navegar. Puede dejar elementos de la vista anterior ocultos en el DOM, o crear la barra nueva antes de quitar la vieja. Un document.querySelector('#top-level-buttons-computed') devolvía el primero que encontraba, que podía ser uno oculto o uno a punto de desaparecer. Y comprobar document.querySelector('yt-sub-fetch-native-menu') para saber si ya había botón daba un sí aunque el botón estuviera en esa barra muerta.
findTargetContainer busca ahora por orden de preferencia, empezando por los metadatos del vídeo activo, y solo acepta elementos que sigan conectados al documento:
function findTargetContainer(): HTMLElement | null {
const watchMetadata = document.querySelector('ytd-watch-metadata');
if (watchMetadata) {
const computed = watchMetadata.querySelector<HTMLElement>('#top-level-buttons-computed');
if (computed && computed.isConnected) return computed;
// ... dentro de #actions
}
const watchFlexy = document.querySelector('ytd-watch-flexy[role="main"]');
if (watchFlexy) {
const flexyComputed = watchFlexy.querySelector<HTMLElement>('#top-level-buttons-computed');
if (flexyComputed && flexyComputed.isConnected) return flexyComputed;
}
// ... selectores de reserva, cada vez más genéricos
return null;
}
La pregunta dejó de ser "¿existe mi botón?" y pasó a ser "¿está mi botón dentro de la barra buena?". Si está y es del vídeo actual, no se hace nada. Si no, se quitan todas las instancias que haya en el documento y se monta una nueva.
Cuarta versión: la barra reciclada. Quedaba un caso: YouTube puede destruir la barra y crear otra igual sin cambiar de vídeo. Pasa, por ejemplo, cuando vuelve a pintar los metadatos del vídeo. El código anterior veía que el id del vídeo era el mismo que el del último montaje y no hacía nada. El botón se había ido con la barra vieja. Por eso la comprobación es la de antes, la de si el botón está dentro de la barra buena, y no la de si el vídeo ha cambiado. Hay un test para esto en concreto: monta el botón, borra la barra, crea otra con el mismo id y comprueba que el botón aparece en la nueva.
Un candado para el montaje. createShadowRootUi es asíncrona. Entre que empieza y termina pueden llegar tres o cuatro llamadas más a checkNavigation (el observador, los eventos duplicados, el sondeo), y cada una veía que no había botón y empezaba otro montaje. El resultado eran botones duplicados. Una variable isMounting hace que esas llamadas salgan sin hacer nada mientras hay un montaje en curso, y lastMountedVideoId se apunta antes del await para que ninguna crea que falta montar.
Estado 6: navegaciones que nadie anuncia
Con todo lo anterior, aún había navegaciones en las que el botón se quedaba con el vídeo anterior hasta que movías el ratón. Que se arreglara al mover el ratón era la pista: el observador lo arreglaba cuando algo cambiaba en el DOM, pero ningún evento había avisado del cambio de URL.
No todas las navegaciones de YouTube disparan yt-navigate-finish, o no siempre en el momento en que la URL ya ha cambiado. Pasar al siguiente vídeo de una lista o de la reproducción automática, por ejemplo, puede cambiar la URL con history.replaceState sin una navegación completa. Y popstate solo salta con Atrás y Adelante, no con pushState: no existe un evento del navegador que avise de un pushState.
Así que el mundo MAIN lo crea. Parchea history.pushState y history.replaceState de la página y avisa al content script cada vez que se llaman:
const origPushState = history.pushState;
history.pushState = function (...args) {
const ret = origPushState.apply(this, args);
window.postMessage({ source: 'yt-sub-fetch-main', type: 'YT_URL_CHANGED', url: window.location.href }, '*');
return ret;
};
Esto solo funciona en MAIN. El history del mundo aislado es otro objeto, y parchearlo no se entera de lo que hace YouTube. El aviso se manda después de llamar al original, para que location.href ya sea la URL nueva cuando el content script la lea.
Y como red final, un sondeo cada 400 ms:
const navInterval = setInterval(() => {
void checkNavigation(ctx);
}, 400);
Tengo sentimientos encontrados con este sondeo. Es la admisión de que no me fío de nada de lo anterior. Pero checkNavigation es barata cuando no hay nada que hacer: lee location.href, busca un contenedor y ve que el botón ya está, todo en unos microsegundos. Y garantiza que, pase lo que pase, en menos de medio segundo el botón refleja el vídeo actual. Lo dejé porque prefiero un sondeo barato a un fallo que no sé reproducir.
Las cinco capas, juntas
Al final, saber que has cambiado de vídeo depende de cinco mecanismos a la vez, y cada uno está ahí porque los demás se dejaban algún caso:
| Capa | Qué detecta | Qué se le escapa |
|---|---|---|
Eventos yt-navigate-* y yt-page-data-updated | Navegaciones normales de YouTube | No son públicos; no siempre llegan a window ni en el momento justo |
popstate | Atrás y Adelante | Cualquier navegación hacia delante |
pushState y replaceState parcheados en MAIN | Cada cambio de URL, en el instante | Nada de lo que cambia el DOM sin cambiar la URL |
MutationObserver agrupado por fotograma | Barras recreadas, botones arrancados | Nada si la URL cambia y el DOM no |
| Sondeo cada 400 ms | Todo, con hasta 400 ms de retraso | Nada, pero es fuerza bruta |
Todas llaman a la misma función, checkNavigation, y por eso tiene que ser idempotente. Esto es lo que hace cada vez:
Cuando cambia el vídeo, el botón se desmonta entero y se monta otro, con su aplicación Vue nueva. Podría haber reutilizado el componente y recargar sus datos, pero desmontar garantiza que no queda ningún estado del vídeo anterior: ni las pistas, ni un error, ni el menú abierto. El componente no pide las pistas al montarse, sino la primera vez que abres el menú, así que montar uno nuevo en cada vídeo no genera ninguna petición.
Al salir de una página de vídeo a la portada, a una búsqueda o a un canal, el botón se quita, pero el observador se vuelve a enganchar enseguida. La primera versión desconectaba todo al salir de /watch, y al volver a un vídeo no había nada escuchando.
Todo se limpia en ctx.onInvalidated: el intervalo, los ocho listeners y el botón. WXT invalida el contexto cuando la extensión se actualiza o se recarga, y sin esa limpieza el content script viejo seguiría montando botones junto al nuevo.
El botón por dentro: Shadow DOM y CSS
El botón vive dentro de un Shadow DOM para que los estilos de YouTube no le afecten y los míos no se salgan a la página. Tenía que parecer un botón de YouTube: píldora de 36 px de alto, el icono de subtítulos, el mismo gris y el mismo hover, en claro y en oscuro.
content.css y CORS
WXT, con cssInjectionMode: 'ui', compila los estilos del content script en un content.css y lo carga dentro del Shadow DOM. En la consola de YouTube salía un error de CORS al pedir ese fichero, y el botón aparecía sin estilos. El CSS es un recurso de la extensión y la página no puede cargarlo salvo que esté declarado en web_accessible_resources. Lo declaré, pero no me fiaba de depender de esa carga para algo que tiene que estar en el primer fotograma.
La solución definitiva fue no cargar ningún fichero. cssInjectionMode: 'manual' hace que WXT no inyecte nada, y los estilos van como una cadena en nativeMenuStyles.ts que se pasa a createShadowRootUi:
currentUi = await createShadowRootUi(ctx, {
name: 'yt-sub-fetch-native-menu',
position: 'inline',
anchor: targetContainer,
append: 'last',
css: NATIVE_MENU_CSS,
onMount: (container) => {
const app = createApp(NativeSubtitleMenu);
app.mount(container);
return app;
},
onRemove: (app) => {
app?.unmount();
},
});
El precio es que el menú no usa Tailwind: son unas 300 líneas de CSS escrito a mano, con clases propias (yt-sub-pill-btn, yt-sub-popover) y una variante .yt-sub-dark para cada una.
Clics fuera del menú
El menú se cierra al hacer clic fuera, con un listener en document. Comprobaba event.target.closest('.yt-sub-menu-container'), y el menú se cerraba también al hacer clic dentro. Un evento que sale de un Shadow DOM se retargetea: visto desde document, su target es el elemento anfitrión, no el botón real que pulsaste, y closest no encuentra el contenedor. La ruta completa sí está en composedPath():
const isInside = event.composedPath().some(
(el) => el instanceof HTMLElement && el.classList.contains('yt-sub-menu-container')
);
if (!isInside && isOpen.value) {
isOpen.value = false;
}
Un menú que no cabe
La barra de acciones de YouTube recorta lo que se sale de ella, así que un menú con position: absolute debajo del botón quedaba cortado. El menú se posiciona con coordenadas de la ventana a partir de getBoundingClientRect() del botón y, si a la derecha no caben sus 340 px, se alinea por el borde derecho. La posición se calcula dos veces al abrir, antes y después del nextTick, porque el botón puede moverse cuando Vue pinta la lista.
El tema oscuro se detecta mirando el atributo dark de <html> y de ytd-app, que es como YouTube marca su modo oscuro, con un MutationObserver sobre esos atributos para cambiar en vivo.
Descargar un .srt que siga siendo .srt
La descarga también dio guerra. El SRT se mete en un Blob, se crea una URL de objeto y se descarga con un nombre como Como_vuelan_los_aviones_es_auto.srt. Primero el Blob iba como text/plain, y Chrome lo guardaba como .txt: ajusta la extensión del fichero al tipo MIME. Con application/x-subrip;charset=utf-8 respeta el .srt.
Desde el botón de la página apareció otro problema. El componente vive en el content script, y los content scripts no tienen acceso a browser.downloads. Así que downloadSrt usa un enlace con el atributo download, que funciona en cualquier página:
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
Si la URL de objeto se revoca justo después del clic, en algunos casos la descarga no ha empezado todavía y falla. Diez segundos sobran. El nombre del fichero pasa antes por un saneado que quita \ / : * ? " < > | (prohibidos en Windows) y cambia los espacios por guiones bajos. Si el título queda vacío, el nombre empieza por video.
El camino de vuelta: tus subtítulos sobre el vídeo
Con la descarga resuelta, la extensión ganó la función contraria. Descargas el .srt, lo traduces o lo corriges con otra herramienta y lo vuelves a cargar, y la extensión lo pinta sobre el reproductor, sincronizado con el vídeo y con el aspecto que elijas. Se puede cargar desde el menú del botón, con un modal que acepta arrastrar el fichero, o desde la pestaña Accesibilidad del popup.
Del fichero a los cues
parseSubtitles acepta SRT y WebVTT con el mismo código, porque en el fondo son casi el mismo formato. Los dos son bloques separados por líneas en blanco, y en cada bloque hay una línea con --> y el texto debajo. Las diferencias están en los detalles: SRT separa los milisegundos con coma y WebVTT con punto, y WebVTT permite omitir las horas (01:23.456). El parser normaliza la coma a punto, acepta tiempos de dos y de tres partes y busca la línea del --> en lugar de suponer que es la segunda, así que le dan igual el número de bloque del SRT, la cabecera WEBVTT o los ajustes de posición que WebVTT añade detrás del tiempo.
Cada cue sale con startMs, endMs y el texto sin etiquetas (<i>, <b>, <font>, las de voz de WebVTT). Un bloque con un tiempo que no se puede leer, con el final antes que el inicio o sin texto se descarta sin romper el resto del fichero, y al final todo se ordena por inicio. El texto se pinta con la interpolación de Vue, nunca con v-html, así que quitar las etiquetas es por limpieza: aunque quedara alguna, se vería como texto.
El fichero se lee con un FileReader y se parsea en el mismo sitio donde lo cargas. Desde el menú del botón, el componente está en el content script y le pasa los cues directamente al overlay. Desde el popup, viajan a la pestaña con un mensaje SET_CUES. Ese mensaje llegó a tener su propio fallo: el popup lo mandaba y el content script no tenía ningún manejador para él, así que cargabas el fichero, el popup decía que todo había ido bien y en el vídeo no aparecía nada. Ahora el content script lo atiende y, si el overlay todavía no está montado, lo monta antes de pasarle los cues.
Dentro del reproductor
El overlay es un segundo createShadowRootUi, montado esta vez dentro de #movie_player. Podría haberlo puesto encima del vídeo con position: fixed y coordenadas de la ventana, como el menú. Pero #movie_player es el elemento que YouTube pone a pantalla completa, y todo lo que no esté dentro desaparece en ese momento. Montado dentro, el subtítulo sigue ahí al pasar a modo cine o a pantalla completa, sin recalcular nada.
La capa ocupa todo el reproductor con pointer-events: none, para que los clics sigan llegando a YouTube (pausar, mostrar los controles), y solo la caja del texto los recibe. Así se puede arrastrar el subtítulo hacia arriba o hacia abajo. El desplazamiento del ratón se convierte en un porcentaje de la altura, limitado entre el 5 % y el 85 %, y se guarda como un ajuste más.
El montaje tropezó con lo mismo que el del botón: createShadowRootUi puede devolver una promesa. La primera versión llamaba a mount() sobre lo que devolvía como si fuera el resultado, y el overlay no llegaba a montarse. Esperar la promesa abre otra carrera: si cambias de vídeo mientras se resuelve, el remove() llega antes de que haya nada que quitar. El gestor apunta que se le ha pedido quitarse y, cuando la promesa se resuelve, desmonta en vez de montar. Al cambiar de vídeo, los cues se vacían, para que los subtítulos de un vídeo no se queden encima del siguiente.
Un tiempo por fotograma
La primera versión sacaba el tiempo del evento timeupdate del <video>. Funcionaba, pero los subtítulos iban a saltos: timeupdate no se dispara en cada fotograma, sino cuando el navegador lo decide, y en Chrome son unas cuatro veces por segundo. Un subtítulo podía aparecer hasta un cuarto de segundo tarde, y en un diálogo rápido se nota.
Ahora, mientras el vídeo se reproduce, el tiempo se lee en cada fotograma con requestVideoFrameCallback, que avisa cuando el vídeo pinta un fotograma nuevo, y con requestAnimationFrame como alternativa si no existe:
const scheduleNextFrame = () => {
if (!video || video.paused || video.ended) {
stopSyncLoop();
return;
}
if (typeof (video as any).requestVideoFrameCallback === 'function') {
rvfcHandle = (video as any).requestVideoFrameCallback(() => {
updateCurrentTime();
scheduleNextFrame();
});
} else if (typeof requestAnimationFrame === 'function') {
rafHandle = requestAnimationFrame(() => {
updateCurrentTime();
scheduleNextFrame();
});
}
};
El bucle arranca con play y playing y se para con pause y ended, así que con el vídeo parado no cuesta nada. timeupdate, seeking y seeked siguen escuchándose, porque si mueves la barra de progreso con el vídeo en pausa no hay fotogramas que avisen, y el subtítulo tiene que cambiar igual. El tiempo se guarda redondeado a milisegundos y solo se escribe si ha cambiado, para que Vue no vuelva a pintar el overlay sin motivo.
Cues que se pisan
Para encontrar qué cue toca en cada momento, la especificación que escribí pedía una búsqueda binaria. Con los cues ordenados por inicio, encontrar el activo en un fichero de 2000 líneas cuesta once comparaciones en lugar de 2000, y eso, sesenta veces por segundo, parecía importante.
La probé con un .srt descargado con la propia extensión de un vídeo con subtítulos automáticos, y se comía líneas. Los subtítulos automáticos de YouTube son de los que van subiendo: cada línea aparece debajo de la anterior y la anterior sigue en pantalla unos segundos. En el SRT eso son cues que se solapan:
3
00:00:16,280 --> 00:00:24,280
>> The water's rising, clear as glass.
4
00:00:20,280 --> 00:00:26,720
A silent senses of the past.
Entre el segundo 20 y el 24 tienen que verse los dos. La búsqueda binaria encuentra un cue que contiene el instante y se para, y según dónde cayera el punto medio enseñaba uno u otro. Ahora findActiveCues recorre la lista entera y devuelve todos los activos. El overlay los junta en líneas, sin repetir textos iguales:
const targetTime = currentTimeMs + syncOffsetMs;
if (targetTime < 0) return [];
const active: SubtitleCue[] = [];
for (let i = 0; i < cues.length; i++) {
const cue = cues[i];
if (targetTime >= cue.startMs && targetTime <= cue.endMs) {
active.push(cue);
}
}
Recorrer 2000 cues por fotograma son 2000 comparaciones de números, nada para un navegador. La optimización resolvía un problema que no tenía, y el problema que sí tenía era otro. Hay un test que reproduce justo ese caso: cuatro cues encadenados, el tiempo avanzando de 200 en 200 ms, y la comprobación de que los cuatro se llegan a ver.
El syncOffsetMs de la primera línea es el ajuste de sincronización. Si el fichero va adelantado o atrasado respecto al vídeo, los botones de -0,5 s y +0,5 s del popup lo corrigen sin tocar el fichero: el desfase se suma al tiempo del vídeo antes de buscar.
Ajustes que viajan solos
Tamaño, tipografía, colores, contorno, posición y desfase son un único objeto que useSubtitleSettings guarda en el almacenamiento local de la extensión con wxt/storage. El composable se usa en el popup y en el overlay, y los dos escuchan la misma clave con storage.watch. Cuando mueves el control del tamaño en el popup, el popup guarda y el overlay, en la pestaña de YouTube, recibe el cambio y se repinta. No hay mensajes entre los dos: el almacenamiento hace de canal.
Hay cuatro preajustes: amarillo sobre negro, blanco clásico, cian sobre azul noche y uno con la tipografía OpenDyslexic, pensada para personas con dislexia. El contorno de alto contraste es un text-shadow con cuatro sombras negras desplazadas en diagonal más una difuminada, que hace que el texto se lea sobre cualquier imagen aunque quites el fondo. La pestaña de accesibilidad del popup tiene una vista previa que aplica los mismos ajustes que el overlay, para ver el resultado sin ir al vídeo.
Tests
Hay 208 tests de Vitest. La mayoría son unitarios contra utils/: el parseo de la respuesta del reproductor con todos sus estados (OK, UNPLAYABLE con y sin motivo, sin pistas, nombres en simpleText o en runs), la conversión a SRT (el cero, los eventos sin texto, las respuestas vacías o rotas), los nombres de fichero, la URL traducida (que URLSearchParams no rompa la codificación de la firma, un fallo que llegó a pasar) y el tema y el idioma.
triggerPlayerFetch se prueba con un #movie_player falso que apunta cada setOption que recibe: que lee la pista previa, cambia a la pedida y la restaura, que la traducción se pone y se quita tanto al resolver como en el timeout, que sin setOption contesta PLAYER_UNAVAILABLE, y que una respuesta que llega después del timeout no vuelve a tocar el reproductor. Los envoltorios de fetch y XHR se prueban con respuestas simuladas: que las URLs ajenas pasan sin tocar, que el cuerpo se clona, que aceptan un string, una URL o un Request, que leen bien un responseType: 'json' y que un cuerpo que no es JSON no lanza nada. El script de MAIN entero tiene su propio archivo: la caché de la pista activa, que se vacía con yt-navigate-start, que una segunda descarga simultánea se rechaza y que la respuesta llega también por XHR.
El montaje del botón tiene su propio archivo, con un DOM falso: que se monta dentro de ytd-watch-metadata, que se quita al salir de /watch, que se vuelve a montar si YouTube recrea la barra con el mismo vídeo y que se registran y limpian todos los listeners.
Los subtítulos propios añadieron sus archivos: el parser con SRT y WebVTT, comas y puntos, cabeceras, etiquetas y bloques rotos; la sincronización con el desfase y los cues solapados; los ajustes y sus preajustes; el overlay y el modal de carga como componentes; el montaje del overlay dentro del reproductor, y el popup mandando y quitando los cues de la pestaña.
Lo que los tests no cubren es YouTube de verdad, y es donde salieron casi todos los fallos de la SPA. Cada capa de detección vino de una sesión de navegar por YouTube con la consola abierta, ver un caso que fallaba y reproducirlo. Por eso el código tiene tantos console.log con el prefijo [yt-sub-fetch]: eran la única forma de saber qué evento había llegado y en qué orden.
Lo que dejaría distinto
Repasando el código para este artículo he encontrado cosas que hoy haría de otra manera:
- La caché de pistas no distingue el
kind.cachedTrackse compara solo por idioma. Si tienes puesto el inglés del autor y descargas el inglés automático, te llevas el del autor. Bastaría con guardar también elkind. - El tema oscuro mira también el sistema. La detección acaba con un
|| systemDark, así que con YouTube en claro y el sistema en oscuro, el menú sale oscuro sobre una página clara. Debería fiarse solo del atributo de YouTube. - El menú no está traducido. El popup ya está en ocho idiomas, pero los textos del menú de la página y del modal de carga siguen escritos en español.
- OpenDyslexic no viene incluida. El preajuste pide la fuente por su nombre y, si no la tienes instalada, el navegador cae en Comic Sans. Habría que empaquetarla con la extensión.
- Un modo dual a medias. El overlay sabe leer los subtítulos nativos de YouTube y pintarlos encima de los tuyos, para ver el original y la traducción a la vez. Pero quité el interruptor del menú al simplificarlo y nunca llegó al popup, así que ahora mismo no hay forma de activarlo.
- Código duplicado. La lógica de mandar
TRIGGER_SUBTITLE_FETCHy esperar la respuesta está en el content script (para el popup) y encontentSubtitles.ts(para el botón), casi idéntica. Debería ser una sola función. - Los logs. Fueron imprescindibles para depurar, pero siguen en el build, y en la consola de YouTube se oye cada navegación.
Lo que me llevo
Lo que cuesta de un proyecto así no es el formato SRT ni Vue, sino trabajar dentro de una aplicación ajena que no te debe nada. YouTube no avisa cuando cambia de vídeo, no promete que sus eventos sigan existiendo, recicla el DOM cuando le conviene y, cuando decide que no quiere servirte algo, te contesta 200 OK con el cuerpo vacío.
Casi todos los fallos tuvieron la misma causa: suponer que la página se comporta como una página. Que el content script se inyecta cuando cambia la URL, que la variable global sigue el vídeo que ves, que el elemento que encuentra querySelector es el que está a la vista o que un evento llega a window. Cada suposición funcionaba al recargar y fallaba al navegar. Probar con F5 era probar el único caso en el que todo encajaba.
Y lo del PoToken me enseñó a comparar antes de suponer. Dos hipótesis y un cambio de arquitectura después, la respuesta estaba en dos parámetros que se veían poniendo una URL junto a la otra.