[{"data":1,"prerenderedAt":6244},["ShallowReactive",2],{"\u002Farticulos\u002Fsubfetch-por-dentro\u002F":3,"upcoming:\u002Farticulos\u002Fsubfetch-por-dentro\u002F":6243},{"id":4,"title":5,"body":6,"date":6228,"description":6229,"draft":6230,"extension":6231,"meta":6232,"navigation":327,"path":6233,"seo":6234,"stem":6235,"tags":6236,"type":6241,"__hash__":6242},"posts\u002Farticulos\u002Fsubfetch-por-dentro.md","SubFetch for YouTube por dentro: el PoToken, el mundo MAIN y una SPA que no avisa cuando cambia de vídeo",{"type":7,"value":8,"toc":6181},"minimark",[9,39,42,47,55,66,84,87,91,118,167,170,205,209,244,251,567,577,592,614,619,628,1004,1022,1036,1040,1047,1102,1127,1208,1215,1223,1233,1248,1266,1286,1289,1293,1296,1311,1314,1318,1341,1348,1420,1432,1445,1449,1455,1789,1792,1840,1846,1861,1875,2230,2259,2268,2272,2278,2361,2364,2540,2543,2608,2611,2742,2749,2752,2756,2763,2772,2775,2779,2791,2873,2904,3048,3057,3063,3069,3232,3259,3262,3266,3273,3279,3283,3286,3305,3308,3465,3477,3481,3484,3489,3492,3611,3617,3636,3639,3681,3694,3812,3818,3837,3841,3854,3857,3931,3941,3944,3950,3954,3967,3973,4066,4069,4073,4091,4101,4116,4229,4232,4246,4252,4458,4461,4467,4492,4496,4499,4518,4529,4625,4639,4642,4684,4693,4697,4700,4796,4802,4867,4870,4876,4883,4887,4890,4897,4911,4924,5077,5091,5095,5124,5222,5226,5241,5259,5268,5296,5311,5411,5421,5425,5434,5438,5459,5483,5494,5498,5514,5521,5535,5539,5553,5563,5755,5781,5785,5788,5794,5801,5808,5959,5962,5968,5972,5987,5994,5998,6024,6062,6072,6075,6086,6090,6093,6155,6159,6165,6174,6177],[10,11,12,17,18,22,23,27,28,31,32,34,35,38],"p",{},[13,14,16],"a",{"href":15},"\u002Fproyectos\u002Fsubfetch","SubFetch for YouTube",", que empezó llamándose YouTube Subtitles, descarga en ",[19,20,21],"code",{},".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 ",[24,25,26],"em",{},"Me gusta"," y ",[24,29,30],{},"Compartir",". También hace lo contrario: cargas un ",[19,33,21],{}," o un ",[19,36,37],{},".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.",[10,40,41],{},"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.",[43,44,46],"h2",{"id":45},"por-qué-aún-no-está-publicada","Por qué aún no está publicada",[10,48,49,50,54],{},"YouTube solo entrega los subtítulos a peticiones que llevan un token de atestación, el ",[51,52,53],"strong",{},"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.",[10,56,57,58,61,62,65],{},"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 ",[24,59,60],{},"YouTube Subtitles"," pasó a ser ",[24,63,64],{},"SubFetch for YouTube™",", con iconos nuevos sin el logotipo de YouTube y la ficha de la tienda escrita en ocho idiomas.",[10,67,68,69,27,72,75,76,79,80,83],{},"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 ",[19,70,71],{},"setOption('captions', …)",[19,73,74],{},"getPlayerResponse()",", eventos internos como ",[19,77,78],{},"yt-navigate-finish"," e ids del DOM como ",[19,81,82],{},"#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.",[10,85,86],{},"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.",[43,88,90],{"id":89},"la-forma-de-la-extensión","La forma de la extensión",[10,92,93,94,97,98,101,102,105,106,109,110,113,114,117],{},"Es una extensión Manifest V3 hecha con ",[51,95,96],{},"WXT",", la interfaz en ",[51,99,100],{},"Vue 3"," con ",[51,103,104],{},"Tailwind CSS 4",", todo en ",[51,107,108],{},"TypeScript",", con ",[51,111,112],{},"vue-i18n"," para la interfaz (empezó en español e inglés y ahora va en ocho idiomas) y ",[51,115,116],{},"Vitest"," para los tests. Al final quedaron cinco piezas:",[119,120,121,136,142,148,158],"ul",{},[122,123,124,127,128,131,132,135],"li",{},[51,125,126],{},"El popup",", la primera interfaz. Tiene dos pestañas: ",[24,129,130],{},"Subtítulos"," lista las pistas del vídeo de la pestaña activa y las descarga, y ",[24,133,134],{},"Accesibilidad"," carga subtítulos propios y ajusta cómo se ven.",[122,137,138,141],{},[51,139,140],{},"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ú.",[122,143,144,147],{},[51,145,146],{},"El overlay de subtítulos",", otro componente Vue en otro Shadow DOM, este dentro del propio reproductor.",[122,149,150,153,154,157],{},[51,151,152],{},"El content script"," (",[19,155,156],{},"content.ts","), en el mundo aislado de la extensión. Monta el botón y el overlay, atiende al popup y hace de puente.",[122,159,160,153,163,166],{},[51,161,162],{},"El interceptor",[19,164,165],{},"player-interceptor.content.ts","), en el mundo de la propia página. Habla con el reproductor y escucha su tráfico de red.",[10,168,169],{},"No hay service worker. El popup habla directamente con la pestaña, y la pestaña tiene todo lo que hace falta.",[10,171,172,173,176,177,180,181,184,185,188,189,192,193,196,197,200,201,204],{},"La lógica está fuera de los componentes, en ",[19,174,175],{},"utils\u002F",": ",[19,178,179],{},"youtube.ts"," lee y parsea la respuesta del reproductor, ",[19,182,183],{},"srtFormatter.ts"," convierte JSON3 a SRT, ",[19,186,187],{},"download.ts"," construye el nombre del fichero y lo descarga, ",[19,190,191],{},"translation.ts"," arma la URL traducida, ",[19,194,195],{},"interceptor.ts"," envuelve la red y maneja el reproductor, e ",[19,198,199],{},"interceptorTypes.ts"," tiene los contratos de los mensajes. Los subtítulos propios tienen su carpeta, ",[19,202,203],{},"utils\u002Fsubtitles\u002F",", con el parser y la sincronización. Casi todos los tests van contra esos módulos.",[43,206,208],{"id":207},"la-primera-versión-leer-lo-que-youtube-ya-ha-puesto-en-la-página","La primera versión: leer lo que YouTube ya ha puesto en la página",[10,210,211,212,215,216,219,220,223,224,227,228,231,232,235,236,239,240,243],{},"Cada página ",[19,213,214],{},"\u002Fwatch"," de YouTube lleva incrustado un objeto enorme, ",[19,217,218],{},"ytInitialPlayerResponse",", con todo lo que el reproductor necesita para arrancar. Dentro, en ",[19,221,222],{},"captions.playerCaptionsTracklistRenderer",", están las pistas de subtítulos (",[19,225,226],{},"captionTracks",") y el catálogo de idiomas a los que YouTube sabe traducir (",[19,229,230],{},"translationLanguages","). Cada pista trae su idioma, un nombre visible, si es traducible, si es automática (",[19,233,234],{},"kind: 'asr'",") y una ",[19,237,238],{},"baseUrl"," firmada que apunta a ",[19,241,242],{},"\u002Fapi\u002Ftimedtext",".",[10,245,246,247,250],{},"El content script lo busca primero en la variable global y, si no está, en el texto de los ",[19,248,249],{},"\u003Cscript>"," de la página:",[252,253,258],"pre",{"className":254,"code":255,"language":256,"meta":257,"style":257},"language-ts shiki shiki-themes github-light github-dark","const PLAYER_RESPONSE_RE = \u002Fvar ytInitialPlayerResponse\\s*=\\s*(\\{.+?\\});\u002F;\n\nexport function readPlayerResponseFromDom(win: Window = window): RawPlayerResponse | null {\n  const globalResponse = (win as unknown as { ytInitialPlayerResponse?: RawPlayerResponse })\n    .ytInitialPlayerResponse;\n  if (globalResponse) return globalResponse;\n\n  \u002F\u002F Scan only \u003Cscript> nodes: serializing the whole document (innerHTML) on a\n  \u002F\u002F loaded YouTube page allocates several MB and breaks NFR-1.\n  for (const script of Array.from(win.document.scripts)) {\n    const text = script.textContent;\n    if (!text) continue;\n\n    const parsed = parsePlayerResponseFromText(text);\n    if (parsed) return parsed;\n  }\n\n  return null;\n}\n","ts","",[19,259,260,322,329,373,409,415,430,435,442,448,473,487,506,511,527,540,546,551,561],{"__ignoreMap":257},[261,262,265,269,273,276,280,284,287,290,293,295,297,300,304,306,309,312,315,318],"span",{"class":263,"line":264},"line",1,[261,266,268],{"class":267},"szBVR","const",[261,270,272],{"class":271},"sj4cs"," PLAYER_RESPONSE_RE",[261,274,275],{"class":267}," =",[261,277,279],{"class":278},"sZZnC"," \u002F",[261,281,283],{"class":282},"sA_wV","var ytInitialPlayerResponse",[261,285,286],{"class":271},"\\s",[261,288,289],{"class":267},"*",[261,291,292],{"class":282},"=",[261,294,286],{"class":271},[261,296,289],{"class":267},[261,298,299],{"class":282},"(",[261,301,303],{"class":302},"snhLl","\\{",[261,305,243],{"class":271},[261,307,308],{"class":267},"+?",[261,310,311],{"class":302},"\\}",[261,313,314],{"class":282},");",[261,316,317],{"class":278},"\u002F",[261,319,321],{"class":320},"sVt8B",";\n",[261,323,325],{"class":263,"line":324},2,[261,326,328],{"emptyLinePlaceholder":327},true,"\n",[261,330,332,335,338,342,344,348,351,354,356,359,361,364,367,370],{"class":263,"line":331},3,[261,333,334],{"class":267},"export",[261,336,337],{"class":267}," function",[261,339,341],{"class":340},"sScJk"," readPlayerResponseFromDom",[261,343,299],{"class":320},[261,345,347],{"class":346},"s4XuR","win",[261,349,350],{"class":267},":",[261,352,353],{"class":340}," Window",[261,355,275],{"class":267},[261,357,358],{"class":320}," window)",[261,360,350],{"class":267},[261,362,363],{"class":340}," RawPlayerResponse",[261,365,366],{"class":267}," |",[261,368,369],{"class":271}," null",[261,371,372],{"class":320}," {\n",[261,374,376,379,382,384,387,390,393,396,399,401,404,406],{"class":263,"line":375},4,[261,377,378],{"class":267},"  const",[261,380,381],{"class":271}," globalResponse",[261,383,275],{"class":267},[261,385,386],{"class":320}," (win ",[261,388,389],{"class":267},"as",[261,391,392],{"class":271}," unknown",[261,394,395],{"class":267}," as",[261,397,398],{"class":320}," { ",[261,400,218],{"class":346},[261,402,403],{"class":267},"?:",[261,405,363],{"class":340},[261,407,408],{"class":320}," })\n",[261,410,412],{"class":263,"line":411},5,[261,413,414],{"class":320},"    .ytInitialPlayerResponse;\n",[261,416,418,421,424,427],{"class":263,"line":417},6,[261,419,420],{"class":267},"  if",[261,422,423],{"class":320}," (globalResponse) ",[261,425,426],{"class":267},"return",[261,428,429],{"class":320}," globalResponse;\n",[261,431,433],{"class":263,"line":432},7,[261,434,328],{"emptyLinePlaceholder":327},[261,436,438],{"class":263,"line":437},8,[261,439,441],{"class":440},"sJ8bj","  \u002F\u002F Scan only \u003Cscript> nodes: serializing the whole document (innerHTML) on a\n",[261,443,445],{"class":263,"line":444},9,[261,446,447],{"class":440},"  \u002F\u002F loaded YouTube page allocates several MB and breaks NFR-1.\n",[261,449,451,454,456,458,461,464,467,470],{"class":263,"line":450},10,[261,452,453],{"class":267},"  for",[261,455,153],{"class":320},[261,457,268],{"class":267},[261,459,460],{"class":271}," script",[261,462,463],{"class":267}," of",[261,465,466],{"class":320}," Array.",[261,468,469],{"class":340},"from",[261,471,472],{"class":320},"(win.document.scripts)) {\n",[261,474,476,479,482,484],{"class":263,"line":475},11,[261,477,478],{"class":267},"    const",[261,480,481],{"class":271}," text",[261,483,275],{"class":267},[261,485,486],{"class":320}," script.textContent;\n",[261,488,490,493,495,498,501,504],{"class":263,"line":489},12,[261,491,492],{"class":267},"    if",[261,494,153],{"class":320},[261,496,497],{"class":267},"!",[261,499,500],{"class":320},"text) ",[261,502,503],{"class":267},"continue",[261,505,321],{"class":320},[261,507,509],{"class":263,"line":508},13,[261,510,328],{"emptyLinePlaceholder":327},[261,512,514,516,519,521,524],{"class":263,"line":513},14,[261,515,478],{"class":267},[261,517,518],{"class":271}," parsed",[261,520,275],{"class":267},[261,522,523],{"class":340}," parsePlayerResponseFromText",[261,525,526],{"class":320},"(text);\n",[261,528,530,532,535,537],{"class":263,"line":529},15,[261,531,492],{"class":267},[261,533,534],{"class":320}," (parsed) ",[261,536,426],{"class":267},[261,538,539],{"class":320}," parsed;\n",[261,541,543],{"class":263,"line":542},16,[261,544,545],{"class":320},"  }\n",[261,547,549],{"class":263,"line":548},17,[261,550,328],{"emptyLinePlaceholder":327},[261,552,554,557,559],{"class":263,"line":553},18,[261,555,556],{"class":267},"  return",[261,558,369],{"class":271},[261,560,321],{"class":320},[261,562,564],{"class":263,"line":563},19,[261,565,566],{"class":320},"}\n",[10,568,569,570,573,574,576],{},"La primera versión hacía el regex sobre ",[19,571,572],{},"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 ",[19,575,249],{}," da el mismo resultado sin serializar nada.",[10,578,579,580,583,584,587,588,591],{},"El regex tiene un riesgo que dejé apuntado en la documentación y nunca llegué a ver: el ",[19,581,582],{},".+?"," no es codicioso, así que corta en el primer ",[19,585,586],{},"};"," que encuentra. Si algún texto dentro del JSON contuviera esos dos caracteres seguidos, el JSON quedaría cortado y ",[19,589,590],{},"JSON.parse"," fallaría. En ese caso el resultado es un error controlado, no unos datos corruptos, y por eso lo dejé así.",[10,593,594,597,598,601,602,605,606,609,610,613],{},[19,595,596],{},"parsePlayerResponse"," traduce ese objeto a algo manejable y decide los errores. Si ",[19,599,600],{},"playabilityStatus.status"," no es ",[19,603,604],{},"OK"," (vídeo privado, de pago, con restricción de edad), devuelve ",[19,607,608],{},"UNPLAYABLE"," con el motivo que da YouTube, que se enseña tal cual porque viene ya en el idioma del usuario. Sin pistas, ",[19,611,612],{},"NO_CAPTIONS",". Los errores son claves de i18n, no texto, para que el popup los traduzca.",[615,616,618],"h3",{"id":617},"del-json3-al-srt","Del JSON3 al SRT",[10,620,621,622,101,624,627],{},"Pidiendo la ",[19,623,238],{},[19,625,626],{},"fmt=json3",", YouTube devuelve una lista de eventos con un inicio, una duración y trozos de texto:",[252,629,631],{"className":254,"code":630,"language":256,"meta":257,"style":257},"export function convertJson3ToSrt(data: YoutubeJson3): string {\n  \u002F\u002F Real timedtext responses can be `{}` or otherwise malformed (e.g. an\n  \u002F\u002F expired signed baseUrl), so `events` is not guaranteed at runtime.\n  if (!Array.isArray(data?.events)) return '';\n\n  let srt = '';\n  let index = 1;\n\n  for (const event of data.events) {\n    if (!event.segs || !event.tStartMs && event.tStartMs !== 0) continue;\n\n    const text = event.segs.map((s) => s.utf8).join('').trim();\n    if (!text || text === '\\n') continue;\n\n    const startTime = formatMsToSRT(event.tStartMs);\n    const endTime = formatMsToSRT(event.tStartMs + (event.dDurationMs || 2000));\n\n    srt += `${index}\\n${startTime} --> ${endTime}\\n${text}\\n\\n`;\n    index++;\n  }\n\n  return srt;\n}\n",[19,632,633,662,667,672,696,700,714,728,732,748,787,791,836,870,874,889,917,921,971,981,986,991,999],{"__ignoreMap":257},[261,634,635,637,639,642,644,647,649,652,655,657,660],{"class":263,"line":264},[261,636,334],{"class":267},[261,638,337],{"class":267},[261,640,641],{"class":340}," convertJson3ToSrt",[261,643,299],{"class":320},[261,645,646],{"class":346},"data",[261,648,350],{"class":267},[261,650,651],{"class":340}," YoutubeJson3",[261,653,654],{"class":320},")",[261,656,350],{"class":267},[261,658,659],{"class":271}," string",[261,661,372],{"class":320},[261,663,664],{"class":263,"line":324},[261,665,666],{"class":440},"  \u002F\u002F Real timedtext responses can be `{}` or otherwise malformed (e.g. an\n",[261,668,669],{"class":263,"line":331},[261,670,671],{"class":440},"  \u002F\u002F expired signed baseUrl), so `events` is not guaranteed at runtime.\n",[261,673,674,676,678,680,683,686,689,691,694],{"class":263,"line":375},[261,675,420],{"class":267},[261,677,153],{"class":320},[261,679,497],{"class":267},[261,681,682],{"class":320},"Array.",[261,684,685],{"class":340},"isArray",[261,687,688],{"class":320},"(data?.events)) ",[261,690,426],{"class":267},[261,692,693],{"class":278}," ''",[261,695,321],{"class":320},[261,697,698],{"class":263,"line":411},[261,699,328],{"emptyLinePlaceholder":327},[261,701,702,705,708,710,712],{"class":263,"line":417},[261,703,704],{"class":267},"  let",[261,706,707],{"class":320}," srt ",[261,709,292],{"class":267},[261,711,693],{"class":278},[261,713,321],{"class":320},[261,715,716,718,721,723,726],{"class":263,"line":432},[261,717,704],{"class":267},[261,719,720],{"class":320}," index ",[261,722,292],{"class":267},[261,724,725],{"class":271}," 1",[261,727,321],{"class":320},[261,729,730],{"class":263,"line":437},[261,731,328],{"emptyLinePlaceholder":327},[261,733,734,736,738,740,743,745],{"class":263,"line":444},[261,735,453],{"class":267},[261,737,153],{"class":320},[261,739,268],{"class":267},[261,741,742],{"class":271}," event",[261,744,463],{"class":267},[261,746,747],{"class":320}," data.events) {\n",[261,749,750,752,754,756,759,762,765,768,771,774,777,780,783,785],{"class":263,"line":450},[261,751,492],{"class":267},[261,753,153],{"class":320},[261,755,497],{"class":267},[261,757,758],{"class":320},"event.segs ",[261,760,761],{"class":267},"||",[261,763,764],{"class":267}," !",[261,766,767],{"class":320},"event.tStartMs ",[261,769,770],{"class":267},"&&",[261,772,773],{"class":320}," event.tStartMs ",[261,775,776],{"class":267},"!==",[261,778,779],{"class":271}," 0",[261,781,782],{"class":320},") ",[261,784,503],{"class":267},[261,786,321],{"class":320},[261,788,789],{"class":263,"line":475},[261,790,328],{"emptyLinePlaceholder":327},[261,792,793,795,797,799,802,805,808,811,813,816,819,822,824,827,830,833],{"class":263,"line":489},[261,794,478],{"class":267},[261,796,481],{"class":271},[261,798,275],{"class":267},[261,800,801],{"class":320}," event.segs.",[261,803,804],{"class":340},"map",[261,806,807],{"class":320},"((",[261,809,810],{"class":346},"s",[261,812,782],{"class":320},[261,814,815],{"class":267},"=>",[261,817,818],{"class":320}," s.utf8).",[261,820,821],{"class":340},"join",[261,823,299],{"class":320},[261,825,826],{"class":278},"''",[261,828,829],{"class":320},").",[261,831,832],{"class":340},"trim",[261,834,835],{"class":320},"();\n",[261,837,838,840,842,844,847,849,852,855,858,861,864,866,868],{"class":263,"line":508},[261,839,492],{"class":267},[261,841,153],{"class":320},[261,843,497],{"class":267},[261,845,846],{"class":320},"text ",[261,848,761],{"class":267},[261,850,851],{"class":320}," text ",[261,853,854],{"class":267},"===",[261,856,857],{"class":278}," '",[261,859,860],{"class":271},"\\n",[261,862,863],{"class":278},"'",[261,865,782],{"class":320},[261,867,503],{"class":267},[261,869,321],{"class":320},[261,871,872],{"class":263,"line":513},[261,873,328],{"emptyLinePlaceholder":327},[261,875,876,878,881,883,886],{"class":263,"line":529},[261,877,478],{"class":267},[261,879,880],{"class":271}," startTime",[261,882,275],{"class":267},[261,884,885],{"class":340}," formatMsToSRT",[261,887,888],{"class":320},"(event.tStartMs);\n",[261,890,891,893,896,898,900,903,906,909,911,914],{"class":263,"line":542},[261,892,478],{"class":267},[261,894,895],{"class":271}," endTime",[261,897,275],{"class":267},[261,899,885],{"class":340},[261,901,902],{"class":320},"(event.tStartMs ",[261,904,905],{"class":267},"+",[261,907,908],{"class":320}," (event.dDurationMs ",[261,910,761],{"class":267},[261,912,913],{"class":271}," 2000",[261,915,916],{"class":320},"));\n",[261,918,919],{"class":263,"line":548},[261,920,328],{"emptyLinePlaceholder":327},[261,922,923,926,929,932,935,938,940,943,946,949,952,954,956,958,961,963,966,969],{"class":263,"line":553},[261,924,925],{"class":320},"    srt ",[261,927,928],{"class":267},"+=",[261,930,931],{"class":278}," `${",[261,933,934],{"class":320},"index",[261,936,937],{"class":278},"}",[261,939,860],{"class":271},[261,941,942],{"class":278},"${",[261,944,945],{"class":320},"startTime",[261,947,948],{"class":278},"} --> ${",[261,950,951],{"class":320},"endTime",[261,953,937],{"class":278},[261,955,860],{"class":271},[261,957,942],{"class":278},[261,959,960],{"class":320},"text",[261,962,937],{"class":278},[261,964,965],{"class":271},"\\n\\n",[261,967,968],{"class":278},"`",[261,970,321],{"class":320},[261,972,973,976,979],{"class":263,"line":563},[261,974,975],{"class":320},"    index",[261,977,978],{"class":267},"++",[261,980,321],{"class":320},[261,982,984],{"class":263,"line":983},20,[261,985,545],{"class":320},[261,987,989],{"class":263,"line":988},21,[261,990,328],{"emptyLinePlaceholder":327},[261,992,994,996],{"class":263,"line":993},22,[261,995,556],{"class":267},[261,997,998],{"class":320}," srt;\n",[261,1000,1002],{"class":263,"line":1001},23,[261,1003,566],{"class":320},[10,1005,1006,1007,1010,1011,1014,1015,1018,1019,1021],{},"La especificación que escribí al empezar traía esta función casi entera, con un fallo: comprobaba ",[19,1008,1009],{},"!event.tStartMs",", y como ",[19,1012,1013],{},"0"," es falso, el subtítulo que empieza en el milisegundo cero desaparecía. Lo corregí ya en la implementación. Hay eventos sin ",[19,1016,1017],{},"segs"," (YouTube los usa para marcar ventanas y saltos de línea) y eventos que solo traen un ",[19,1020,860],{},", 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.",[10,1023,1024,1025,1028,1029,1031,1032,1035],{},"La comprobación de ",[19,1026,1027],{},"events"," llegó después, con respuestas reales. Una ",[19,1030,238],{}," caducada, o cualquier otro fallo, puede devolver ",[19,1033,1034],{},"{}"," 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.",[615,1037,1039],{"id":1038},"mensajes-entre-el-popup-y-la-pestaña","Mensajes entre el popup y la pestaña",[10,1041,1042,1043,1046],{},"El popup pregunta al content script con ",[19,1044,1045],{},"browser.tabs.sendMessage",", y al principio el listener devolvía una promesa:",[252,1048,1050],{"className":254,"code":1049,"language":256,"meta":257,"style":257},"browser.runtime.onMessage.addListener(async (message) => {\n  if (message?.type === 'GET_CAPTION_TRACKS') return getCaptionData();\n});\n",[19,1051,1052,1076,1097],{"__ignoreMap":257},[261,1053,1054,1057,1060,1062,1065,1067,1070,1072,1074],{"class":263,"line":264},[261,1055,1056],{"class":320},"browser.runtime.onMessage.",[261,1058,1059],{"class":340},"addListener",[261,1061,299],{"class":320},[261,1063,1064],{"class":267},"async",[261,1066,153],{"class":320},[261,1068,1069],{"class":346},"message",[261,1071,782],{"class":320},[261,1073,815],{"class":267},[261,1075,372],{"class":320},[261,1077,1078,1080,1083,1085,1088,1090,1092,1095],{"class":263,"line":324},[261,1079,420],{"class":267},[261,1081,1082],{"class":320}," (message?.type ",[261,1084,854],{"class":267},[261,1086,1087],{"class":278}," 'GET_CAPTION_TRACKS'",[261,1089,782],{"class":320},[261,1091,426],{"class":267},[261,1093,1094],{"class":340}," getCaptionData",[261,1096,835],{"class":320},[261,1098,1099],{"class":263,"line":331},[261,1100,1101],{"class":320},"});\n",[10,1103,1104,1105,1108,1109,1111,1112,1115,1116,1119,1120,1123,1124,350],{},"En Firefox eso funciona porque su ",[19,1106,1107],{},"browser"," está basado en promesas. Pero el ",[19,1110,1107],{}," global de WXT no es el ",[24,1113,1114],{},"webextension-polyfill",", como yo creía y como llegué a poner en la documentación. Es un alias fino de ",[19,1117,1118],{},"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 ",[19,1121,1122],{},"true"," para dejar el canal abierto y contestar con ",[19,1125,1126],{},"sendResponse",[252,1128,1130],{"className":254,"code":1129,"language":256,"meta":257,"style":257},"browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {\n  if (message?.type === 'GET_CAPTION_TRACKS') {\n    getCaptionData().then(sendResponse);\n    return true;\n  }\n  \u002F\u002F ...\n});\n",[19,1131,1132,1158,1171,1185,1195,1199,1204],{"__ignoreMap":257},[261,1133,1134,1136,1138,1140,1142,1145,1148,1150,1152,1154,1156],{"class":263,"line":264},[261,1135,1056],{"class":320},[261,1137,1059],{"class":340},[261,1139,807],{"class":320},[261,1141,1069],{"class":346},[261,1143,1144],{"class":320},", ",[261,1146,1147],{"class":346},"_sender",[261,1149,1144],{"class":320},[261,1151,1126],{"class":346},[261,1153,782],{"class":320},[261,1155,815],{"class":267},[261,1157,372],{"class":320},[261,1159,1160,1162,1164,1166,1168],{"class":263,"line":324},[261,1161,420],{"class":267},[261,1163,1082],{"class":320},[261,1165,854],{"class":267},[261,1167,1087],{"class":278},[261,1169,1170],{"class":320},") {\n",[261,1172,1173,1176,1179,1182],{"class":263,"line":331},[261,1174,1175],{"class":340},"    getCaptionData",[261,1177,1178],{"class":320},"().",[261,1180,1181],{"class":340},"then",[261,1183,1184],{"class":320},"(sendResponse);\n",[261,1186,1187,1190,1193],{"class":263,"line":375},[261,1188,1189],{"class":267},"    return",[261,1191,1192],{"class":271}," true",[261,1194,321],{"class":320},[261,1196,1197],{"class":263,"line":411},[261,1198,545],{"class":320},[261,1200,1201],{"class":263,"line":417},[261,1202,1203],{"class":440},"  \u002F\u002F ...\n",[261,1205,1206],{"class":263,"line":432},[261,1207,1101],{"class":320},[10,1209,1210,1211,1214],{},"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. ",[19,1212,1213],{},"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.",[43,1216,1218,1219,1222],{"id":1217},"el-muro-200-ok-y-el-cuerpo-vacío","El muro: ",[19,1220,1221],{},"200 OK"," y el cuerpo vacío",[10,1224,1225,1226,1229,1230,1232],{},"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 ",[19,1227,1228],{},"timedtext"," respondía ",[19,1231,1221],{},", sin error, sin redirección y con cero bytes.",[10,1234,1235,1236,1239,1240,1243,1244,1247],{},"Mi primera hipótesis fue el origen. El popup hace la petición desde ",[19,1237,1238],{},"chrome-extension:\u002F\u002F…",", y YouTube podía estar rechazando cualquier cosa que no saliera de ",[19,1241,1242],{},"youtube.com",". Así que moví el ",[19,1245,1246],{},"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.",[10,1249,1250,1251,1253,1254,1256,1257,1259,1260,27,1263,243],{},"Lo que dio con la causa fue comparar. Activé los subtítulos en el reproductor, busqué la petición a ",[19,1252,1228],{}," que hacía YouTube y la puse al lado de la ",[19,1255,238],{}," que yo sacaba de ",[19,1258,218],{},". Eran casi iguales, salvo por dos parámetros que la del reproductor tenía y la mía no: ",[19,1261,1262],{},"potc=1",[19,1264,1265],{},"pot=\u003Cuna cadena larga>",[10,1267,1268,1271,1272,153,1274,1277,1278,1281,1282,1285],{},[19,1269,1270],{},"pot"," es el ",[51,1273,53],{},[24,1275,1276],{},"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 ",[19,1279,1280],{},"403",", sin un mensaje de error, con un ",[19,1283,1284],{},"200"," vacío. Desde el exterior, una URL sin token se comporta como un vídeo sin subtítulos.",[10,1287,1288],{},"Lo apunté todo en el ROADMAP del proyecto y lo dejé ahí. Pasaron seis semanas entre ese commit y el siguiente.",[615,1290,1292],{"id":1291},"las-dos-salidas","Las dos salidas",[10,1294,1295],{},"Había dos maneras de conseguir una respuesta con token:",[1297,1298,1299,1305],"ol",{},[122,1300,1301,1304],{},[51,1302,1303],{},"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.",[122,1306,1307,1310],{},[51,1308,1309],{},"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.",[10,1312,1313],{},"Elegí la segunda. Para hacerla hacía falta algo que el content script normal no puede hacer.",[43,1315,1317],{"id":1316},"el-mundo-main","El mundo MAIN",[10,1319,1320,1321,1324,1325,1328,1329,1332,1333,1336,1337,1340],{},"Un content script corre en un ",[51,1322,1323],{},"mundo aislado",": ve el mismo DOM que la página, pero tiene su propio ",[19,1326,1327],{},"window"," y su propio JavaScript. Si el content script envuelve ",[19,1330,1331],{},"window.fetch",", envuelve el suyo, no el que usa YouTube. Y el elemento ",[19,1334,1335],{},"#movie_player"," que ve es un elemento del DOM sin los métodos que el JavaScript de YouTube le ha añadido, así que ",[19,1338,1339],{},"setOption"," no existe.",[10,1342,1343,1344,1347],{},"Manifest V3 permite inyectar un content script en el mundo de la página, ",[19,1345,1346],{},"MAIN",". Con WXT es una opción más:",[252,1349,1351],{"className":254,"code":1350,"language":256,"meta":257,"style":257},"export default defineContentScript({\n  matches: ['*:\u002F\u002F*.youtube.com\u002F*'],\n  world: 'MAIN',\n  runAt: 'document_start',\n  main() {\n    \u002F\u002F ...\n  },\n});\n",[19,1352,1353,1366,1377,1388,1398,1406,1411,1416],{"__ignoreMap":257},[261,1354,1355,1357,1360,1363],{"class":263,"line":264},[261,1356,334],{"class":267},[261,1358,1359],{"class":267}," default",[261,1361,1362],{"class":340}," defineContentScript",[261,1364,1365],{"class":320},"({\n",[261,1367,1368,1371,1374],{"class":263,"line":324},[261,1369,1370],{"class":320},"  matches: [",[261,1372,1373],{"class":278},"'*:\u002F\u002F*.youtube.com\u002F*'",[261,1375,1376],{"class":320},"],\n",[261,1378,1379,1382,1385],{"class":263,"line":331},[261,1380,1381],{"class":320},"  world: ",[261,1383,1384],{"class":278},"'MAIN'",[261,1386,1387],{"class":320},",\n",[261,1389,1390,1393,1396],{"class":263,"line":375},[261,1391,1392],{"class":320},"  runAt: ",[261,1394,1395],{"class":278},"'document_start'",[261,1397,1387],{"class":320},[261,1399,1400,1403],{"class":263,"line":411},[261,1401,1402],{"class":340},"  main",[261,1404,1405],{"class":320},"() {\n",[261,1407,1408],{"class":263,"line":417},[261,1409,1410],{"class":440},"    \u002F\u002F ...\n",[261,1412,1413],{"class":263,"line":432},[261,1414,1415],{"class":320},"  },\n",[261,1417,1418],{"class":263,"line":437},[261,1419,1101],{"class":320},[10,1421,1422,1425,1426,1428,1429,1431],{},[19,1423,1424],{},"document_start"," es imprescindible. El script tiene que envolver ",[19,1427,1246],{}," antes de que el código de YouTube se guarde una referencia al original. Si llega tarde, YouTube seguirá llamando al ",[19,1430,1246],{}," de siempre y el envoltorio no verá nada.",[10,1433,1434,1435,1437,1438,1441,1442,1444],{},"El precio del mundo ",[19,1436,1346],{}," es que ahí no hay APIs de extensión: ni ",[19,1439,1440],{},"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 ",[19,1443,1346],{}," habla con el reproductor y el aislado habla con la extensión.",[615,1446,1448],{"id":1447},"escuchar-sin-molestar","Escuchar sin molestar",[10,1450,1451,1452,1454],{},"El envoltorio de ",[19,1453,1246],{}," deja pasar sin tocar todo lo que no le interesa, y de lo que le interesa lee una copia:",[252,1456,1458],{"className":254,"code":1457,"language":256,"meta":257,"style":257},"const wrappedFetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise\u003CResponse> {\n  let url = '';\n  \u002F\u002F ... sacar la URL de un string, una URL o un Request\n\n  if (!url || (!url.includes('\u002Fapi\u002Ftimedtext') && !url.includes('\u002Fyoutubei\u002Fv1\u002Fplayer'))) {\n    return init !== undefined\n      ? Reflect.apply(originalFetch, window, [input, init])\n      : Reflect.apply(originalFetch, window, [input]);\n  }\n\n  const response = init !== undefined\n    ? await Reflect.apply(originalFetch, window, [input, init])\n    : await Reflect.apply(originalFetch, window, [input]);\n\n  if (url.includes('\u002Fapi\u002Ftimedtext')) {\n    const text = await response.clone().text();\n    if (text) {\n      const data = JSON.parse(text);\n      for (const handler of fetchHandlers) handler(data, url);\n    }\n  }\n\n  return response;\n};\n",[19,1459,1460,1515,1528,1533,1537,1583,1595,1609,1621,1625,1629,1644,1658,1671,1675,1691,1713,1720,1740,1763,1768,1772,1776,1783],{"__ignoreMap":257},[261,1461,1462,1464,1467,1469,1472,1474,1476,1479,1481,1484,1486,1489,1491,1494,1496,1499,1501,1503,1506,1509,1512],{"class":263,"line":264},[261,1463,268],{"class":267},[261,1465,1466],{"class":340}," wrappedFetch",[261,1468,275],{"class":267},[261,1470,1471],{"class":267}," async",[261,1473,337],{"class":267},[261,1475,153],{"class":320},[261,1477,1478],{"class":346},"input",[261,1480,350],{"class":267},[261,1482,1483],{"class":340}," RequestInfo",[261,1485,366],{"class":267},[261,1487,1488],{"class":340}," URL",[261,1490,1144],{"class":320},[261,1492,1493],{"class":346},"init",[261,1495,403],{"class":267},[261,1497,1498],{"class":340}," RequestInit",[261,1500,654],{"class":320},[261,1502,350],{"class":267},[261,1504,1505],{"class":340}," Promise",[261,1507,1508],{"class":320},"\u003C",[261,1510,1511],{"class":340},"Response",[261,1513,1514],{"class":320},"> {\n",[261,1516,1517,1519,1522,1524,1526],{"class":263,"line":324},[261,1518,704],{"class":267},[261,1520,1521],{"class":320}," url ",[261,1523,292],{"class":267},[261,1525,693],{"class":278},[261,1527,321],{"class":320},[261,1529,1530],{"class":263,"line":331},[261,1531,1532],{"class":440},"  \u002F\u002F ... sacar la URL de un string, una URL o un Request\n",[261,1534,1535],{"class":263,"line":375},[261,1536,328],{"emptyLinePlaceholder":327},[261,1538,1539,1541,1543,1545,1548,1550,1552,1554,1557,1560,1562,1565,1567,1569,1571,1573,1575,1577,1580],{"class":263,"line":411},[261,1540,420],{"class":267},[261,1542,153],{"class":320},[261,1544,497],{"class":267},[261,1546,1547],{"class":320},"url ",[261,1549,761],{"class":267},[261,1551,153],{"class":320},[261,1553,497],{"class":267},[261,1555,1556],{"class":320},"url.",[261,1558,1559],{"class":340},"includes",[261,1561,299],{"class":320},[261,1563,1564],{"class":278},"'\u002Fapi\u002Ftimedtext'",[261,1566,782],{"class":320},[261,1568,770],{"class":267},[261,1570,764],{"class":267},[261,1572,1556],{"class":320},[261,1574,1559],{"class":340},[261,1576,299],{"class":320},[261,1578,1579],{"class":278},"'\u002Fyoutubei\u002Fv1\u002Fplayer'",[261,1581,1582],{"class":320},"))) {\n",[261,1584,1585,1587,1590,1592],{"class":263,"line":417},[261,1586,1189],{"class":267},[261,1588,1589],{"class":320}," init ",[261,1591,776],{"class":267},[261,1593,1594],{"class":271}," undefined\n",[261,1596,1597,1600,1603,1606],{"class":263,"line":432},[261,1598,1599],{"class":267},"      ?",[261,1601,1602],{"class":320}," Reflect.",[261,1604,1605],{"class":340},"apply",[261,1607,1608],{"class":320},"(originalFetch, window, [input, init])\n",[261,1610,1611,1614,1616,1618],{"class":263,"line":437},[261,1612,1613],{"class":267},"      :",[261,1615,1602],{"class":320},[261,1617,1605],{"class":340},[261,1619,1620],{"class":320},"(originalFetch, window, [input]);\n",[261,1622,1623],{"class":263,"line":444},[261,1624,545],{"class":320},[261,1626,1627],{"class":263,"line":450},[261,1628,328],{"emptyLinePlaceholder":327},[261,1630,1631,1633,1636,1638,1640,1642],{"class":263,"line":475},[261,1632,378],{"class":267},[261,1634,1635],{"class":271}," response",[261,1637,275],{"class":267},[261,1639,1589],{"class":320},[261,1641,776],{"class":267},[261,1643,1594],{"class":271},[261,1645,1646,1649,1652,1654,1656],{"class":263,"line":489},[261,1647,1648],{"class":267},"    ?",[261,1650,1651],{"class":267}," await",[261,1653,1602],{"class":320},[261,1655,1605],{"class":340},[261,1657,1608],{"class":320},[261,1659,1660,1663,1665,1667,1669],{"class":263,"line":508},[261,1661,1662],{"class":267},"    :",[261,1664,1651],{"class":267},[261,1666,1602],{"class":320},[261,1668,1605],{"class":340},[261,1670,1620],{"class":320},[261,1672,1673],{"class":263,"line":513},[261,1674,328],{"emptyLinePlaceholder":327},[261,1676,1677,1679,1682,1684,1686,1688],{"class":263,"line":529},[261,1678,420],{"class":267},[261,1680,1681],{"class":320}," (url.",[261,1683,1559],{"class":340},[261,1685,299],{"class":320},[261,1687,1564],{"class":278},[261,1689,1690],{"class":320},")) {\n",[261,1692,1693,1695,1697,1699,1701,1704,1707,1709,1711],{"class":263,"line":542},[261,1694,478],{"class":267},[261,1696,481],{"class":271},[261,1698,275],{"class":267},[261,1700,1651],{"class":267},[261,1702,1703],{"class":320}," response.",[261,1705,1706],{"class":340},"clone",[261,1708,1178],{"class":320},[261,1710,960],{"class":340},[261,1712,835],{"class":320},[261,1714,1715,1717],{"class":263,"line":548},[261,1716,492],{"class":267},[261,1718,1719],{"class":320}," (text) {\n",[261,1721,1722,1725,1728,1730,1733,1735,1738],{"class":263,"line":553},[261,1723,1724],{"class":267},"      const",[261,1726,1727],{"class":271}," data",[261,1729,275],{"class":267},[261,1731,1732],{"class":271}," JSON",[261,1734,243],{"class":320},[261,1736,1737],{"class":340},"parse",[261,1739,526],{"class":320},[261,1741,1742,1745,1747,1749,1752,1754,1757,1760],{"class":263,"line":563},[261,1743,1744],{"class":267},"      for",[261,1746,153],{"class":320},[261,1748,268],{"class":267},[261,1750,1751],{"class":271}," handler",[261,1753,463],{"class":267},[261,1755,1756],{"class":320}," fetchHandlers) ",[261,1758,1759],{"class":340},"handler",[261,1761,1762],{"class":320},"(data, url);\n",[261,1764,1765],{"class":263,"line":983},[261,1766,1767],{"class":320},"    }\n",[261,1769,1770],{"class":263,"line":988},[261,1771,545],{"class":320},[261,1773,1774],{"class":263,"line":993},[261,1775,328],{"emptyLinePlaceholder":327},[261,1777,1778,1780],{"class":263,"line":1001},[261,1779,556],{"class":267},[261,1781,1782],{"class":320}," response;\n",[261,1784,1786],{"class":263,"line":1785},24,[261,1787,1788],{"class":320},"};\n",[10,1790,1791],{},"Tres detalles de esta función vienen de fallos concretos:",[119,1793,1794,1802,1827],{},[122,1795,1796,1801],{},[51,1797,1798],{},[19,1799,1800],{},"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.",[122,1803,1804,1809,1810,1813,1814,1817,1818,1820,1821,1823,1824,1826],{},[51,1805,1806],{},[19,1807,1808],{},"Reflect.apply(originalFetch, window, …)",". Llamar a ",[19,1811,1812],{},"originalFetch(input)"," a secas pierde el ",[19,1815,1816],{},"this",", y ",[19,1819,1246],{}," lanza \"Illegal invocation\" si no se llama sobre ",[19,1822,1327],{},". Pasar ",[19,1825,1493],{}," solo cuando existe evita cambiar la aridad de llamadas que YouTube hace con un solo argumento.",[122,1828,1829,1832,1833,1836,1837,1839],{},[51,1830,1831],{},"Una marca para no envolver dos veces."," El envoltorio lleva un ",[19,1834,1835],{},"Symbol"," y, si ",[19,1838,1331],{}," ya 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.",[615,1841,1843,1844],{"id":1842},"el-reproductor-no-usa-fetch","El reproductor no usa ",[19,1845,1246],{},[10,1847,1848,1849,1851,1852,1854,1855,1857,1858,243],{},"Con el interceptor de ",[19,1850,1246],{}," 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 ",[19,1853,1270],{},", pero no pasaba por ",[19,1856,1246],{},". El reproductor de YouTube pide los subtítulos con ",[19,1859,1860],{},"XMLHttpRequest",[10,1862,1863,1864,1866,1867,1870,1871,1874],{},"Así que hay un segundo interceptor, sobre el prototipo de ",[19,1865,1860],{},". ",[19,1868,1869],{},"open"," apunta la URL en la propia instancia y ",[19,1872,1873],{},"send"," engancha un listener si es una de subtítulos:",[252,1876,1878],{"className":254,"code":1877,"language":256,"meta":257,"style":257},"XHR.prototype.open = function (this: any, method: string, url: string | URL, ...rest: any[]) {\n  this.__yt_sub_url = typeof url === 'string' ? url : url?.toString?.() || '';\n  return Reflect.apply(originalOpen, this, [method, url, ...rest]);\n};\n\nXHR.prototype.send = function (this: any, body?: any) {\n  const url = this.__yt_sub_url;\n  if (url && url.includes('\u002Fapi\u002Ftimedtext')) {\n    let settled = false;\n    const handleResponse = () => {\n      if (settled || this.readyState !== 4) return;\n      settled = true;\n      \u002F\u002F leer response según responseType y avisar a los handlers\n    };\n    this.addEventListener('readystatechange', handleResponse, false);\n    this.addEventListener('load', handleResponse, false);\n  }\n  return Reflect.apply(originalSend, this, [body]);\n};\n",[19,1879,1880,1944,1986,2007,2011,2015,2050,2065,2085,2100,2116,2142,2153,2158,2163,2187,2206,2210,2226],{"__ignoreMap":257},[261,1881,1882,1885,1887,1890,1892,1894,1896,1898,1900,1902,1904,1907,1909,1912,1914,1916,1918,1921,1923,1925,1927,1929,1931,1934,1937,1939,1941],{"class":263,"line":264},[261,1883,1884],{"class":271},"XHR",[261,1886,243],{"class":320},[261,1888,1889],{"class":271},"prototype",[261,1891,243],{"class":320},[261,1893,1869],{"class":340},[261,1895,275],{"class":267},[261,1897,337],{"class":267},[261,1899,153],{"class":320},[261,1901,1816],{"class":271},[261,1903,350],{"class":267},[261,1905,1906],{"class":271}," any",[261,1908,1144],{"class":320},[261,1910,1911],{"class":346},"method",[261,1913,350],{"class":267},[261,1915,659],{"class":271},[261,1917,1144],{"class":320},[261,1919,1920],{"class":346},"url",[261,1922,350],{"class":267},[261,1924,659],{"class":271},[261,1926,366],{"class":267},[261,1928,1488],{"class":340},[261,1930,1144],{"class":320},[261,1932,1933],{"class":267},"...",[261,1935,1936],{"class":346},"rest",[261,1938,350],{"class":267},[261,1940,1906],{"class":271},[261,1942,1943],{"class":320},"[]) {\n",[261,1945,1946,1949,1952,1954,1957,1959,1961,1964,1967,1969,1971,1974,1977,1980,1982,1984],{"class":263,"line":324},[261,1947,1948],{"class":271},"  this",[261,1950,1951],{"class":320},".__yt_sub_url ",[261,1953,292],{"class":267},[261,1955,1956],{"class":267}," typeof",[261,1958,1521],{"class":320},[261,1960,854],{"class":267},[261,1962,1963],{"class":278}," 'string'",[261,1965,1966],{"class":267}," ?",[261,1968,1521],{"class":320},[261,1970,350],{"class":267},[261,1972,1973],{"class":320}," url?.",[261,1975,1976],{"class":340},"toString",[261,1978,1979],{"class":320},"?.() ",[261,1981,761],{"class":267},[261,1983,693],{"class":278},[261,1985,321],{"class":320},[261,1987,1988,1990,1992,1994,1997,1999,2002,2004],{"class":263,"line":331},[261,1989,556],{"class":267},[261,1991,1602],{"class":320},[261,1993,1605],{"class":340},[261,1995,1996],{"class":320},"(originalOpen, ",[261,1998,1816],{"class":271},[261,2000,2001],{"class":320},", [method, url, ",[261,2003,1933],{"class":267},[261,2005,2006],{"class":320},"rest]);\n",[261,2008,2009],{"class":263,"line":375},[261,2010,1788],{"class":320},[261,2012,2013],{"class":263,"line":411},[261,2014,328],{"emptyLinePlaceholder":327},[261,2016,2017,2019,2021,2023,2025,2027,2029,2031,2033,2035,2037,2039,2041,2044,2046,2048],{"class":263,"line":417},[261,2018,1884],{"class":271},[261,2020,243],{"class":320},[261,2022,1889],{"class":271},[261,2024,243],{"class":320},[261,2026,1873],{"class":340},[261,2028,275],{"class":267},[261,2030,337],{"class":267},[261,2032,153],{"class":320},[261,2034,1816],{"class":271},[261,2036,350],{"class":267},[261,2038,1906],{"class":271},[261,2040,1144],{"class":320},[261,2042,2043],{"class":346},"body",[261,2045,403],{"class":267},[261,2047,1906],{"class":271},[261,2049,1170],{"class":320},[261,2051,2052,2054,2057,2059,2062],{"class":263,"line":432},[261,2053,378],{"class":267},[261,2055,2056],{"class":271}," url",[261,2058,275],{"class":267},[261,2060,2061],{"class":271}," this",[261,2063,2064],{"class":320},".__yt_sub_url;\n",[261,2066,2067,2069,2072,2074,2077,2079,2081,2083],{"class":263,"line":437},[261,2068,420],{"class":267},[261,2070,2071],{"class":320}," (url ",[261,2073,770],{"class":267},[261,2075,2076],{"class":320}," url.",[261,2078,1559],{"class":340},[261,2080,299],{"class":320},[261,2082,1564],{"class":278},[261,2084,1690],{"class":320},[261,2086,2087,2090,2093,2095,2098],{"class":263,"line":444},[261,2088,2089],{"class":267},"    let",[261,2091,2092],{"class":320}," settled ",[261,2094,292],{"class":267},[261,2096,2097],{"class":271}," false",[261,2099,321],{"class":320},[261,2101,2102,2104,2107,2109,2112,2114],{"class":263,"line":450},[261,2103,478],{"class":267},[261,2105,2106],{"class":340}," handleResponse",[261,2108,275],{"class":267},[261,2110,2111],{"class":320}," () ",[261,2113,815],{"class":267},[261,2115,372],{"class":320},[261,2117,2118,2121,2124,2126,2128,2131,2133,2136,2138,2140],{"class":263,"line":475},[261,2119,2120],{"class":267},"      if",[261,2122,2123],{"class":320}," (settled ",[261,2125,761],{"class":267},[261,2127,2061],{"class":271},[261,2129,2130],{"class":320},".readyState ",[261,2132,776],{"class":267},[261,2134,2135],{"class":271}," 4",[261,2137,782],{"class":320},[261,2139,426],{"class":267},[261,2141,321],{"class":320},[261,2143,2144,2147,2149,2151],{"class":263,"line":489},[261,2145,2146],{"class":320},"      settled ",[261,2148,292],{"class":267},[261,2150,1192],{"class":271},[261,2152,321],{"class":320},[261,2154,2155],{"class":263,"line":508},[261,2156,2157],{"class":440},"      \u002F\u002F leer response según responseType y avisar a los handlers\n",[261,2159,2160],{"class":263,"line":513},[261,2161,2162],{"class":320},"    };\n",[261,2164,2165,2168,2170,2173,2175,2178,2181,2184],{"class":263,"line":529},[261,2166,2167],{"class":271},"    this",[261,2169,243],{"class":320},[261,2171,2172],{"class":340},"addEventListener",[261,2174,299],{"class":320},[261,2176,2177],{"class":278},"'readystatechange'",[261,2179,2180],{"class":320},", handleResponse, ",[261,2182,2183],{"class":271},"false",[261,2185,2186],{"class":320},");\n",[261,2188,2189,2191,2193,2195,2197,2200,2202,2204],{"class":263,"line":542},[261,2190,2167],{"class":271},[261,2192,243],{"class":320},[261,2194,2172],{"class":340},[261,2196,299],{"class":320},[261,2198,2199],{"class":278},"'load'",[261,2201,2180],{"class":320},[261,2203,2183],{"class":271},[261,2205,2186],{"class":320},[261,2207,2208],{"class":263,"line":548},[261,2209,545],{"class":320},[261,2211,2212,2214,2216,2218,2221,2223],{"class":263,"line":553},[261,2213,556],{"class":267},[261,2215,1602],{"class":320},[261,2217,1605],{"class":340},[261,2219,2220],{"class":320},"(originalSend, ",[261,2222,1816],{"class":271},[261,2224,2225],{"class":320},", [body]);\n",[261,2227,2228],{"class":263,"line":563},[261,2229,1788],{"class":320},[10,2231,2232,2233,27,2236,2239,2240,2243,2244,2247,2248,2251,2252,2255,2256,2258],{},"Escucho ",[19,2234,2235],{},"readystatechange",[19,2237,2238],{},"load"," a la vez para no depender de cuál de los dos atiende el reproductor, y el ",[19,2241,2242],{},"settled"," evita procesar la misma respuesta dos veces. La lectura cubre los tres ",[19,2245,2246],{},"responseType"," posibles: ",[19,2249,2250],{},"json"," (ya viene parseado), texto (",[19,2253,2254],{},"responseText",") y cualquier otro, con un intento de ",[19,2257,590],{},". La respuesta del XHR se lee sin consumir nada, así que aquí no hace falta clonar.",[10,2260,2261,2262,2264,2265,2267],{},"Me quedé con los dos interceptores. Nada garantiza que el reproductor no vuelva a usar ",[19,2263,1246],{}," en una versión futura, y el de ",[19,2266,1246],{}," lo necesito igualmente para otra cosa que cuento más abajo.",[615,2269,2271],{"id":2270},"pedirle-la-pista-al-reproductor","Pedirle la pista al reproductor",[10,2273,2274,2277],{},[19,2275,2276],{},"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:",[252,2279,2281],{"className":254,"code":2280,"language":256,"meta":257,"style":257},"const previousTrack = player.getOption?.('captions', 'track') || {};\nconst previousTranslation = player.getOption?.('captions', 'translationLanguage') ?? null;\nconst wasSubtitlesOn = isSubtitlesActive();\n",[19,2282,2283,2316,2347],{"__ignoreMap":257},[261,2284,2285,2287,2290,2292,2295,2298,2301,2304,2306,2309,2311,2313],{"class":263,"line":264},[261,2286,268],{"class":267},[261,2288,2289],{"class":271}," previousTrack",[261,2291,275],{"class":267},[261,2293,2294],{"class":320}," player.",[261,2296,2297],{"class":340},"getOption",[261,2299,2300],{"class":320},"?.(",[261,2302,2303],{"class":278},"'captions'",[261,2305,1144],{"class":320},[261,2307,2308],{"class":278},"'track'",[261,2310,782],{"class":320},[261,2312,761],{"class":267},[261,2314,2315],{"class":320}," {};\n",[261,2317,2318,2320,2323,2325,2327,2329,2331,2333,2335,2338,2340,2343,2345],{"class":263,"line":324},[261,2319,268],{"class":267},[261,2321,2322],{"class":271}," previousTranslation",[261,2324,275],{"class":267},[261,2326,2294],{"class":320},[261,2328,2297],{"class":340},[261,2330,2300],{"class":320},[261,2332,2303],{"class":278},[261,2334,1144],{"class":320},[261,2336,2337],{"class":278},"'translationLanguage'",[261,2339,782],{"class":320},[261,2341,2342],{"class":267},"??",[261,2344,369],{"class":271},[261,2346,321],{"class":320},[261,2348,2349,2351,2354,2356,2359],{"class":263,"line":331},[261,2350,268],{"class":267},[261,2352,2353],{"class":271}," wasSubtitlesOn",[261,2355,275],{"class":267},[261,2357,2358],{"class":340}," isSubtitlesActive",[261,2360,835],{"class":320},[10,2362,2363],{},"Después carga el módulo de subtítulos, elige la pista y enciende los subtítulos:",[252,2365,2367],{"className":254,"code":2366,"language":256,"meta":257,"style":257},"player.loadModule?.('captions');\n\n\u002F\u002F If the target track is already the active track, toggle track to force a fresh fetch\nif (previousTrack?.languageCode === targetTrack.languageCode && !targetTrack.tlang) {\n  player.setOption('captions', 'track', {});\n}\n\nconst trackOption: Record\u003Cstring, any> = { languageCode: targetTrack.languageCode };\nif (targetTrack.kind) trackOption.kind = targetTrack.kind;\n\nplayer.setOption('captions', 'track', trackOption);\nif (targetTrack.tlang) {\n  player.setOption('captions', 'translationLanguage', { languageCode: targetTrack.tlang });\n}\n\nturnSubtitlesOn();\n",[19,2368,2369,2383,2387,2392,2412,2430,2434,2438,2468,2480,2484,2501,2508,2525,2529,2533],{"__ignoreMap":257},[261,2370,2371,2374,2377,2379,2381],{"class":263,"line":264},[261,2372,2373],{"class":320},"player.",[261,2375,2376],{"class":340},"loadModule",[261,2378,2300],{"class":320},[261,2380,2303],{"class":278},[261,2382,2186],{"class":320},[261,2384,2385],{"class":263,"line":324},[261,2386,328],{"emptyLinePlaceholder":327},[261,2388,2389],{"class":263,"line":331},[261,2390,2391],{"class":440},"\u002F\u002F If the target track is already the active track, toggle track to force a fresh fetch\n",[261,2393,2394,2397,2400,2402,2405,2407,2409],{"class":263,"line":375},[261,2395,2396],{"class":267},"if",[261,2398,2399],{"class":320}," (previousTrack?.languageCode ",[261,2401,854],{"class":267},[261,2403,2404],{"class":320}," targetTrack.languageCode ",[261,2406,770],{"class":267},[261,2408,764],{"class":267},[261,2410,2411],{"class":320},"targetTrack.tlang) {\n",[261,2413,2414,2417,2419,2421,2423,2425,2427],{"class":263,"line":411},[261,2415,2416],{"class":320},"  player.",[261,2418,1339],{"class":340},[261,2420,299],{"class":320},[261,2422,2303],{"class":278},[261,2424,1144],{"class":320},[261,2426,2308],{"class":278},[261,2428,2429],{"class":320},", {});\n",[261,2431,2432],{"class":263,"line":417},[261,2433,566],{"class":320},[261,2435,2436],{"class":263,"line":432},[261,2437,328],{"emptyLinePlaceholder":327},[261,2439,2440,2442,2445,2447,2450,2452,2455,2457,2460,2463,2465],{"class":263,"line":437},[261,2441,268],{"class":267},[261,2443,2444],{"class":271}," trackOption",[261,2446,350],{"class":267},[261,2448,2449],{"class":340}," Record",[261,2451,1508],{"class":320},[261,2453,2454],{"class":271},"string",[261,2456,1144],{"class":320},[261,2458,2459],{"class":271},"any",[261,2461,2462],{"class":320},"> ",[261,2464,292],{"class":267},[261,2466,2467],{"class":320}," { languageCode: targetTrack.languageCode };\n",[261,2469,2470,2472,2475,2477],{"class":263,"line":444},[261,2471,2396],{"class":267},[261,2473,2474],{"class":320}," (targetTrack.kind) trackOption.kind ",[261,2476,292],{"class":267},[261,2478,2479],{"class":320}," targetTrack.kind;\n",[261,2481,2482],{"class":263,"line":450},[261,2483,328],{"emptyLinePlaceholder":327},[261,2485,2486,2488,2490,2492,2494,2496,2498],{"class":263,"line":475},[261,2487,2373],{"class":320},[261,2489,1339],{"class":340},[261,2491,299],{"class":320},[261,2493,2303],{"class":278},[261,2495,1144],{"class":320},[261,2497,2308],{"class":278},[261,2499,2500],{"class":320},", trackOption);\n",[261,2502,2503,2505],{"class":263,"line":489},[261,2504,2396],{"class":267},[261,2506,2507],{"class":320}," (targetTrack.tlang) {\n",[261,2509,2510,2512,2514,2516,2518,2520,2522],{"class":263,"line":508},[261,2511,2416],{"class":320},[261,2513,1339],{"class":340},[261,2515,299],{"class":320},[261,2517,2303],{"class":278},[261,2519,1144],{"class":320},[261,2521,2337],{"class":278},[261,2523,2524],{"class":320},", { languageCode: targetTrack.tlang });\n",[261,2526,2527],{"class":263,"line":513},[261,2528,566],{"class":320},[261,2530,2531],{"class":263,"line":529},[261,2532,328],{"emptyLinePlaceholder":327},[261,2534,2535,2538],{"class":263,"line":542},[261,2536,2537],{"class":340},"turnSubtitlesOn",[261,2539,835],{"class":320},[10,2541,2542],{},"Cada línea de este bloque existe por un caso que fallaba:",[119,2544,2545,2551,2567,2577],{},[122,2546,2547,2550],{},[51,2548,2549],{},"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.",[122,2552,2553,2559,2560,2563,2564,2566],{},[51,2554,2555,2558],{},[19,2556,2557],{},"kind"," distingue pistas del mismo idioma."," Un vídeo puede tener inglés subido por el autor e inglés automático. Con solo ",[19,2561,2562],{},"languageCode",", el reproductor elige el que quiere. Con ",[19,2565,234],{}," 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.",[122,2568,2569,2572,2573,2576],{},[51,2570,2571],{},"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 ",[19,2574,2575],{},"tlang"," a su propia petición, con su token.",[122,2578,2579,2582,2583,2585,2586,2588,2589,2592,2593,2596,2597,2600,2601,2604,2605,2607],{},[51,2580,2581],{},"Encender los subtítulos."," Con los subtítulos apagados, ",[19,2584,1339],{}," cambia la pista pero el reproductor no la descarga hasta que hace falta enseñarla. ",[19,2587,2537],{}," prueba tres caminos por orden: ",[19,2590,2591],{},"toggleSubtitlesOn()"," si existe, ",[19,2594,2595],{},"toggleSubtitles()"," si están apagados, y como último recurso un clic en ",[19,2598,2599],{},".ytp-subtitles-button"," si su ",[19,2602,2603],{},"aria-pressed"," es ",[19,2606,2183],{},". Ninguno de los tres está documentado, así que me quedé con los tres.",[10,2609,2610],{},"Cuando llega la respuesta, o a los 6 segundos si no llega, se deshace todo:",[252,2612,2614],{"className":254,"code":2613,"language":256,"meta":257,"style":257},"promise.resolveWith = (data: any) => {\n  if (isSettled) return;\n  isSettled = true;\n  if (timer) clearTimeout(timer);\n  if (!wasSubtitlesOn) turnSubtitlesOff();\n  player.setOption?.('captions', 'track', previousTrack);\n  player.setOption?.('captions', 'translationLanguage', previousTranslation);\n  resolvePromise({ ok: true, data });\n};\n",[19,2615,2616,2640,2651,2662,2675,2691,2708,2725,2738],{"__ignoreMap":257},[261,2617,2618,2621,2624,2626,2628,2630,2632,2634,2636,2638],{"class":263,"line":264},[261,2619,2620],{"class":320},"promise.",[261,2622,2623],{"class":340},"resolveWith",[261,2625,275],{"class":267},[261,2627,153],{"class":320},[261,2629,646],{"class":346},[261,2631,350],{"class":267},[261,2633,1906],{"class":271},[261,2635,782],{"class":320},[261,2637,815],{"class":267},[261,2639,372],{"class":320},[261,2641,2642,2644,2647,2649],{"class":263,"line":324},[261,2643,420],{"class":267},[261,2645,2646],{"class":320}," (isSettled) ",[261,2648,426],{"class":267},[261,2650,321],{"class":320},[261,2652,2653,2656,2658,2660],{"class":263,"line":331},[261,2654,2655],{"class":320},"  isSettled ",[261,2657,292],{"class":267},[261,2659,1192],{"class":271},[261,2661,321],{"class":320},[261,2663,2664,2666,2669,2672],{"class":263,"line":375},[261,2665,420],{"class":267},[261,2667,2668],{"class":320}," (timer) ",[261,2670,2671],{"class":340},"clearTimeout",[261,2673,2674],{"class":320},"(timer);\n",[261,2676,2677,2679,2681,2683,2686,2689],{"class":263,"line":411},[261,2678,420],{"class":267},[261,2680,153],{"class":320},[261,2682,497],{"class":267},[261,2684,2685],{"class":320},"wasSubtitlesOn) ",[261,2687,2688],{"class":340},"turnSubtitlesOff",[261,2690,835],{"class":320},[261,2692,2693,2695,2697,2699,2701,2703,2705],{"class":263,"line":417},[261,2694,2416],{"class":320},[261,2696,1339],{"class":340},[261,2698,2300],{"class":320},[261,2700,2303],{"class":278},[261,2702,1144],{"class":320},[261,2704,2308],{"class":278},[261,2706,2707],{"class":320},", previousTrack);\n",[261,2709,2710,2712,2714,2716,2718,2720,2722],{"class":263,"line":432},[261,2711,2416],{"class":320},[261,2713,1339],{"class":340},[261,2715,2300],{"class":320},[261,2717,2303],{"class":278},[261,2719,1144],{"class":320},[261,2721,2337],{"class":278},[261,2723,2724],{"class":320},", previousTranslation);\n",[261,2726,2727,2730,2733,2735],{"class":263,"line":437},[261,2728,2729],{"class":340},"  resolvePromise",[261,2731,2732],{"class":320},"({ ok: ",[261,2734,1122],{"class":271},[261,2736,2737],{"class":320},", data });\n",[261,2739,2740],{"class":263,"line":444},[261,2741,1788],{"class":320},[10,2743,2744,2745,2748],{},"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 ",[19,2746,2747],{},"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.",[10,2750,2751],{},"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.",[615,2753,2755],{"id":2754},"lo-que-el-interceptor-ya-ha-visto","Lo que el interceptor ya ha visto",[10,2757,2758,2759,2762],{},"El interceptor no solo escucha cuando hay una descarga pendiente. Cada respuesta de subtítulos que pasa se guarda en ",[19,2760,2761],{},"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.",[10,2764,2765,2766,2768,2769,2771],{},"Las traducciones no se cachean. Las respuestas con ",[19,2767,2575],{}," se dejan fuera, porque ",[19,2770,2761],{}," 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.",[10,2773,2774],{},"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.",[43,2776,2778],{"id":2777},"un-puente-entre-dos-mundos","Un puente entre dos mundos",[10,2780,2781,2782,2784,2785,2787,2788,350],{},"El script de ",[19,2783,1346],{}," no puede hablar con la extensión y el aislado no puede hablar con el reproductor. Lo que comparten es el ",[19,2786,1327],{}," de la página como canal de mensajes, a través de ",[19,2789,2790],{},"window.postMessage",[252,2792,2796],{"className":2793,"code":2794,"language":2795,"meta":257,"style":257},"language-mermaid shiki shiki-themes github-light github-dark","sequenceDiagram\n  participant UI as Popup \u002F botón\n  participant CS as Content script (ISOLATED)\n  participant M as Interceptor (MAIN)\n  participant P as Reproductor de YouTube\n  UI->>CS: FETCH_SUBTITLE (baseUrl)\n  CS->>CS: requestId, timeout de 6 s\n  CS->>M: postMessage TRIGGER_SUBTITLE_FETCH\n  M->>P: setOption('captions', 'track', …)\n  P->>P: XHR a \u002Fapi\u002Ftimedtext con pot\n  P-->>M: respuesta interceptada (JSON3)\n  M->>P: restaurar pista, traducción y CC\n  M->>CS: postMessage SUBTITLE_FETCH_RESPONSE\n  CS->>CS: convertJson3ToSrt\n  CS-->>UI: { ok: true, srt }\n","mermaid",[19,2797,2798,2803,2808,2813,2818,2823,2828,2833,2838,2843,2848,2853,2858,2863,2868],{"__ignoreMap":257},[261,2799,2800],{"class":263,"line":264},[261,2801,2802],{},"sequenceDiagram\n",[261,2804,2805],{"class":263,"line":324},[261,2806,2807],{},"  participant UI as Popup \u002F botón\n",[261,2809,2810],{"class":263,"line":331},[261,2811,2812],{},"  participant CS as Content script (ISOLATED)\n",[261,2814,2815],{"class":263,"line":375},[261,2816,2817],{},"  participant M as Interceptor (MAIN)\n",[261,2819,2820],{"class":263,"line":411},[261,2821,2822],{},"  participant P as Reproductor de YouTube\n",[261,2824,2825],{"class":263,"line":417},[261,2826,2827],{},"  UI->>CS: FETCH_SUBTITLE (baseUrl)\n",[261,2829,2830],{"class":263,"line":432},[261,2831,2832],{},"  CS->>CS: requestId, timeout de 6 s\n",[261,2834,2835],{"class":263,"line":437},[261,2836,2837],{},"  CS->>M: postMessage TRIGGER_SUBTITLE_FETCH\n",[261,2839,2840],{"class":263,"line":444},[261,2841,2842],{},"  M->>P: setOption('captions', 'track', …)\n",[261,2844,2845],{"class":263,"line":450},[261,2846,2847],{},"  P->>P: XHR a \u002Fapi\u002Ftimedtext con pot\n",[261,2849,2850],{"class":263,"line":475},[261,2851,2852],{},"  P-->>M: respuesta interceptada (JSON3)\n",[261,2854,2855],{"class":263,"line":489},[261,2856,2857],{},"  M->>P: restaurar pista, traducción y CC\n",[261,2859,2860],{"class":263,"line":508},[261,2861,2862],{},"  M->>CS: postMessage SUBTITLE_FETCH_RESPONSE\n",[261,2864,2865],{"class":263,"line":513},[261,2866,2867],{},"  CS->>CS: convertJson3ToSrt\n",[261,2869,2870],{"class":263,"line":529},[261,2871,2872],{},"  CS-->>UI: { ok: true, srt }\n",[10,2874,2875,2876,2878,2879,153,2882,2885,2886,2889,2890,2892,2893,2895,2896,2899,2900,2903],{},"Cualquier script de la página, y cualquier otra extensión con un script en ",[19,2877,1346],{},", ve estos mensajes. Por eso cada uno lleva un ",[19,2880,2881],{},"source",[19,2883,2884],{},"yt-sub-fetch-cs"," en un sentido y ",[19,2887,2888],{},"yt-sub-fetch-main"," en el otro), cada receptor descarta lo que no viene de ",[19,2891,1327],{}," o no trae su ",[19,2894,2881],{},", y cada petición tiene un ",[19,2897,2898],{},"requestId"," generado con ",[19,2901,2902],{},"crypto.randomUUID()",". El content script solo acepta la respuesta que tiene su id:",[252,2905,2907],{"className":254,"code":2906,"language":256,"meta":257,"style":257},"const onMessage = (event: MessageEvent) => {\n  if (event.source !== window || event.data?.source !== 'yt-sub-fetch-main') return;\n  const res = event.data as SubtitleFetchResponseMessage;\n  if (res.type !== 'SUBTITLE_FETCH_RESPONSE' || res.requestId !== requestId) return;\n\n  window.removeEventListener('message', onMessage);\n  if (timeoutId) clearTimeout(timeoutId);\n  \u002F\u002F ...\n};\n",[19,2908,2909,2934,2962,2981,3008,3012,3028,3040,3044],{"__ignoreMap":257},[261,2910,2911,2913,2916,2918,2920,2923,2925,2928,2930,2932],{"class":263,"line":264},[261,2912,268],{"class":267},[261,2914,2915],{"class":340}," onMessage",[261,2917,275],{"class":267},[261,2919,153],{"class":320},[261,2921,2922],{"class":346},"event",[261,2924,350],{"class":267},[261,2926,2927],{"class":340}," MessageEvent",[261,2929,782],{"class":320},[261,2931,815],{"class":267},[261,2933,372],{"class":320},[261,2935,2936,2938,2941,2943,2946,2948,2951,2953,2956,2958,2960],{"class":263,"line":324},[261,2937,420],{"class":267},[261,2939,2940],{"class":320}," (event.source ",[261,2942,776],{"class":267},[261,2944,2945],{"class":320}," window ",[261,2947,761],{"class":267},[261,2949,2950],{"class":320}," event.data?.source ",[261,2952,776],{"class":267},[261,2954,2955],{"class":278}," 'yt-sub-fetch-main'",[261,2957,782],{"class":320},[261,2959,426],{"class":267},[261,2961,321],{"class":320},[261,2963,2964,2966,2969,2971,2974,2976,2979],{"class":263,"line":331},[261,2965,378],{"class":267},[261,2967,2968],{"class":271}," res",[261,2970,275],{"class":267},[261,2972,2973],{"class":320}," event.data ",[261,2975,389],{"class":267},[261,2977,2978],{"class":340}," SubtitleFetchResponseMessage",[261,2980,321],{"class":320},[261,2982,2983,2985,2988,2990,2993,2996,2999,3001,3004,3006],{"class":263,"line":375},[261,2984,420],{"class":267},[261,2986,2987],{"class":320}," (res.type ",[261,2989,776],{"class":267},[261,2991,2992],{"class":278}," 'SUBTITLE_FETCH_RESPONSE'",[261,2994,2995],{"class":267}," ||",[261,2997,2998],{"class":320}," res.requestId ",[261,3000,776],{"class":267},[261,3002,3003],{"class":320}," requestId) ",[261,3005,426],{"class":267},[261,3007,321],{"class":320},[261,3009,3010],{"class":263,"line":411},[261,3011,328],{"emptyLinePlaceholder":327},[261,3013,3014,3017,3020,3022,3025],{"class":263,"line":417},[261,3015,3016],{"class":320},"  window.",[261,3018,3019],{"class":340},"removeEventListener",[261,3021,299],{"class":320},[261,3023,3024],{"class":278},"'message'",[261,3026,3027],{"class":320},", onMessage);\n",[261,3029,3030,3032,3035,3037],{"class":263,"line":432},[261,3031,420],{"class":267},[261,3033,3034],{"class":320}," (timeoutId) ",[261,3036,2671],{"class":340},[261,3038,3039],{"class":320},"(timeoutId);\n",[261,3041,3042],{"class":263,"line":437},[261,3043,1203],{"class":440},[261,3045,3046],{"class":263,"line":444},[261,3047,1788],{"class":320},[10,3049,3050,3051,3053,3054,3056],{},"Esto no es seguridad de verdad, y conviene no venderlo como tal. El ",[19,3052,2881],{}," 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 ",[19,3055,21],{}," con texto inventado, y lo acepté.",[10,3058,3059,3060,3062],{},"El ",[19,3061,2898],{}," 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.",[10,3064,3065,3066,3068],{},"Los mensajes están tipados en ",[19,3067,199],{},", y los dos lados importan los mismos tipos:",[252,3070,3072],{"className":254,"code":3071,"language":256,"meta":257,"style":257},"export interface TriggerSubtitleFetchMessage {\n  source: 'yt-sub-fetch-cs';\n  type: 'TRIGGER_SUBTITLE_FETCH';\n  requestId: string;\n  track: SubtitleTrackTarget;\n}\n\nexport interface SubtitleFetchResponseMessage {\n  source: 'yt-sub-fetch-main';\n  type: 'SUBTITLE_FETCH_RESPONSE';\n  requestId: string;\n  ok: boolean;\n  data?: any;\n  error?: { code: 'NETWORK_ERROR' | 'PLAYER_UNAVAILABLE' };\n}\n",[19,3073,3074,3086,3098,3110,3121,3133,3137,3141,3151,3161,3171,3181,3193,3204,3228],{"__ignoreMap":257},[261,3075,3076,3078,3081,3084],{"class":263,"line":264},[261,3077,334],{"class":267},[261,3079,3080],{"class":267}," interface",[261,3082,3083],{"class":340}," TriggerSubtitleFetchMessage",[261,3085,372],{"class":320},[261,3087,3088,3091,3093,3096],{"class":263,"line":324},[261,3089,3090],{"class":346},"  source",[261,3092,350],{"class":267},[261,3094,3095],{"class":278}," 'yt-sub-fetch-cs'",[261,3097,321],{"class":320},[261,3099,3100,3103,3105,3108],{"class":263,"line":331},[261,3101,3102],{"class":346},"  type",[261,3104,350],{"class":267},[261,3106,3107],{"class":278}," 'TRIGGER_SUBTITLE_FETCH'",[261,3109,321],{"class":320},[261,3111,3112,3115,3117,3119],{"class":263,"line":375},[261,3113,3114],{"class":346},"  requestId",[261,3116,350],{"class":267},[261,3118,659],{"class":271},[261,3120,321],{"class":320},[261,3122,3123,3126,3128,3131],{"class":263,"line":411},[261,3124,3125],{"class":346},"  track",[261,3127,350],{"class":267},[261,3129,3130],{"class":340}," SubtitleTrackTarget",[261,3132,321],{"class":320},[261,3134,3135],{"class":263,"line":417},[261,3136,566],{"class":320},[261,3138,3139],{"class":263,"line":432},[261,3140,328],{"emptyLinePlaceholder":327},[261,3142,3143,3145,3147,3149],{"class":263,"line":437},[261,3144,334],{"class":267},[261,3146,3080],{"class":267},[261,3148,2978],{"class":340},[261,3150,372],{"class":320},[261,3152,3153,3155,3157,3159],{"class":263,"line":444},[261,3154,3090],{"class":346},[261,3156,350],{"class":267},[261,3158,2955],{"class":278},[261,3160,321],{"class":320},[261,3162,3163,3165,3167,3169],{"class":263,"line":450},[261,3164,3102],{"class":346},[261,3166,350],{"class":267},[261,3168,2992],{"class":278},[261,3170,321],{"class":320},[261,3172,3173,3175,3177,3179],{"class":263,"line":475},[261,3174,3114],{"class":346},[261,3176,350],{"class":267},[261,3178,659],{"class":271},[261,3180,321],{"class":320},[261,3182,3183,3186,3188,3191],{"class":263,"line":489},[261,3184,3185],{"class":346},"  ok",[261,3187,350],{"class":267},[261,3189,3190],{"class":271}," boolean",[261,3192,321],{"class":320},[261,3194,3195,3198,3200,3202],{"class":263,"line":508},[261,3196,3197],{"class":346},"  data",[261,3199,403],{"class":267},[261,3201,1906],{"class":271},[261,3203,321],{"class":320},[261,3205,3206,3209,3211,3213,3215,3217,3220,3222,3225],{"class":263,"line":513},[261,3207,3208],{"class":346},"  error",[261,3210,403],{"class":267},[261,3212,398],{"class":320},[261,3214,19],{"class":346},[261,3216,350],{"class":267},[261,3218,3219],{"class":278}," 'NETWORK_ERROR'",[261,3221,366],{"class":267},[261,3223,3224],{"class":278}," 'PLAYER_UNAVAILABLE'",[261,3226,3227],{"class":320}," };\n",[261,3229,3230],{"class":263,"line":529},[261,3231,566],{"class":320},[10,3233,3234,3235,3238,3239,3241,3242,3245,3246,3248,3249,3251,3252,3255,3256,3258],{},"El popup no supo nada de este cambio. Sigue mandando ",[19,3236,3237],{},"FETCH_SUBTITLE"," con la ",[19,3240,238],{}," y recibe un SRT. El content script saca de la URL el ",[19,3243,3244],{},"lang",", el ",[19,3247,2557],{}," y el ",[19,3250,2575],{}," que necesita el reproductor y lo convierte en una ",[19,3253,3254],{},"SubtitleTrackTarget",". La ",[19,3257,238],{}," ya no se pide nunca: solo sirve para saber qué pista quieres.",[10,3260,3261],{},"Con esto la extensión volvió a descargar. Al menos al abrir un vídeo directamente o al recargar la página.",[43,3263,3265],{"id":3264},"la-parte-larga-una-spa-que-no-avisa","La parte larga: una SPA que no avisa",[10,3267,3268,3269,3272],{},"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 ",[19,3270,3271],{},"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.",[10,3274,3275,3276,3278],{},"Al recargar, todo empieza de cero. Chrome inyecta los content scripts, ",[19,3277,218],{}," 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é.",[615,3280,3282],{"id":3281},"estado-1-el-content-script-que-nunca-llega","Estado 1: el content script que nunca llega",[10,3284,3285],{},"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.",[10,3287,3288,3289,3292,3293,3296,3297,3300,3301,3304],{},"Los dos content scripts declaraban ",[19,3290,3291],{},"matches: ['*:\u002F\u002F*.youtube.com\u002Fwatch*']",". Chrome evalúa ese patrón cuando el documento carga, no cuando cambia la URL. Si el documento que se cargó fue ",[19,3294,3295],{},"youtube.com\u002F",", ninguno de los dos se inyectó, y un ",[19,3298,3299],{},"pushState"," a ",[19,3302,3303],{},"\u002Fwatch?v=…"," no cambia eso. Para Chrome sigues en la misma página.",[10,3306,3307],{},"La solución es inyectarlos en todo YouTube y decidir dentro si estás en un vídeo:",[252,3309,3311],{"className":254,"code":3310,"language":256,"meta":257,"style":257},"export function isWatchPageUrl(url: string): boolean {\n  try {\n    const parsed = new URL(url);\n    return (\n      \u002F(^|\\.)youtube\\.com$\u002F.test(parsed.hostname) &&\n      parsed.pathname === '\u002Fwatch' &&\n      parsed.searchParams.has('v')\n    );\n  } catch {\n    return false;\n  }\n}\n",[19,3312,3313,3338,3345,3361,3368,3405,3418,3434,3439,3449,3457,3461],{"__ignoreMap":257},[261,3314,3315,3317,3319,3322,3324,3326,3328,3330,3332,3334,3336],{"class":263,"line":264},[261,3316,334],{"class":267},[261,3318,337],{"class":267},[261,3320,3321],{"class":340}," isWatchPageUrl",[261,3323,299],{"class":320},[261,3325,1920],{"class":346},[261,3327,350],{"class":267},[261,3329,659],{"class":271},[261,3331,654],{"class":320},[261,3333,350],{"class":267},[261,3335,3190],{"class":271},[261,3337,372],{"class":320},[261,3339,3340,3343],{"class":263,"line":324},[261,3341,3342],{"class":267},"  try",[261,3344,372],{"class":320},[261,3346,3347,3349,3351,3353,3356,3358],{"class":263,"line":331},[261,3348,478],{"class":267},[261,3350,518],{"class":271},[261,3352,275],{"class":267},[261,3354,3355],{"class":267}," new",[261,3357,1488],{"class":340},[261,3359,3360],{"class":320},"(url);\n",[261,3362,3363,3365],{"class":263,"line":375},[261,3364,1189],{"class":267},[261,3366,3367],{"class":320}," (\n",[261,3369,3370,3373,3375,3378,3381,3384,3386,3389,3392,3394,3396,3399,3402],{"class":263,"line":411},[261,3371,3372],{"class":278},"      \u002F",[261,3374,299],{"class":282},[261,3376,3377],{"class":267},"^|",[261,3379,3380],{"class":302},"\\.",[261,3382,3383],{"class":282},")youtube",[261,3385,3380],{"class":302},[261,3387,3388],{"class":282},"com",[261,3390,3391],{"class":267},"$",[261,3393,317],{"class":278},[261,3395,243],{"class":320},[261,3397,3398],{"class":340},"test",[261,3400,3401],{"class":320},"(parsed.hostname) ",[261,3403,3404],{"class":267},"&&\n",[261,3406,3407,3410,3412,3415],{"class":263,"line":417},[261,3408,3409],{"class":320},"      parsed.pathname ",[261,3411,854],{"class":267},[261,3413,3414],{"class":278}," '\u002Fwatch'",[261,3416,3417],{"class":267}," &&\n",[261,3419,3420,3423,3426,3428,3431],{"class":263,"line":432},[261,3421,3422],{"class":320},"      parsed.searchParams.",[261,3424,3425],{"class":340},"has",[261,3427,299],{"class":320},[261,3429,3430],{"class":278},"'v'",[261,3432,3433],{"class":320},")\n",[261,3435,3436],{"class":263,"line":437},[261,3437,3438],{"class":320},"    );\n",[261,3440,3441,3444,3447],{"class":263,"line":444},[261,3442,3443],{"class":320},"  } ",[261,3445,3446],{"class":267},"catch",[261,3448,372],{"class":320},[261,3450,3451,3453,3455],{"class":263,"line":450},[261,3452,1189],{"class":267},[261,3454,2097],{"class":271},[261,3456,321],{"class":320},[261,3458,3459],{"class":263,"line":475},[261,3460,545],{"class":320},[261,3462,3463],{"class":263,"line":489},[261,3464,566],{"class":320},[10,3466,3467,3468,3470,3471,3473,3474,3476],{},"Para el interceptor de ",[19,3469,1346],{}," esto es todavía más importante. Tiene que estar en ",[19,3472,1424],{}," del documento que realmente se carga, que puede ser la portada, una búsqueda o un canal, para que su envoltorio de ",[19,3475,1246],{}," esté puesto cuando el usuario llegue a un vídeo.",[615,3478,3480],{"id":3479},"estado-2-los-subtítulos-del-vídeo-anterior","Estado 2: los subtítulos del vídeo anterior",[10,3482,3483],{},"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.",[10,3485,3486,3488],{},[19,3487,218],{}," 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.",[10,3490,3491],{},"La primera defensa fue comprobar que el vídeo coincide y, si no, descargar el HTML de la página actual:",[252,3493,3495],{"className":254,"code":3494,"language":256,"meta":257,"style":257},"const currentVideoId = extractVideoId(win.location.href);\nlet raw = readPlayerResponseFromDom(win);\n\n\u002F\u002F YouTube's client-side router doesn't update this global on SPA navigation,\n\u002F\u002F so a mismatched videoId means it's left over from the previous video.\nif (raw && currentVideoId && raw.videoDetails?.videoId !== currentVideoId) {\n  raw = null;\n}\n\nif (!raw) {\n  raw = await fetchPlayerResponseFromHtml(win.location.href, fetchImpl);\n}\n",[19,3496,3497,3512,3527,3531,3536,3541,3563,3574,3578,3582,3593,3607],{"__ignoreMap":257},[261,3498,3499,3501,3504,3506,3509],{"class":263,"line":264},[261,3500,268],{"class":267},[261,3502,3503],{"class":271}," currentVideoId",[261,3505,275],{"class":267},[261,3507,3508],{"class":340}," extractVideoId",[261,3510,3511],{"class":320},"(win.location.href);\n",[261,3513,3514,3517,3520,3522,3524],{"class":263,"line":324},[261,3515,3516],{"class":267},"let",[261,3518,3519],{"class":320}," raw ",[261,3521,292],{"class":267},[261,3523,341],{"class":340},[261,3525,3526],{"class":320},"(win);\n",[261,3528,3529],{"class":263,"line":331},[261,3530,328],{"emptyLinePlaceholder":327},[261,3532,3533],{"class":263,"line":375},[261,3534,3535],{"class":440},"\u002F\u002F YouTube's client-side router doesn't update this global on SPA navigation,\n",[261,3537,3538],{"class":263,"line":411},[261,3539,3540],{"class":440},"\u002F\u002F so a mismatched videoId means it's left over from the previous video.\n",[261,3542,3543,3545,3548,3550,3553,3555,3558,3560],{"class":263,"line":417},[261,3544,2396],{"class":267},[261,3546,3547],{"class":320}," (raw ",[261,3549,770],{"class":267},[261,3551,3552],{"class":320}," currentVideoId ",[261,3554,770],{"class":267},[261,3556,3557],{"class":320}," raw.videoDetails?.videoId ",[261,3559,776],{"class":267},[261,3561,3562],{"class":320}," currentVideoId) {\n",[261,3564,3565,3568,3570,3572],{"class":263,"line":432},[261,3566,3567],{"class":320},"  raw ",[261,3569,292],{"class":267},[261,3571,369],{"class":271},[261,3573,321],{"class":320},[261,3575,3576],{"class":263,"line":437},[261,3577,566],{"class":320},[261,3579,3580],{"class":263,"line":444},[261,3581,328],{"emptyLinePlaceholder":327},[261,3583,3584,3586,3588,3590],{"class":263,"line":450},[261,3585,2396],{"class":267},[261,3587,153],{"class":320},[261,3589,497],{"class":267},[261,3591,3592],{"class":320},"raw) {\n",[261,3594,3595,3597,3599,3601,3604],{"class":263,"line":475},[261,3596,3567],{"class":320},[261,3598,292],{"class":267},[261,3600,1651],{"class":267},[261,3602,3603],{"class":340}," fetchPlayerResponseFromHtml",[261,3605,3606],{"class":320},"(win.location.href, fetchImpl);\n",[261,3608,3609],{"class":263,"line":489},[261,3610,566],{"class":320},[10,3612,3613,3614,3616],{},"Funciona, porque el HTML de ",[19,3615,3303],{}," 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.",[10,3618,3619,3620,3623,3624,3626,3627,3629,3630,3632,3633,3635],{},"La mejor fuente es la que usa el propio reproductor. Al navegar, YouTube pide los datos del vídeo a ",[19,3621,3622],{},"\u002Fyoutubei\u002Fv1\u002Fplayer",", y la respuesta tiene exactamente la misma forma que ",[19,3625,218],{},". El envoltorio de ",[19,3628,1246],{}," del mundo ",[19,3631,1346],{}," 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 ",[19,3634,1246],{}," se quedó junto al de XHR.",[10,3637,3638],{},"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:",[1297,3640,3641,3649,3657,3672],{},[122,3642,3643,3648],{},[51,3644,3645,3646],{},"Lo interceptado de ",[19,3647,3622],{},", si es del vídeo actual.",[122,3650,3651,3656],{},[51,3652,3653],{},[19,3654,3655],{},"player.getPlayerResponse()",", un método del reproductor que devuelve lo que tiene cargado. Existe en la mayoría de versiones, pero no está garantizado.",[122,3658,3659,3667,3668,3671],{},[51,3660,3661,3664,3665],{},[19,3662,3663],{},"window.ytInitialPlayerResponse"," leído desde ",[19,3666,1346],{},", solo si su ",[19,3669,3670],{},"videoId"," coincide con el del reproductor.",[122,3673,3674,3677,3678,3680],{},[51,3675,3676],{},"El HTML de la página",", desde el content script, si el mundo ",[19,3679,1346],{}," no contesta en 500 ms.",[10,3682,3683,3684,3686,3687,3690,3691,3693],{},"El content script pide al de ",[19,3685,1346],{}," la respuesta con ",[19,3688,3689],{},"GET_PLAYER_RESPONSE"," y el id del vídeo que ve en la URL. El de ",[19,3692,1346],{}," contesta si tiene algo, y solo si el vídeo coincide:",[252,3695,3697],{"className":254,"code":3696,"language":256,"meta":257,"style":257},"if (!cachedPlayerResponse || (req.videoId && cachedPlayerResponse?.videoDetails?.videoId\n    && cachedPlayerResponse.videoDetails.videoId !== req.videoId)) {\n  const liveResponse = player?.getPlayerResponse?.();\n  if (liveResponse) cachedPlayerResponse = liveResponse;\n}\n\nconst responseOk = cachedPlayerResponse &&\n  (!req.videoId || !currentVideoId || req.videoId === currentVideoId);\n",[19,3698,3699,3720,3733,3751,3763,3767,3771,3785],{"__ignoreMap":257},[261,3700,3701,3703,3705,3707,3710,3712,3715,3717],{"class":263,"line":264},[261,3702,2396],{"class":267},[261,3704,153],{"class":320},[261,3706,497],{"class":267},[261,3708,3709],{"class":320},"cachedPlayerResponse ",[261,3711,761],{"class":267},[261,3713,3714],{"class":320}," (req.videoId ",[261,3716,770],{"class":267},[261,3718,3719],{"class":320}," cachedPlayerResponse?.videoDetails?.videoId\n",[261,3721,3722,3725,3728,3730],{"class":263,"line":324},[261,3723,3724],{"class":267},"    &&",[261,3726,3727],{"class":320}," cachedPlayerResponse.videoDetails.videoId ",[261,3729,776],{"class":267},[261,3731,3732],{"class":320}," req.videoId)) {\n",[261,3734,3735,3737,3740,3742,3745,3748],{"class":263,"line":331},[261,3736,378],{"class":267},[261,3738,3739],{"class":271}," liveResponse",[261,3741,275],{"class":267},[261,3743,3744],{"class":320}," player?.",[261,3746,3747],{"class":340},"getPlayerResponse",[261,3749,3750],{"class":320},"?.();\n",[261,3752,3753,3755,3758,3760],{"class":263,"line":375},[261,3754,420],{"class":267},[261,3756,3757],{"class":320}," (liveResponse) cachedPlayerResponse ",[261,3759,292],{"class":267},[261,3761,3762],{"class":320}," liveResponse;\n",[261,3764,3765],{"class":263,"line":411},[261,3766,566],{"class":320},[261,3768,3769],{"class":263,"line":417},[261,3770,328],{"emptyLinePlaceholder":327},[261,3772,3773,3775,3778,3780,3783],{"class":263,"line":432},[261,3774,268],{"class":267},[261,3776,3777],{"class":271}," responseOk",[261,3779,275],{"class":267},[261,3781,3782],{"class":320}," cachedPlayerResponse ",[261,3784,3404],{"class":267},[261,3786,3787,3790,3792,3795,3797,3799,3802,3804,3807,3809],{"class":263,"line":437},[261,3788,3789],{"class":320},"  (",[261,3791,497],{"class":267},[261,3793,3794],{"class":320},"req.videoId ",[261,3796,761],{"class":267},[261,3798,764],{"class":267},[261,3800,3801],{"class":320},"currentVideoId ",[261,3803,761],{"class":267},[261,3805,3806],{"class":320}," req.videoId ",[261,3808,854],{"class":267},[261,3810,3811],{"class":320}," currentVideoId);\n",[10,3813,3814,3815,3817],{},"Los 500 ms son el tiempo que doy al mundo ",[19,3816,1346],{}," 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.",[10,3819,3820,3821,3824,3825,3828,3829,3832,3833,3836],{},"El título necesitó un parche aparte. Algunas respuestas del reproductor llegan sin ",[19,3822,3823],{},"videoDetails.title",", y el nombre del fichero acababa siendo ",[19,3826,3827],{},"video_es_auto.srt",". Si falta, se toma de ",[19,3830,3831],{},"player.getVideoData().title"," y, como último recurso, de ",[19,3834,3835],{},"document.title"," sin el \" - YouTube\" del final.",[615,3838,3840],{"id":3839},"estado-3-cachés-que-sobreviven-al-vídeo","Estado 3: cachés que sobreviven al vídeo",[10,3842,3843,3844,153,3846,27,3849,3851,3852,243],{},"Tener cachés en ",[19,3845,1346],{},[19,3847,3848],{},"cachedPlayerResponse",[19,3850,2761],{},") 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 ",[19,3853,2761],{},[10,3855,3856],{},"Hay dos cosas que lo arreglan. La primera es vaciar las cachés al empezar una navegación, no al terminarla:",[252,3858,3860],{"className":254,"code":3859,"language":256,"meta":257,"style":257},"const onNavStart = () => {\n  cachedTrack = null;\n  cachedPlayerResponse = null;\n};\ndocument.addEventListener('yt-navigate-start', onNavStart);\nwindow.addEventListener('yt-navigate-start', onNavStart);\n",[19,3861,3862,3877,3888,3899,3903,3918],{"__ignoreMap":257},[261,3863,3864,3866,3869,3871,3873,3875],{"class":263,"line":264},[261,3865,268],{"class":267},[261,3867,3868],{"class":340}," onNavStart",[261,3870,275],{"class":267},[261,3872,2111],{"class":320},[261,3874,815],{"class":267},[261,3876,372],{"class":320},[261,3878,3879,3882,3884,3886],{"class":263,"line":324},[261,3880,3881],{"class":320},"  cachedTrack ",[261,3883,292],{"class":267},[261,3885,369],{"class":271},[261,3887,321],{"class":320},[261,3889,3890,3893,3895,3897],{"class":263,"line":331},[261,3891,3892],{"class":320},"  cachedPlayerResponse ",[261,3894,292],{"class":267},[261,3896,369],{"class":271},[261,3898,321],{"class":320},[261,3900,3901],{"class":263,"line":375},[261,3902,1788],{"class":320},[261,3904,3905,3908,3910,3912,3915],{"class":263,"line":411},[261,3906,3907],{"class":320},"document.",[261,3909,2172],{"class":340},[261,3911,299],{"class":320},[261,3913,3914],{"class":278},"'yt-navigate-start'",[261,3916,3917],{"class":320},", onNavStart);\n",[261,3919,3920,3923,3925,3927,3929],{"class":263,"line":417},[261,3921,3922],{"class":320},"window.",[261,3924,2172],{"class":340},[261,3926,299],{"class":320},[261,3928,3914],{"class":278},[261,3930,3917],{"class":320},[10,3932,3933,3934,3937,3938,3940],{},"Entre ",[19,3935,3936],{},"yt-navigate-start"," y la llegada de los datos nuevos, la caché está vacía y se cae a ",[19,3939,74],{}," o al HTML. Es más lento, pero no da datos equivocados.",[10,3942,3943],{},"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.",[10,3945,3946,3947,3949],{},"Al terminar la navegación, ",[19,3948,78],{}," 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.",[615,3951,3953],{"id":3952},"estado-4-eventos-que-llegan-a-otro-sitio","Estado 4: eventos que llegan a otro sitio",[10,3955,3956,1144,3958,27,3960,3963,3964,3966],{},[19,3957,3936],{},[19,3959,78],{},[19,3961,3962],{},"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 ",[19,3965,1327],{},", porque era lo que había visto en otros proyectos, y a veces no llegaban.",[10,3968,3969,3970,3972],{},"YouTube los dispara sobre elementos del documento, y según la versión burbujean o no hasta ",[19,3971,1327],{},". Ahora todos se escuchan en los dos sitios:",[252,3974,3976],{"className":254,"code":3975,"language":256,"meta":257,"style":257},"document.addEventListener('yt-navigate-finish', onNavEvent);\nwindow.addEventListener('yt-navigate-finish', onNavEvent);\ndocument.addEventListener('yt-page-data-updated', onNavEvent);\nwindow.addEventListener('yt-page-data-updated', onNavEvent);\ndocument.addEventListener('yt-navigate-start', onNavEvent);\nwindow.addEventListener('yt-navigate-start', onNavEvent);\nwindow.addEventListener('popstate', onNavEvent);\n",[19,3977,3978,3992,4004,4017,4029,4041,4053],{"__ignoreMap":257},[261,3979,3980,3982,3984,3986,3989],{"class":263,"line":264},[261,3981,3907],{"class":320},[261,3983,2172],{"class":340},[261,3985,299],{"class":320},[261,3987,3988],{"class":278},"'yt-navigate-finish'",[261,3990,3991],{"class":320},", onNavEvent);\n",[261,3993,3994,3996,3998,4000,4002],{"class":263,"line":324},[261,3995,3922],{"class":320},[261,3997,2172],{"class":340},[261,3999,299],{"class":320},[261,4001,3988],{"class":278},[261,4003,3991],{"class":320},[261,4005,4006,4008,4010,4012,4015],{"class":263,"line":331},[261,4007,3907],{"class":320},[261,4009,2172],{"class":340},[261,4011,299],{"class":320},[261,4013,4014],{"class":278},"'yt-page-data-updated'",[261,4016,3991],{"class":320},[261,4018,4019,4021,4023,4025,4027],{"class":263,"line":375},[261,4020,3922],{"class":320},[261,4022,2172],{"class":340},[261,4024,299],{"class":320},[261,4026,4014],{"class":278},[261,4028,3991],{"class":320},[261,4030,4031,4033,4035,4037,4039],{"class":263,"line":411},[261,4032,3907],{"class":320},[261,4034,2172],{"class":340},[261,4036,299],{"class":320},[261,4038,3914],{"class":278},[261,4040,3991],{"class":320},[261,4042,4043,4045,4047,4049,4051],{"class":263,"line":417},[261,4044,3922],{"class":320},[261,4046,2172],{"class":340},[261,4048,299],{"class":320},[261,4050,3914],{"class":278},[261,4052,3991],{"class":320},[261,4054,4055,4057,4059,4061,4064],{"class":263,"line":432},[261,4056,3922],{"class":320},[261,4058,2172],{"class":340},[261,4060,299],{"class":320},[261,4062,4063],{"class":278},"'popstate'",[261,4065,3991],{"class":320},[10,4067,4068],{},"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.",[615,4070,4072],{"id":4071},"estado-5-el-botón-en-la-barra-de-acciones","Estado 5: el botón en la barra de acciones",[10,4074,4075,4076,4078,4079,4082,4083,4085,4086,1144,4088,4090],{},"El botón ",[24,4077,130],{}," se monta con ",[19,4080,4081],{},"createShadowRootUi"," de WXT, en línea, al final de ",[19,4084,82],{},", que es el contenedor donde YouTube pone ",[24,4087,26],{},[24,4089,30],{}," 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.",[10,4092,4093,4096,4097,4100],{},[51,4094,4095],{},"Primera versión: esperar al contenedor y rendirse."," Si al cargar no estaba la barra (YouTube la pinta tarde), un ",[19,4098,4099],{},"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.",[10,4102,4103,4106,4107,4109,4110,4112,4113,350],{},[51,4104,4105],{},"Segunda versión: un observador permanente."," Un único ",[19,4108,4099],{}," sobre todo el ",[19,4111,2043],{},", 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 ",[19,4114,4115],{},"requestAnimationFrame",[252,4117,4119],{"className":254,"code":4118,"language":256,"meta":257,"style":257},"globalObserver = new MutationObserver(() => {\n  if (checkFrameId !== null) return;\n  checkFrameId = requestAnimationFrame(() => {\n    checkFrameId = null;\n    void checkNavigation(ctx);\n  });\n});\n\nglobalObserver.observe(root, { childList: true, subtree: true });\n",[19,4120,4121,4140,4157,4173,4184,4195,4200,4204,4208],{"__ignoreMap":257},[261,4122,4123,4126,4128,4130,4133,4136,4138],{"class":263,"line":264},[261,4124,4125],{"class":320},"globalObserver ",[261,4127,292],{"class":267},[261,4129,3355],{"class":267},[261,4131,4132],{"class":340}," MutationObserver",[261,4134,4135],{"class":320},"(() ",[261,4137,815],{"class":267},[261,4139,372],{"class":320},[261,4141,4142,4144,4147,4149,4151,4153,4155],{"class":263,"line":324},[261,4143,420],{"class":267},[261,4145,4146],{"class":320}," (checkFrameId ",[261,4148,776],{"class":267},[261,4150,369],{"class":271},[261,4152,782],{"class":320},[261,4154,426],{"class":267},[261,4156,321],{"class":320},[261,4158,4159,4162,4164,4167,4169,4171],{"class":263,"line":331},[261,4160,4161],{"class":320},"  checkFrameId ",[261,4163,292],{"class":267},[261,4165,4166],{"class":340}," requestAnimationFrame",[261,4168,4135],{"class":320},[261,4170,815],{"class":267},[261,4172,372],{"class":320},[261,4174,4175,4178,4180,4182],{"class":263,"line":375},[261,4176,4177],{"class":320},"    checkFrameId ",[261,4179,292],{"class":267},[261,4181,369],{"class":271},[261,4183,321],{"class":320},[261,4185,4186,4189,4192],{"class":263,"line":411},[261,4187,4188],{"class":267},"    void",[261,4190,4191],{"class":340}," checkNavigation",[261,4193,4194],{"class":320},"(ctx);\n",[261,4196,4197],{"class":263,"line":417},[261,4198,4199],{"class":320},"  });\n",[261,4201,4202],{"class":263,"line":432},[261,4203,1101],{"class":320},[261,4205,4206],{"class":263,"line":437},[261,4207,328],{"emptyLinePlaceholder":327},[261,4209,4210,4213,4216,4219,4221,4224,4226],{"class":263,"line":444},[261,4211,4212],{"class":320},"globalObserver.",[261,4214,4215],{"class":340},"observe",[261,4217,4218],{"class":320},"(root, { childList: ",[261,4220,1122],{"class":271},[261,4222,4223],{"class":320},", subtree: ",[261,4225,1122],{"class":271},[261,4227,4228],{"class":320}," });\n",[10,4230,4231],{},"Como mucho, una comprobación por fotograma, por muchas mutaciones que haya.",[10,4233,4234,4237,4238,4241,4242,4245],{},[51,4235,4236],{},"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 ",[19,4239,4240],{},"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 ",[19,4243,4244],{},"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.",[10,4247,4248,4251],{},[19,4249,4250],{},"findTargetContainer"," busca ahora por orden de preferencia, empezando por los metadatos del vídeo activo, y solo acepta elementos que sigan conectados al documento:",[252,4253,4255],{"className":254,"code":4254,"language":256,"meta":257,"style":257},"function findTargetContainer(): HTMLElement | null {\n  const watchMetadata = document.querySelector('ytd-watch-metadata');\n  if (watchMetadata) {\n    const computed = watchMetadata.querySelector\u003CHTMLElement>('#top-level-buttons-computed');\n    if (computed && computed.isConnected) return computed;\n    \u002F\u002F ... dentro de #actions\n  }\n\n  const watchFlexy = document.querySelector('ytd-watch-flexy[role=\"main\"]');\n  if (watchFlexy) {\n    const flexyComputed = watchFlexy.querySelector\u003CHTMLElement>('#top-level-buttons-computed');\n    if (flexyComputed && flexyComputed.isConnected) return flexyComputed;\n  }\n\n  \u002F\u002F ... selectores de reserva, cada vez más genéricos\n  return null;\n}\n",[19,4256,4257,4279,4301,4308,4335,4352,4357,4361,4365,4385,4392,4416,4433,4437,4441,4446,4454],{"__ignoreMap":257},[261,4258,4259,4262,4265,4268,4270,4273,4275,4277],{"class":263,"line":264},[261,4260,4261],{"class":267},"function",[261,4263,4264],{"class":340}," findTargetContainer",[261,4266,4267],{"class":320},"()",[261,4269,350],{"class":267},[261,4271,4272],{"class":340}," HTMLElement",[261,4274,366],{"class":267},[261,4276,369],{"class":271},[261,4278,372],{"class":320},[261,4280,4281,4283,4286,4288,4291,4294,4296,4299],{"class":263,"line":324},[261,4282,378],{"class":267},[261,4284,4285],{"class":271}," watchMetadata",[261,4287,275],{"class":267},[261,4289,4290],{"class":320}," document.",[261,4292,4293],{"class":340},"querySelector",[261,4295,299],{"class":320},[261,4297,4298],{"class":278},"'ytd-watch-metadata'",[261,4300,2186],{"class":320},[261,4302,4303,4305],{"class":263,"line":331},[261,4304,420],{"class":267},[261,4306,4307],{"class":320}," (watchMetadata) {\n",[261,4309,4310,4312,4315,4317,4320,4322,4324,4327,4330,4333],{"class":263,"line":375},[261,4311,478],{"class":267},[261,4313,4314],{"class":271}," computed",[261,4316,275],{"class":267},[261,4318,4319],{"class":320}," watchMetadata.",[261,4321,4293],{"class":340},[261,4323,1508],{"class":320},[261,4325,4326],{"class":340},"HTMLElement",[261,4328,4329],{"class":320},">(",[261,4331,4332],{"class":278},"'#top-level-buttons-computed'",[261,4334,2186],{"class":320},[261,4336,4337,4339,4342,4344,4347,4349],{"class":263,"line":411},[261,4338,492],{"class":267},[261,4340,4341],{"class":320}," (computed ",[261,4343,770],{"class":267},[261,4345,4346],{"class":320}," computed.isConnected) ",[261,4348,426],{"class":267},[261,4350,4351],{"class":320}," computed;\n",[261,4353,4354],{"class":263,"line":417},[261,4355,4356],{"class":440},"    \u002F\u002F ... dentro de #actions\n",[261,4358,4359],{"class":263,"line":432},[261,4360,545],{"class":320},[261,4362,4363],{"class":263,"line":437},[261,4364,328],{"emptyLinePlaceholder":327},[261,4366,4367,4369,4372,4374,4376,4378,4380,4383],{"class":263,"line":444},[261,4368,378],{"class":267},[261,4370,4371],{"class":271}," watchFlexy",[261,4373,275],{"class":267},[261,4375,4290],{"class":320},[261,4377,4293],{"class":340},[261,4379,299],{"class":320},[261,4381,4382],{"class":278},"'ytd-watch-flexy[role=\"main\"]'",[261,4384,2186],{"class":320},[261,4386,4387,4389],{"class":263,"line":450},[261,4388,420],{"class":267},[261,4390,4391],{"class":320}," (watchFlexy) {\n",[261,4393,4394,4396,4399,4401,4404,4406,4408,4410,4412,4414],{"class":263,"line":475},[261,4395,478],{"class":267},[261,4397,4398],{"class":271}," flexyComputed",[261,4400,275],{"class":267},[261,4402,4403],{"class":320}," watchFlexy.",[261,4405,4293],{"class":340},[261,4407,1508],{"class":320},[261,4409,4326],{"class":340},[261,4411,4329],{"class":320},[261,4413,4332],{"class":278},[261,4415,2186],{"class":320},[261,4417,4418,4420,4423,4425,4428,4430],{"class":263,"line":489},[261,4419,492],{"class":267},[261,4421,4422],{"class":320}," (flexyComputed ",[261,4424,770],{"class":267},[261,4426,4427],{"class":320}," flexyComputed.isConnected) ",[261,4429,426],{"class":267},[261,4431,4432],{"class":320}," flexyComputed;\n",[261,4434,4435],{"class":263,"line":508},[261,4436,545],{"class":320},[261,4438,4439],{"class":263,"line":513},[261,4440,328],{"emptyLinePlaceholder":327},[261,4442,4443],{"class":263,"line":529},[261,4444,4445],{"class":440},"  \u002F\u002F ... selectores de reserva, cada vez más genéricos\n",[261,4447,4448,4450,4452],{"class":263,"line":542},[261,4449,556],{"class":267},[261,4451,369],{"class":271},[261,4453,321],{"class":320},[261,4455,4456],{"class":263,"line":548},[261,4457,566],{"class":320},[10,4459,4460],{},"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.",[10,4462,4463,4466],{},[51,4464,4465],{},"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.",[10,4468,4469,4472,4473,4475,4476,4479,4480,4483,4484,4487,4488,4491],{},[51,4470,4471],{},"Un candado para el montaje."," ",[19,4474,4081],{}," es asíncrona. Entre que empieza y termina pueden llegar tres o cuatro llamadas más a ",[19,4477,4478],{},"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 ",[19,4481,4482],{},"isMounting"," hace que esas llamadas salgan sin hacer nada mientras hay un montaje en curso, y ",[19,4485,4486],{},"lastMountedVideoId"," se apunta antes del ",[19,4489,4490],{},"await"," para que ninguna crea que falta montar.",[615,4493,4495],{"id":4494},"estado-6-navegaciones-que-nadie-anuncia","Estado 6: navegaciones que nadie anuncia",[10,4497,4498],{},"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.",[10,4500,4501,4502,4504,4505,4508,4509,4512,4513,4515,4516,243],{},"No todas las navegaciones de YouTube disparan ",[19,4503,78],{},", 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 ",[19,4506,4507],{},"history.replaceState"," sin una navegación completa. Y ",[19,4510,4511],{},"popstate"," solo salta con Atrás y Adelante, no con ",[19,4514,3299],{},": no existe un evento del navegador que avise de un ",[19,4517,3299],{},[10,4519,4520,4521,4523,4524,27,4526,4528],{},"Así que el mundo ",[19,4522,1346],{}," lo crea. Parchea ",[19,4525,3271],{},[19,4527,4507],{}," de la página y avisa al content script cada vez que se llaman:",[252,4530,4532],{"className":254,"code":4531,"language":256,"meta":257,"style":257},"const origPushState = history.pushState;\nhistory.pushState = function (...args) {\n  const ret = origPushState.apply(this, args);\n  window.postMessage({ source: 'yt-sub-fetch-main', type: 'YT_URL_CHANGED', url: window.location.href }, '*');\n  return ret;\n};\n",[19,4533,4534,4546,4566,4587,4614,4621],{"__ignoreMap":257},[261,4535,4536,4538,4541,4543],{"class":263,"line":264},[261,4537,268],{"class":267},[261,4539,4540],{"class":271}," origPushState",[261,4542,275],{"class":267},[261,4544,4545],{"class":320}," history.pushState;\n",[261,4547,4548,4551,4553,4555,4557,4559,4561,4564],{"class":263,"line":324},[261,4549,4550],{"class":320},"history.",[261,4552,3299],{"class":340},[261,4554,275],{"class":267},[261,4556,337],{"class":267},[261,4558,153],{"class":320},[261,4560,1933],{"class":267},[261,4562,4563],{"class":346},"args",[261,4565,1170],{"class":320},[261,4567,4568,4570,4573,4575,4578,4580,4582,4584],{"class":263,"line":331},[261,4569,378],{"class":267},[261,4571,4572],{"class":271}," ret",[261,4574,275],{"class":267},[261,4576,4577],{"class":320}," origPushState.",[261,4579,1605],{"class":340},[261,4581,299],{"class":320},[261,4583,1816],{"class":271},[261,4585,4586],{"class":320},", args);\n",[261,4588,4589,4591,4594,4597,4600,4603,4606,4609,4612],{"class":263,"line":375},[261,4590,3016],{"class":320},[261,4592,4593],{"class":340},"postMessage",[261,4595,4596],{"class":320},"({ source: ",[261,4598,4599],{"class":278},"'yt-sub-fetch-main'",[261,4601,4602],{"class":320},", type: ",[261,4604,4605],{"class":278},"'YT_URL_CHANGED'",[261,4607,4608],{"class":320},", url: window.location.href }, ",[261,4610,4611],{"class":278},"'*'",[261,4613,2186],{"class":320},[261,4615,4616,4618],{"class":263,"line":411},[261,4617,556],{"class":267},[261,4619,4620],{"class":320}," ret;\n",[261,4622,4623],{"class":263,"line":417},[261,4624,1788],{"class":320},[10,4626,4627,4628,4630,4631,4634,4635,4638],{},"Esto solo funciona en ",[19,4629,1346],{},". El ",[19,4632,4633],{},"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 ",[19,4636,4637],{},"location.href"," ya sea la URL nueva cuando el content script la lea.",[10,4640,4641],{},"Y como red final, un sondeo cada 400 ms:",[252,4643,4645],{"className":254,"code":4644,"language":256,"meta":257,"style":257},"const navInterval = setInterval(() => {\n  void checkNavigation(ctx);\n}, 400);\n",[19,4646,4647,4665,4674],{"__ignoreMap":257},[261,4648,4649,4651,4654,4656,4659,4661,4663],{"class":263,"line":264},[261,4650,268],{"class":267},[261,4652,4653],{"class":271}," navInterval",[261,4655,275],{"class":267},[261,4657,4658],{"class":340}," setInterval",[261,4660,4135],{"class":320},[261,4662,815],{"class":267},[261,4664,372],{"class":320},[261,4666,4667,4670,4672],{"class":263,"line":324},[261,4668,4669],{"class":267},"  void",[261,4671,4191],{"class":340},[261,4673,4194],{"class":320},[261,4675,4676,4679,4682],{"class":263,"line":331},[261,4677,4678],{"class":320},"}, ",[261,4680,4681],{"class":271},"400",[261,4683,2186],{"class":320},[10,4685,4686,4687,4689,4690,4692],{},"Tengo sentimientos encontrados con este sondeo. Es la admisión de que no me fío de nada de lo anterior. Pero ",[19,4688,4478],{}," es barata cuando no hay nada que hacer: lee ",[19,4691,4637],{},", 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.",[615,4694,4696],{"id":4695},"las-cinco-capas-juntas","Las cinco capas, juntas",[10,4698,4699],{},"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:",[4701,4702,4703,4719],"table",{},[4704,4705,4706],"thead",{},[4707,4708,4709,4713,4716],"tr",{},[4710,4711,4712],"th",{},"Capa",[4710,4714,4715],{},"Qué detecta",[4710,4717,4718],{},"Qué se le escapa",[4720,4721,4722,4742,4754,4772,4785],"tbody",{},[4707,4723,4724,4733,4736],{},[4725,4726,4727,4728,27,4731],"td",{},"Eventos ",[19,4729,4730],{},"yt-navigate-*",[19,4732,3962],{},[4725,4734,4735],{},"Navegaciones normales de YouTube",[4725,4737,4738,4739,4741],{},"No son públicos; no siempre llegan a ",[19,4740,1327],{}," ni en el momento justo",[4707,4743,4744,4748,4751],{},[4725,4745,4746],{},[19,4747,4511],{},[4725,4749,4750],{},"Atrás y Adelante",[4725,4752,4753],{},"Cualquier navegación hacia delante",[4707,4755,4756,4766,4769],{},[4725,4757,4758,27,4760,4763,4764],{},[19,4759,3299],{},[19,4761,4762],{},"replaceState"," parcheados en ",[19,4765,1346],{},[4725,4767,4768],{},"Cada cambio de URL, en el instante",[4725,4770,4771],{},"Nada de lo que cambia el DOM sin cambiar la URL",[4707,4773,4774,4779,4782],{},[4725,4775,4776,4778],{},[19,4777,4099],{}," agrupado por fotograma",[4725,4780,4781],{},"Barras recreadas, botones arrancados",[4725,4783,4784],{},"Nada si la URL cambia y el DOM no",[4707,4786,4787,4790,4793],{},[4725,4788,4789],{},"Sondeo cada 400 ms",[4725,4791,4792],{},"Todo, con hasta 400 ms de retraso",[4725,4794,4795],{},"Nada, pero es fuerza bruta",[10,4797,4798,4799,4801],{},"Todas llaman a la misma función, ",[19,4800,4478],{},", y por eso tiene que ser idempotente. Esto es lo que hace cada vez:",[252,4803,4805],{"className":2793,"code":4804,"language":2795,"meta":257,"style":257},"flowchart TD\n  A[checkNavigation] --> B{¿montaje en curso?}\n  B -- sí --> Z[salir]\n  B -- no --> C{¿es \u002Fwatch?v=…?}\n  C -- no --> D[quitar el botón si lo hay] --> Z\n  C -- sí --> E{¿ha cambiado el vídeo?}\n  E -- sí --> F[quitar el botón anterior] --> G\n  E -- no --> G[buscar la barra buena y conectada]\n  G -- no hay --> Z\n  G -- hay --> H{¿mi botón está dentro y es de este vídeo?}\n  H -- sí --> Z\n  H -- no --> I[quitar duplicados y montar]\n",[19,4806,4807,4812,4817,4822,4827,4832,4837,4842,4847,4852,4857,4862],{"__ignoreMap":257},[261,4808,4809],{"class":263,"line":264},[261,4810,4811],{},"flowchart TD\n",[261,4813,4814],{"class":263,"line":324},[261,4815,4816],{},"  A[checkNavigation] --> B{¿montaje en curso?}\n",[261,4818,4819],{"class":263,"line":331},[261,4820,4821],{},"  B -- sí --> Z[salir]\n",[261,4823,4824],{"class":263,"line":375},[261,4825,4826],{},"  B -- no --> C{¿es \u002Fwatch?v=…?}\n",[261,4828,4829],{"class":263,"line":411},[261,4830,4831],{},"  C -- no --> D[quitar el botón si lo hay] --> Z\n",[261,4833,4834],{"class":263,"line":417},[261,4835,4836],{},"  C -- sí --> E{¿ha cambiado el vídeo?}\n",[261,4838,4839],{"class":263,"line":432},[261,4840,4841],{},"  E -- sí --> F[quitar el botón anterior] --> G\n",[261,4843,4844],{"class":263,"line":437},[261,4845,4846],{},"  E -- no --> G[buscar la barra buena y conectada]\n",[261,4848,4849],{"class":263,"line":444},[261,4850,4851],{},"  G -- no hay --> Z\n",[261,4853,4854],{"class":263,"line":450},[261,4855,4856],{},"  G -- hay --> H{¿mi botón está dentro y es de este vídeo?}\n",[261,4858,4859],{"class":263,"line":475},[261,4860,4861],{},"  H -- sí --> Z\n",[261,4863,4864],{"class":263,"line":489},[261,4865,4866],{},"  H -- no --> I[quitar duplicados y montar]\n",[10,4868,4869],{},"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.",[10,4871,4872,4873,4875],{},"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 ",[19,4874,214],{},", y al volver a un vídeo no había nada escuchando.",[10,4877,4878,4879,4882],{},"Todo se limpia en ",[19,4880,4881],{},"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.",[43,4884,4886],{"id":4885},"el-botón-por-dentro-shadow-dom-y-css","El botón por dentro: Shadow DOM y CSS",[10,4888,4889],{},"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.",[615,4891,4893,4896],{"id":4892},"contentcss-y-cors",[19,4894,4895],{},"content.css"," y CORS",[10,4898,4899,4900,4903,4904,4906,4907,4910],{},"WXT, con ",[19,4901,4902],{},"cssInjectionMode: 'ui'",", compila los estilos del content script en un ",[19,4905,4895],{}," 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 ",[19,4908,4909],{},"web_accessible_resources",". Lo declaré, pero no me fiaba de depender de esa carga para algo que tiene que estar en el primer fotograma.",[10,4912,4913,4914,4917,4918,4921,4922,350],{},"La solución definitiva fue no cargar ningún fichero. ",[19,4915,4916],{},"cssInjectionMode: 'manual'"," hace que WXT no inyecte nada, y los estilos van como una cadena en ",[19,4919,4920],{},"nativeMenuStyles.ts"," que se pasa a ",[19,4923,4081],{},[252,4925,4927],{"className":254,"code":4926,"language":256,"meta":257,"style":257},"currentUi = await createShadowRootUi(ctx, {\n  name: 'yt-sub-fetch-native-menu',\n  position: 'inline',\n  anchor: targetContainer,\n  append: 'last',\n  css: NATIVE_MENU_CSS,\n  onMount: (container) => {\n    const app = createApp(NativeSubtitleMenu);\n    app.mount(container);\n    return app;\n  },\n  onRemove: (app) => {\n    app?.unmount();\n  },\n});\n",[19,4928,4929,4944,4954,4964,4969,4979,4989,5006,5021,5032,5039,5043,5059,5069,5073],{"__ignoreMap":257},[261,4930,4931,4934,4936,4938,4941],{"class":263,"line":264},[261,4932,4933],{"class":320},"currentUi ",[261,4935,292],{"class":267},[261,4937,1651],{"class":267},[261,4939,4940],{"class":340}," createShadowRootUi",[261,4942,4943],{"class":320},"(ctx, {\n",[261,4945,4946,4949,4952],{"class":263,"line":324},[261,4947,4948],{"class":320},"  name: ",[261,4950,4951],{"class":278},"'yt-sub-fetch-native-menu'",[261,4953,1387],{"class":320},[261,4955,4956,4959,4962],{"class":263,"line":331},[261,4957,4958],{"class":320},"  position: ",[261,4960,4961],{"class":278},"'inline'",[261,4963,1387],{"class":320},[261,4965,4966],{"class":263,"line":375},[261,4967,4968],{"class":320},"  anchor: targetContainer,\n",[261,4970,4971,4974,4977],{"class":263,"line":411},[261,4972,4973],{"class":320},"  append: ",[261,4975,4976],{"class":278},"'last'",[261,4978,1387],{"class":320},[261,4980,4981,4984,4987],{"class":263,"line":417},[261,4982,4983],{"class":320},"  css: ",[261,4985,4986],{"class":271},"NATIVE_MENU_CSS",[261,4988,1387],{"class":320},[261,4990,4991,4994,4997,5000,5002,5004],{"class":263,"line":432},[261,4992,4993],{"class":340},"  onMount",[261,4995,4996],{"class":320},": (",[261,4998,4999],{"class":346},"container",[261,5001,782],{"class":320},[261,5003,815],{"class":267},[261,5005,372],{"class":320},[261,5007,5008,5010,5013,5015,5018],{"class":263,"line":437},[261,5009,478],{"class":267},[261,5011,5012],{"class":271}," app",[261,5014,275],{"class":267},[261,5016,5017],{"class":340}," createApp",[261,5019,5020],{"class":320},"(NativeSubtitleMenu);\n",[261,5022,5023,5026,5029],{"class":263,"line":444},[261,5024,5025],{"class":320},"    app.",[261,5027,5028],{"class":340},"mount",[261,5030,5031],{"class":320},"(container);\n",[261,5033,5034,5036],{"class":263,"line":450},[261,5035,1189],{"class":267},[261,5037,5038],{"class":320}," app;\n",[261,5040,5041],{"class":263,"line":475},[261,5042,1415],{"class":320},[261,5044,5045,5048,5050,5053,5055,5057],{"class":263,"line":489},[261,5046,5047],{"class":340},"  onRemove",[261,5049,4996],{"class":320},[261,5051,5052],{"class":346},"app",[261,5054,782],{"class":320},[261,5056,815],{"class":267},[261,5058,372],{"class":320},[261,5060,5061,5064,5067],{"class":263,"line":508},[261,5062,5063],{"class":320},"    app?.",[261,5065,5066],{"class":340},"unmount",[261,5068,835],{"class":320},[261,5070,5071],{"class":263,"line":513},[261,5072,1415],{"class":320},[261,5074,5075],{"class":263,"line":529},[261,5076,1101],{"class":320},[10,5078,5079,5080,1144,5083,5086,5087,5090],{},"El precio es que el menú no usa Tailwind: son unas 300 líneas de CSS escrito a mano, con clases propias (",[19,5081,5082],{},"yt-sub-pill-btn",[19,5084,5085],{},"yt-sub-popover",") y una variante ",[19,5088,5089],{},".yt-sub-dark"," para cada una.",[615,5092,5094],{"id":5093},"clics-fuera-del-menú","Clics fuera del menú",[10,5096,5097,5098,5101,5102,5105,5106,5109,5110,5112,5113,5116,5117,5120,5121,350],{},"El menú se cierra al hacer clic fuera, con un listener en ",[19,5099,5100],{},"document",". Comprobaba ",[19,5103,5104],{},"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 ",[51,5107,5108],{},"retargetea",": visto desde ",[19,5111,5100],{},", su ",[19,5114,5115],{},"target"," es el elemento anfitrión, no el botón real que pulsaste, y ",[19,5118,5119],{},"closest"," no encuentra el contenedor. La ruta completa sí está en ",[19,5122,5123],{},"composedPath()",[252,5125,5127],{"className":254,"code":5126,"language":256,"meta":257,"style":257},"const isInside = event.composedPath().some(\n  (el) => el instanceof HTMLElement && el.classList.contains('yt-sub-menu-container')\n);\nif (!isInside && isOpen.value) {\n  isOpen.value = false;\n}\n",[19,5128,5129,5152,5187,5191,5207,5218],{"__ignoreMap":257},[261,5130,5131,5133,5136,5138,5141,5144,5146,5149],{"class":263,"line":264},[261,5132,268],{"class":267},[261,5134,5135],{"class":271}," isInside",[261,5137,275],{"class":267},[261,5139,5140],{"class":320}," event.",[261,5142,5143],{"class":340},"composedPath",[261,5145,1178],{"class":320},[261,5147,5148],{"class":340},"some",[261,5150,5151],{"class":320},"(\n",[261,5153,5154,5156,5159,5161,5163,5166,5169,5171,5174,5177,5180,5182,5185],{"class":263,"line":324},[261,5155,3789],{"class":320},[261,5157,5158],{"class":346},"el",[261,5160,782],{"class":320},[261,5162,815],{"class":267},[261,5164,5165],{"class":320}," el ",[261,5167,5168],{"class":267},"instanceof",[261,5170,4272],{"class":340},[261,5172,5173],{"class":267}," &&",[261,5175,5176],{"class":320}," el.classList.",[261,5178,5179],{"class":340},"contains",[261,5181,299],{"class":320},[261,5183,5184],{"class":278},"'yt-sub-menu-container'",[261,5186,3433],{"class":320},[261,5188,5189],{"class":263,"line":331},[261,5190,2186],{"class":320},[261,5192,5193,5195,5197,5199,5202,5204],{"class":263,"line":375},[261,5194,2396],{"class":267},[261,5196,153],{"class":320},[261,5198,497],{"class":267},[261,5200,5201],{"class":320},"isInside ",[261,5203,770],{"class":267},[261,5205,5206],{"class":320}," isOpen.value) {\n",[261,5208,5209,5212,5214,5216],{"class":263,"line":411},[261,5210,5211],{"class":320},"  isOpen.value ",[261,5213,292],{"class":267},[261,5215,2097],{"class":271},[261,5217,321],{"class":320},[261,5219,5220],{"class":263,"line":417},[261,5221,566],{"class":320},[615,5223,5225],{"id":5224},"un-menú-que-no-cabe","Un menú que no cabe",[10,5227,5228,5229,5232,5233,5236,5237,5240],{},"La barra de acciones de YouTube recorta lo que se sale de ella, así que un menú con ",[19,5230,5231],{},"position: absolute"," debajo del botón quedaba cortado. El menú se posiciona con coordenadas de la ventana a partir de ",[19,5234,5235],{},"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 ",[19,5238,5239],{},"nextTick",", porque el botón puede moverse cuando Vue pinta la lista.",[10,5242,5243,5244,5247,5248,5251,5252,5255,5256,5258],{},"El tema oscuro se detecta mirando el atributo ",[19,5245,5246],{},"dark"," de ",[19,5249,5250],{},"\u003Chtml>"," y de ",[19,5253,5254],{},"ytd-app",", que es como YouTube marca su modo oscuro, con un ",[19,5257,4099],{}," sobre esos atributos para cambiar en vivo.",[43,5260,5262,5263,5265,5266],{"id":5261},"descargar-un-srt-que-siga-siendo-srt","Descargar un ",[19,5264,21],{}," que siga siendo ",[19,5267,21],{},[10,5269,5270,5271,5274,5275,5278,5279,5281,5282,5285,5286,5289,5290,5293,5294,243],{},"La descarga también dio guerra. El SRT se mete en un ",[19,5272,5273],{},"Blob",", se crea una URL de objeto y se descarga con un nombre como ",[19,5276,5277],{},"Como_vuelan_los_aviones_es_auto.srt",". Primero el ",[19,5280,5273],{}," iba como ",[19,5283,5284],{},"text\u002Fplain",", y Chrome lo guardaba como ",[19,5287,5288],{},".txt",": ajusta la extensión del fichero al tipo MIME. Con ",[19,5291,5292],{},"application\u002Fx-subrip;charset=utf-8"," respeta el ",[19,5295,21],{},[10,5297,5298,5299,5302,5303,5306,5307,5310],{},"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 ",[19,5300,5301],{},"browser.downloads",". Así que ",[19,5304,5305],{},"downloadSrt"," usa un enlace con el atributo ",[19,5308,5309],{},"download",", que funciona en cualquier página:",[252,5312,5314],{"className":254,"code":5313,"language":256,"meta":257,"style":257},"const a = document.createElement('a');\na.href = url;\na.download = filename;\ndocument.body.appendChild(a);\na.click();\ndocument.body.removeChild(a);\nsetTimeout(() => URL.revokeObjectURL(url), 10000);\n",[19,5315,5316,5337,5347,5357,5368,5378,5387],{"__ignoreMap":257},[261,5317,5318,5320,5323,5325,5327,5330,5332,5335],{"class":263,"line":264},[261,5319,268],{"class":267},[261,5321,5322],{"class":271}," a",[261,5324,275],{"class":267},[261,5326,4290],{"class":320},[261,5328,5329],{"class":340},"createElement",[261,5331,299],{"class":320},[261,5333,5334],{"class":278},"'a'",[261,5336,2186],{"class":320},[261,5338,5339,5342,5344],{"class":263,"line":324},[261,5340,5341],{"class":320},"a.href ",[261,5343,292],{"class":267},[261,5345,5346],{"class":320}," url;\n",[261,5348,5349,5352,5354],{"class":263,"line":331},[261,5350,5351],{"class":320},"a.download ",[261,5353,292],{"class":267},[261,5355,5356],{"class":320}," filename;\n",[261,5358,5359,5362,5365],{"class":263,"line":375},[261,5360,5361],{"class":320},"document.body.",[261,5363,5364],{"class":340},"appendChild",[261,5366,5367],{"class":320},"(a);\n",[261,5369,5370,5373,5376],{"class":263,"line":411},[261,5371,5372],{"class":320},"a.",[261,5374,5375],{"class":340},"click",[261,5377,835],{"class":320},[261,5379,5380,5382,5385],{"class":263,"line":417},[261,5381,5361],{"class":320},[261,5383,5384],{"class":340},"removeChild",[261,5386,5367],{"class":320},[261,5388,5389,5392,5394,5396,5398,5400,5403,5406,5409],{"class":263,"line":432},[261,5390,5391],{"class":340},"setTimeout",[261,5393,4135],{"class":320},[261,5395,815],{"class":267},[261,5397,1488],{"class":271},[261,5399,243],{"class":320},[261,5401,5402],{"class":340},"revokeObjectURL",[261,5404,5405],{"class":320},"(url), ",[261,5407,5408],{"class":271},"10000",[261,5410,2186],{"class":320},[10,5412,5413,5414,5417,5418,243],{},"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 ",[19,5415,5416],{},"\\ \u002F : * ? \" \u003C > |"," (prohibidos en Windows) y cambia los espacios por guiones bajos. Si el título queda vacío, el nombre empieza por ",[19,5419,5420],{},"video",[43,5422,5424],{"id":5423},"el-camino-de-vuelta-tus-subtítulos-sobre-el-vídeo","El camino de vuelta: tus subtítulos sobre el vídeo",[10,5426,5427,5428,5430,5431,5433],{},"Con la descarga resuelta, la extensión ganó la función contraria. Descargas el ",[19,5429,21],{},", 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 ",[24,5432,134],{}," del popup.",[615,5435,5437],{"id":5436},"del-fichero-a-los-cues","Del fichero a los cues",[10,5439,5440,5443,5444,5447,5448,5451,5452,5454,5455,5458],{},[19,5441,5442],{},"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 ",[19,5445,5446],{},"-->"," 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 (",[19,5449,5450],{},"01:23.456","). El parser normaliza la coma a punto, acepta tiempos de dos y de tres partes y busca la línea del ",[19,5453,5446],{}," en lugar de suponer que es la segunda, así que le dan igual el número de bloque del SRT, la cabecera ",[19,5456,5457],{},"WEBVTT"," o los ajustes de posición que WebVTT añade detrás del tiempo.",[10,5460,5461,5462,1144,5465,5468,5469,1144,5472,1144,5475,5478,5479,5482],{},"Cada cue sale con ",[19,5463,5464],{},"startMs",[19,5466,5467],{},"endMs"," y el texto sin etiquetas (",[19,5470,5471],{},"\u003Ci>",[19,5473,5474],{},"\u003Cb>",[19,5476,5477],{},"\u003Cfont>",", 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 ",[19,5480,5481],{},"v-html",", así que quitar las etiquetas es por limpieza: aunque quedara alguna, se vería como texto.",[10,5484,5485,5486,5489,5490,5493],{},"El fichero se lee con un ",[19,5487,5488],{},"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 ",[19,5491,5492],{},"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.",[615,5495,5497],{"id":5496},"dentro-del-reproductor","Dentro del reproductor",[10,5499,5500,5501,5503,5504,5506,5507,5510,5511,5513],{},"El overlay es un segundo ",[19,5502,4081],{},", montado esta vez dentro de ",[19,5505,1335],{},". Podría haberlo puesto encima del vídeo con ",[19,5508,5509],{},"position: fixed"," y coordenadas de la ventana, como el menú. Pero ",[19,5512,1335],{}," 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.",[10,5515,5516,5517,5520],{},"La capa ocupa todo el reproductor con ",[19,5518,5519],{},"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.",[10,5522,5523,5524,5526,5527,5530,5531,5534],{},"El montaje tropezó con lo mismo que el del botón: ",[19,5525,4081],{}," puede devolver una promesa. La primera versión llamaba a ",[19,5528,5529],{},"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 ",[19,5532,5533],{},"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.",[615,5536,5538],{"id":5537},"un-tiempo-por-fotograma","Un tiempo por fotograma",[10,5540,5541,5542,5545,5546,5549,5550,5552],{},"La primera versión sacaba el tiempo del evento ",[19,5543,5544],{},"timeupdate"," del ",[19,5547,5548],{},"\u003Cvideo>",". Funcionaba, pero los subtítulos iban a saltos: ",[19,5551,5544],{}," 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.",[10,5554,5555,5556,5559,5560,5562],{},"Ahora, mientras el vídeo se reproduce, el tiempo se lee en cada fotograma con ",[19,5557,5558],{},"requestVideoFrameCallback",", que avisa cuando el vídeo pinta un fotograma nuevo, y con ",[19,5561,4115],{}," como alternativa si no existe:",[252,5564,5566],{"className":254,"code":5565,"language":256,"meta":257,"style":257},"const scheduleNextFrame = () => {\n  if (!video || video.paused || video.ended) {\n    stopSyncLoop();\n    return;\n  }\n\n  if (typeof (video as any).requestVideoFrameCallback === 'function') {\n    rvfcHandle = (video as any).requestVideoFrameCallback(() => {\n      updateCurrentTime();\n      scheduleNextFrame();\n    });\n  } else if (typeof requestAnimationFrame === 'function') {\n    rafHandle = requestAnimationFrame(() => {\n      updateCurrentTime();\n      scheduleNextFrame();\n    });\n  }\n};\n",[19,5567,5568,5583,5604,5611,5617,5621,5625,5651,5674,5681,5688,5693,5716,5731,5737,5743,5747,5751],{"__ignoreMap":257},[261,5569,5570,5572,5575,5577,5579,5581],{"class":263,"line":264},[261,5571,268],{"class":267},[261,5573,5574],{"class":340}," scheduleNextFrame",[261,5576,275],{"class":267},[261,5578,2111],{"class":320},[261,5580,815],{"class":267},[261,5582,372],{"class":320},[261,5584,5585,5587,5589,5591,5594,5596,5599,5601],{"class":263,"line":324},[261,5586,420],{"class":267},[261,5588,153],{"class":320},[261,5590,497],{"class":267},[261,5592,5593],{"class":320},"video ",[261,5595,761],{"class":267},[261,5597,5598],{"class":320}," video.paused ",[261,5600,761],{"class":267},[261,5602,5603],{"class":320}," video.ended) {\n",[261,5605,5606,5609],{"class":263,"line":331},[261,5607,5608],{"class":340},"    stopSyncLoop",[261,5610,835],{"class":320},[261,5612,5613,5615],{"class":263,"line":375},[261,5614,1189],{"class":267},[261,5616,321],{"class":320},[261,5618,5619],{"class":263,"line":411},[261,5620,545],{"class":320},[261,5622,5623],{"class":263,"line":417},[261,5624,328],{"emptyLinePlaceholder":327},[261,5626,5627,5629,5631,5634,5637,5639,5641,5644,5646,5649],{"class":263,"line":432},[261,5628,420],{"class":267},[261,5630,153],{"class":320},[261,5632,5633],{"class":267},"typeof",[261,5635,5636],{"class":320}," (video ",[261,5638,389],{"class":267},[261,5640,1906],{"class":271},[261,5642,5643],{"class":320},").requestVideoFrameCallback ",[261,5645,854],{"class":267},[261,5647,5648],{"class":278}," 'function'",[261,5650,1170],{"class":320},[261,5652,5653,5656,5658,5660,5662,5664,5666,5668,5670,5672],{"class":263,"line":437},[261,5654,5655],{"class":320},"    rvfcHandle ",[261,5657,292],{"class":267},[261,5659,5636],{"class":320},[261,5661,389],{"class":267},[261,5663,1906],{"class":271},[261,5665,829],{"class":320},[261,5667,5558],{"class":340},[261,5669,4135],{"class":320},[261,5671,815],{"class":267},[261,5673,372],{"class":320},[261,5675,5676,5679],{"class":263,"line":444},[261,5677,5678],{"class":340},"      updateCurrentTime",[261,5680,835],{"class":320},[261,5682,5683,5686],{"class":263,"line":450},[261,5684,5685],{"class":340},"      scheduleNextFrame",[261,5687,835],{"class":320},[261,5689,5690],{"class":263,"line":475},[261,5691,5692],{"class":320},"    });\n",[261,5694,5695,5697,5700,5703,5705,5707,5710,5712,5714],{"class":263,"line":489},[261,5696,3443],{"class":320},[261,5698,5699],{"class":267},"else",[261,5701,5702],{"class":267}," if",[261,5704,153],{"class":320},[261,5706,5633],{"class":267},[261,5708,5709],{"class":320}," requestAnimationFrame ",[261,5711,854],{"class":267},[261,5713,5648],{"class":278},[261,5715,1170],{"class":320},[261,5717,5718,5721,5723,5725,5727,5729],{"class":263,"line":508},[261,5719,5720],{"class":320},"    rafHandle ",[261,5722,292],{"class":267},[261,5724,4166],{"class":340},[261,5726,4135],{"class":320},[261,5728,815],{"class":267},[261,5730,372],{"class":320},[261,5732,5733,5735],{"class":263,"line":513},[261,5734,5678],{"class":340},[261,5736,835],{"class":320},[261,5738,5739,5741],{"class":263,"line":529},[261,5740,5685],{"class":340},[261,5742,835],{"class":320},[261,5744,5745],{"class":263,"line":542},[261,5746,5692],{"class":320},[261,5748,5749],{"class":263,"line":548},[261,5750,545],{"class":320},[261,5752,5753],{"class":263,"line":553},[261,5754,1788],{"class":320},[10,5756,5757,5758,27,5761,5764,5765,27,5768,5771,5772,1144,5774,27,5777,5780],{},"El bucle arranca con ",[19,5759,5760],{},"play",[19,5762,5763],{},"playing"," y se para con ",[19,5766,5767],{},"pause",[19,5769,5770],{},"ended",", así que con el vídeo parado no cuesta nada. ",[19,5773,5544],{},[19,5775,5776],{},"seeking",[19,5778,5779],{},"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.",[615,5782,5784],{"id":5783},"cues-que-se-pisan","Cues que se pisan",[10,5786,5787],{},"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.",[10,5789,5790,5791,5793],{},"La probé con un ",[19,5792,21],{}," 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:",[252,5795,5799],{"className":5796,"code":5798,"language":960,"meta":257},[5797],"language-text","3\n00:00:16,280 --> 00:00:24,280\n>> The water's rising, clear as glass.\n\n4\n00:00:20,280 --> 00:00:26,720\nA silent senses of the past.\n",[19,5800,5798],{"__ignoreMap":257},[10,5802,5803,5804,5807],{},"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 ",[19,5805,5806],{},"findActiveCues"," recorre la lista entera y devuelve todos los activos. El overlay los junta en líneas, sin repetir textos iguales:",[252,5809,5811],{"className":254,"code":5810,"language":256,"meta":257,"style":257},"const targetTime = currentTimeMs + syncOffsetMs;\nif (targetTime \u003C 0) return [];\n\nconst active: SubtitleCue[] = [];\nfor (let i = 0; i \u003C cues.length; i++) {\n  const cue = cues[i];\n  if (targetTime >= cue.startMs && targetTime \u003C= cue.endMs) {\n    active.push(cue);\n  }\n}\n",[19,5812,5813,5830,5848,5852,5871,5905,5917,5940,5951,5955],{"__ignoreMap":257},[261,5814,5815,5817,5820,5822,5825,5827],{"class":263,"line":264},[261,5816,268],{"class":267},[261,5818,5819],{"class":271}," targetTime",[261,5821,275],{"class":267},[261,5823,5824],{"class":320}," currentTimeMs ",[261,5826,905],{"class":267},[261,5828,5829],{"class":320}," syncOffsetMs;\n",[261,5831,5832,5834,5837,5839,5841,5843,5845],{"class":263,"line":324},[261,5833,2396],{"class":267},[261,5835,5836],{"class":320}," (targetTime ",[261,5838,1508],{"class":267},[261,5840,779],{"class":271},[261,5842,782],{"class":320},[261,5844,426],{"class":267},[261,5846,5847],{"class":320}," [];\n",[261,5849,5850],{"class":263,"line":331},[261,5851,328],{"emptyLinePlaceholder":327},[261,5853,5854,5856,5859,5861,5864,5867,5869],{"class":263,"line":375},[261,5855,268],{"class":267},[261,5857,5858],{"class":271}," active",[261,5860,350],{"class":267},[261,5862,5863],{"class":340}," SubtitleCue",[261,5865,5866],{"class":320},"[] ",[261,5868,292],{"class":267},[261,5870,5847],{"class":320},[261,5872,5873,5876,5878,5880,5883,5885,5887,5890,5892,5895,5898,5901,5903],{"class":263,"line":411},[261,5874,5875],{"class":267},"for",[261,5877,153],{"class":320},[261,5879,3516],{"class":267},[261,5881,5882],{"class":320}," i ",[261,5884,292],{"class":267},[261,5886,779],{"class":271},[261,5888,5889],{"class":320},"; i ",[261,5891,1508],{"class":267},[261,5893,5894],{"class":320}," cues.",[261,5896,5897],{"class":271},"length",[261,5899,5900],{"class":320},"; i",[261,5902,978],{"class":267},[261,5904,1170],{"class":320},[261,5906,5907,5909,5912,5914],{"class":263,"line":417},[261,5908,378],{"class":267},[261,5910,5911],{"class":271}," cue",[261,5913,275],{"class":267},[261,5915,5916],{"class":320}," cues[i];\n",[261,5918,5919,5921,5923,5926,5929,5931,5934,5937],{"class":263,"line":432},[261,5920,420],{"class":267},[261,5922,5836],{"class":320},[261,5924,5925],{"class":267},">=",[261,5927,5928],{"class":320}," cue.startMs ",[261,5930,770],{"class":267},[261,5932,5933],{"class":320}," targetTime ",[261,5935,5936],{"class":267},"\u003C=",[261,5938,5939],{"class":320}," cue.endMs) {\n",[261,5941,5942,5945,5948],{"class":263,"line":437},[261,5943,5944],{"class":320},"    active.",[261,5946,5947],{"class":340},"push",[261,5949,5950],{"class":320},"(cue);\n",[261,5952,5953],{"class":263,"line":444},[261,5954,545],{"class":320},[261,5956,5957],{"class":263,"line":450},[261,5958,566],{"class":320},[10,5960,5961],{},"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.",[10,5963,3059,5964,5967],{},[19,5965,5966],{},"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.",[615,5969,5971],{"id":5970},"ajustes-que-viajan-solos","Ajustes que viajan solos",[10,5973,5974,5975,5978,5979,5982,5983,5986],{},"Tamaño, tipografía, colores, contorno, posición y desfase son un único objeto que ",[19,5976,5977],{},"useSubtitleSettings"," guarda en el almacenamiento local de la extensión con ",[19,5980,5981],{},"wxt\u002Fstorage",". El composable se usa en el popup y en el overlay, y los dos escuchan la misma clave con ",[19,5984,5985],{},"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.",[10,5988,5989,5990,5993],{},"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 ",[19,5991,5992],{},"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.",[43,5995,5997],{"id":5996},"tests","Tests",[10,5999,6000,6001,6003,6004,6006,6007,1144,6009,6011,6012,6015,6016,6019,6020,6023],{},"Hay 208 tests de ",[51,6002,116],{},". La mayoría son unitarios contra ",[19,6005,175],{},": el parseo de la respuesta del reproductor con todos sus estados (",[19,6008,604],{},[19,6010,608],{}," con y sin motivo, sin pistas, nombres en ",[19,6013,6014],{},"simpleText"," o en ",[19,6017,6018],{},"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 ",[19,6021,6022],{},"URLSearchParams"," no rompa la codificación de la firma, un fallo que llegó a pasar) y el tema y el idioma.",[10,6025,6026,6028,6029,6031,6032,6034,6035,6037,6038,6041,6042,6044,6045,34,6048,6051,6052,6055,6056,6058,6059,6061],{},[19,6027,2276],{}," se prueba con un ",[19,6030,1335],{}," falso que apunta cada ",[19,6033,1339],{}," 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 ",[19,6036,1339],{}," contesta ",[19,6039,6040],{},"PLAYER_UNAVAILABLE",", y que una respuesta que llega después del timeout no vuelve a tocar el reproductor. Los envoltorios de ",[19,6043,1246],{}," y XHR se prueban con respuestas simuladas: que las URLs ajenas pasan sin tocar, que el cuerpo se clona, que aceptan un string, una ",[19,6046,6047],{},"URL",[19,6049,6050],{},"Request",", que leen bien un ",[19,6053,6054],{},"responseType: 'json'"," y que un cuerpo que no es JSON no lanza nada. El script de ",[19,6057,1346],{}," entero tiene su propio archivo: la caché de la pista activa, que se vacía con ",[19,6060,3936],{},", que una segunda descarga simultánea se rechaza y que la respuesta llega también por XHR.",[10,6063,6064,6065,6068,6069,6071],{},"El montaje del botón tiene su propio archivo, con un DOM falso: que se monta dentro de ",[19,6066,6067],{},"ytd-watch-metadata",", que se quita al salir de ",[19,6070,214],{},", que se vuelve a montar si YouTube recrea la barra con el mismo vídeo y que se registran y limpian todos los listeners.",[10,6073,6074],{},"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.",[10,6076,6077,6078,6081,6082,6085],{},"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 ",[19,6079,6080],{},"console.log"," con el prefijo ",[19,6083,6084],{},"[yt-sub-fetch]",": eran la única forma de saber qué evento había llegado y en qué orden.",[43,6087,6089],{"id":6088},"lo-que-dejaría-distinto","Lo que dejaría distinto",[10,6091,6092],{},"Repasando el código para este artículo he encontrado cosas que hoy haría de otra manera:",[119,6094,6095,6107,6117,6123,6129,6135,6149],{},[122,6096,6097,4472,6102,6104,6105,243],{},[51,6098,6099,6100,243],{},"La caché de pistas no distingue el ",[19,6101,2557],{},[19,6103,2761],{}," se 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 el ",[19,6106,2557],{},[122,6108,6109,6112,6113,6116],{},[51,6110,6111],{},"El tema oscuro mira también el sistema."," La detección acaba con un ",[19,6114,6115],{},"|| 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.",[122,6118,6119,6122],{},[51,6120,6121],{},"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.",[122,6124,6125,6128],{},[51,6126,6127],{},"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.",[122,6130,6131,6134],{},[51,6132,6133],{},"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.",[122,6136,6137,6140,6141,6144,6145,6148],{},[51,6138,6139],{},"Código duplicado."," La lógica de mandar ",[19,6142,6143],{},"TRIGGER_SUBTITLE_FETCH"," y esperar la respuesta está en el content script (para el popup) y en ",[19,6146,6147],{},"contentSubtitles.ts"," (para el botón), casi idéntica. Debería ser una sola función.",[122,6150,6151,6154],{},[51,6152,6153],{},"Los logs."," Fueron imprescindibles para depurar, pero siguen en el build, y en la consola de YouTube se oye cada navegación.",[43,6156,6158],{"id":6157},"lo-que-me-llevo","Lo que me llevo",[10,6160,6161,6162,6164],{},"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 ",[19,6163,1221],{}," con el cuerpo vacío.",[10,6166,6167,6168,6170,6171,6173],{},"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 ",[19,6169,4293],{}," es el que está a la vista o que un evento llega a ",[19,6172,1327],{},". Cada suposición funcionaba al recargar y fallaba al navegar. Probar con F5 era probar el único caso en el que todo encajaba.",[10,6175,6176],{},"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.",[6178,6179,6180],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sA_wV, html code.shiki .sA_wV{--shiki-default:#032F62;--shiki-dark:#DBEDFF}html pre.shiki code .snhLl, html code.shiki .snhLl{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":257,"searchDepth":324,"depth":324,"links":6182},[6183,6184,6185,6189,6193,6200,6201,6210,6216,6218,6225,6226,6227],{"id":45,"depth":324,"text":46},{"id":89,"depth":324,"text":90},{"id":207,"depth":324,"text":208,"children":6186},[6187,6188],{"id":617,"depth":331,"text":618},{"id":1038,"depth":331,"text":1039},{"id":1217,"depth":324,"text":6190,"children":6191},"El muro: 200 OK y el cuerpo vacío",[6192],{"id":1291,"depth":331,"text":1292},{"id":1316,"depth":324,"text":1317,"children":6194},[6195,6196,6198,6199],{"id":1447,"depth":331,"text":1448},{"id":1842,"depth":331,"text":6197},"El reproductor no usa fetch",{"id":2270,"depth":331,"text":2271},{"id":2754,"depth":331,"text":2755},{"id":2777,"depth":324,"text":2778},{"id":3264,"depth":324,"text":3265,"children":6202},[6203,6204,6205,6206,6207,6208,6209],{"id":3281,"depth":331,"text":3282},{"id":3479,"depth":331,"text":3480},{"id":3839,"depth":331,"text":3840},{"id":3952,"depth":331,"text":3953},{"id":4071,"depth":331,"text":4072},{"id":4494,"depth":331,"text":4495},{"id":4695,"depth":331,"text":4696},{"id":4885,"depth":324,"text":4886,"children":6211},[6212,6214,6215],{"id":4892,"depth":331,"text":6213},"content.css y CORS",{"id":5093,"depth":331,"text":5094},{"id":5224,"depth":331,"text":5225},{"id":5261,"depth":324,"text":6217},"Descargar un .srt que siga siendo .srt",{"id":5423,"depth":324,"text":5424,"children":6219},[6220,6221,6222,6223,6224],{"id":5436,"depth":331,"text":5437},{"id":5496,"depth":331,"text":5497},{"id":5537,"depth":331,"text":5538},{"id":5783,"depth":331,"text":5784},{"id":5970,"depth":331,"text":5971},{"id":5996,"depth":324,"text":5997},{"id":6088,"depth":324,"text":6089},{"id":6157,"depth":324,"text":6158},"2026-11-02","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.",false,"md",{},"\u002Farticulos\u002Fsubfetch-por-dentro",{"title":5,"description":6229},"articulos\u002Fsubfetch-por-dentro",[6237,6238,6239,6240],"subfetch","arquitectura","typescript","vue","tecnico","55CLmRS6E2zG2EGyyZIYyYgmIQ4BX1AlGUWUR3f1wO4",null,1793761187667]