Video Accessibility por dentro: una capa de teclado encima de reproductores que no son tuyos
Cómo funciona una extensión de Chrome que añade atajos de teclado a cualquier vídeo HTML. Un permiso amplio con una inyección mínima, encontrar el vídeo correcto entre shadow DOM e iframes, no quedarse con teclas que no son suyas, pulsar botones ajenos con un selector CSS y un aviso en pantalla que pasó de 4,6 MB a 1 KB.
Video Accessibility añade los atajos de YouTube a cualquier web con un <video>: 3 para saltar al 30 %, espacio para pausar, flechas, velocidad, y además eventos personalizados para pulsar cualquier botón con una tecla. Qué hace y cómo se usa está en el artículo para usuarios. Este cuenta cómo está hecha.
La idea cabe en una línea: escuchar el teclado y tocar video.currentTime. Lo difícil es que el reproductor no es tuyo. No sabes cómo está montada la página, si el vídeo está en un iframe o dentro de un web component, qué teclas usa ya la web ni qué botones escucha de verdad. Todo lo que sigue sale de ahí.
La forma de la extensión
Es una extensión Manifest V3 hecha con WXT, Vue 3, TypeScript y Tailwind CSS:
No hay mensajes entre piezas. El popup y la página de opciones escriben en chrome.storage, el service worker y el content script escuchan los cambios. Es el diseño más simple que encontré para que un ajuste se aplique en todas las pestañas sin recargar.
Un permiso amplio y una inyección mínima
El manifiesto pide <all_urls>. Lo hice por comodidad: con permisos opcionales, cada web nueva que añades a la lista dispara un aviso de Chrome para aceptarla. Con el permiso general no hay avisos, pero tampoco quería un script corriendo en todas las webs que visitas. La solución es no declararlo para ninguna.
El content script se declara con un patrón que no casa con nada:
export default defineContentScript({
matches: ['*://*.noop.invalid/*'],
allFrames: true,
matchAboutBlank: true,
matchOriginAsFallback: true,
runAt: 'document_idle',
// ...
});
.invalid es un dominio reservado que nunca va a resolver. WXT necesita un matches para empaquetar el script, y este no se cumple nunca. El registro de verdad lo hace el service worker en tiempo de ejecución, solo para los dominios de la lista:
export function buildContentScriptRegistration(sites: string[]) {
return {
id: CONTENT_SCRIPT_ID,
js: ['content-scripts/video-hotkeys.js'],
css: ['content-scripts/video-hotkeys.css'],
matches: sites.map(domain => `*://*.${domain}/*`),
allFrames: true,
matchOriginAsFallback: true,
runAt: 'document_idle' as const,
};
}
Antes de registrar, se borra el registro anterior, y si la lista está vacía no se registra nada. El service worker solo repite el proceso cuando cambia la lista de sitios, no con cada ajuste que se guarda:
settingsStorage.watch((newSettings, oldSettings) => {
if (JSON.stringify(newSettings?.allowedSites) === JSON.stringify(oldSettings?.allowedSites)) return;
updateRegistrations(newSettings);
});
Esa comprobación llegó tarde. Al principio cualquier cambio, como subir la duración del aviso, desregistraba y volvía a registrar el script.
La línea de css de arriba también llegó tarde, y esta historia es más reciente. Cuando pasé del manifiesto al registro dinámico copié el .js y me olvidé de la hoja de estilos. El script funcionaba, los atajos funcionaban, pero el aviso en pantalla salía como texto suelto debajo del vídeo, con el icono a todo el ancho. Lo vi al hacer las capturas de estos artículos. Ahora el registro sale de una función con un test que comprueba que el CSS va dentro.
Encontrar el vídeo correcto
Cuando pulsas una tecla, primero hay que saber a qué vídeo va. Una página puede tener varios: el principal, una vista previa que se reproduce al pasar el ratón, un anuncio. La regla es sencilla: el que se está reproduciendo y, si no hay ninguno, el más grande.
export function getActiveVideo(): HTMLVideoElement | null {
const videos = findAllVideos(document);
if (videos.length === 0) return null;
const playing = videos.find(v => !v.paused && !v.ended);
if (playing) return playing;
return videos.reduce((prev, curr) =>
curr.offsetWidth * curr.offsetHeight > prev.offsetWidth * prev.offsetHeight ? curr : prev
);
}
findAllVideos no se queda en document.querySelectorAll('video'). Muchos reproductores modernos son web components y meten el <video> dentro de un shadow root, donde querySelectorAll no llega. Así que recorre los elementos y entra en cada shadowRoot que encuentra.
El otro caso son los iframes. Muchas webs insertan el reproductor en un iframe, a menudo de otro dominio. Con allFrames: true el script se carga también dentro de los iframes cuyo dominio está en la lista, y matchOriginAsFallback cubre los frames sin URL propia (about:blank, blob:), que heredan el origen de quien los crea. Cada frame tiene su propio script, que solo ve sus vídeos y solo recibe las teclas cuando tiene el foco.
No quedarse con teclas ajenas
Escuchar keydown es fácil. Lo difícil es saber cuándo no hacer nada. El manejador se registra en fase de captura sobre window, para llegar antes que la web, y lo primero que hace es descartar:
function handleKeyDown(event: KeyboardEvent) {
if (event.repeat) return;
if (isUserTyping()) return;
if (event.ctrlKey || event.altKey || event.metaKey) return;
// ...
}
isUserTyping mira el elemento con el foco: input, textarea, select, cualquier cosa con contenteditable o con rol textbox, search o combobox. Lo que no es obvio es que document.activeElement se queda en la frontera del shadow DOM. Si el buscador de una web es un web component, activeElement devuelve el componente, no el <input> de dentro. Así que hay que bajar:
function getDeepActiveElement(): Element | null {
let element = document.activeElement;
while (element?.shadowRoot?.activeElement) {
element = element.shadowRoot.activeElement;
}
return element;
}
Sin esto, escribir un 3 en un buscador hecho como web component mandaría el vídeo al 30 %.
Si la tecla es un atajo y hay vídeo, se ejecuta la acción y se llama a preventDefault y stopPropagation para que la web no haga lo suyo con la misma tecla. Hay un modo estricto que añade stopImmediatePropagation, para las webs que también escuchan en window en fase de captura. No es el modo por defecto porque es más agresivo.
Dos bugs me enseñaron lo fino que hay que hilar aquí.
El espacio que nunca coincidía. El atajo por defecto para pausar estaba guardado como ' ', que es lo que vale event.key al pulsar espacio. Pero las teclas que llegan se normalizan a nombres legibles, y el espacio pasa a ser 'space'. Así que el atajo por defecto no coincidía nunca. Es fácil que pase desapercibido, porque muchos reproductores ya pausan con espacio por su cuenta. El arreglo fue normalizar los dos lados, también lo que ya estaba guardado, para no romper la configuración de quien la usaba.
Eventos que se quedaban la tecla. Los eventos personalizados se comprueban antes que los atajos. En la primera versión, si un evento tenía asignada una tecla, esa tecla se bloqueaba aunque el selector no encontrara nada en la página. Ahora la función devuelve cuántos elementos ha tocado, y la tecla solo se corta si ha sido más de cero:
if (runCustomEventHotkeys(currentSettings.customEvents, hostname, keyStr) > 0) {
event.preventDefault();
event.stopPropagation();
return;
}
Si no ha encontrado nada, la tecla sigue su camino hacia los atajos normales o hacia la propia web. Un selector mal escrito tampoco rompe el manejador: querySelectorAll lanza una excepción con un selector inválido, y se captura y se registra en vez de dejar que corte el keydown.
Pulsar botones que no son tuyos
Un evento personalizado es un nombre, un dominio, un selector CSS, una acción (clic u ocultar) y, opcionalmente, una tecla. Ocultar es fácil: display: none con !important. Hacer clic tiene truco.
element.click() lanza un evento click, y muchos componentes no escuchan click. Escuchan mousedown o mouseup, sobre todo en reproductores y en interfaces hechas con frameworks. Así que el clic simulado lanza la secuencia completa:
export function simulateClick(el: HTMLElement) {
try { el.focus(); } catch {}
const eventOptions = { bubbles: true, cancelable: true, view: window };
el.dispatchEvent(new MouseEvent('mousedown', eventOptions));
el.dispatchEvent(new MouseEvent('mouseup', eventOptions));
el.click();
}
(En el código real cada paso va en su propio try y hay un dispatchEvent('click') de respaldo por si click no existe.)
Los eventos sin tecla se ejecutan solos, y aquí el problema es el tiempo. En una SPA el botón que quieres pulsar o la capa que quieres esconder no existe cuando carga el script. Aparece después, a veces varias veces. Un MutationObserver sobre todo el documento vuelve a buscar con cada cambio. Para no pulsar el mismo botón en bucle, los elementos ya pulsados se guardan en un Set. Con Ocultar al entrar es al revés: esos elementos no se apuntan, para que si la web los vuelve a pintar se escondan otra vez.
Para escribir el selector sin ir a ciegas, la página de opciones tiene un botón Probar, que ejecuta document.querySelectorAll(selector).length en una pestaña abierta con scripting.executeScript y te dice cuántos elementos encuentra. Antes de eso, comprueba el selector en la propia página de opciones, porque uno con errores de sintaxis lanza una excepción allí mismo.
Estado por vídeo
El bucle A-B y los filtros tienen estado, y ese estado es de un vídeo concreto. Lo guardo en WeakMap con el elemento como clave:
const loopStates = new WeakMap<HTMLVideoElement, LoopState>();
const filterStates = new WeakMap<HTMLVideoElement, FilterState>();
Si la web quita el vídeo del DOM, como hacen muchas SPA al cambiar de página, la entrada desaparece con él. No hay que limpiar nada a mano ni se acumula memoria.
El bucle usa tres pulsaciones: la primera guarda el punto A, la segunda el B (y los intercambia si B queda antes que A) y añade un listener de timeupdate que devuelve el vídeo a A al pasar de B. La tercera quita el listener y borra el estado. Los filtros son un filter: brightness() contrast() saturate() sobre el propio <video>, de 10 en 10 entre 0 % y 300 %.
El modo cine pone una capa negra al 85 % sobre toda la página y sube el z-index del vídeo por encima. Antes guarda su z-index y su position originales para devolverlos al salir. Si el vídeo no está posicionado, le pone position: relative, porque sin eso el z-index no hace nada.
Un aviso de 4,6 MB que ahora pesa 1 KB
Cada acción muestra un aviso sobre el vídeo con un icono y un texto, como el de YouTube. Al principio los iconos venían de Phosphor, la misma librería de iconos de la interfaz, y para eso el CSS del content script importaba la fuente de iconos completa.
Lo que no había pensado es dónde acaba ese CSS. El content script se inyecta en cada web permitida y en cada uno de sus frames. Cada página permitida, tuviera vídeo o no, cargaba una fuente de 4,6 MB para pintar, como mucho, quince iconos.
El aviso usa ahora esos quince iconos como trazados SVG en línea, en un pequeño mapa de nombre de icono a path, y el CSS que se inyecta en la página bajó a 1 KB. La interfaz de la extensión (popup y opciones) sigue usando Phosphor, porque ahí la fuente se carga una vez y es de la propia extensión.
Atajos por sitio desde el popup
La última función nació de un choque. En TikTok, la L da like. Con Video Accessibility activa, la L también marca el bucle A-B. Ya existían los atajos por dominio en las opciones, pero ir allí para apagar una tecla en una web era demasiado.
Ahora el popup enseña los atajos activos con un interruptor al lado de cada uno. Por dentro no hay nada nuevo: apagar un atajo guarda una sobrescritura vacía para ese dominio.
{ "domain": "tiktok.com", "shortcuts": { "abLoop": "" } }
El content script mezcla los atajos globales con los del dominio en el que está, y un atajo vacío no se asocia a ninguna tecla. Al reutilizar la misma estructura, lo que apagas en el popup aparece también en la pestaña Por dominio de las opciones.
Tests
Hay 106 tests de Vitest con jsdom y @vue/test-utils. Cubren la normalización de teclas, la lista de sitios, las acciones sobre el vídeo, los eventos personalizados, el aviso en pantalla, el almacenamiento, el grabador de atajos y el popup. Los bugs de este artículo tienen su test: el espacio, los eventos que se quedaban la tecla, los iconos SVG del aviso y el registro con su CSS. Hay uno más que recorre todo el código de la extensión y falla si encuentra una URL externa: el popup y las opciones pedían los favicons de las webs a Google y cargaban la fuente Inter desde Google Fonts, y ahora los sitios llevan un icono genérico y la fuente va dentro de la extensión.
Las traducciones están en cinco idiomas (español, inglés, francés, portugués y chino), y cada clave nueva se añade en todos.
Lo que me llevo
Una extensión que se mete en páginas ajenas tiene que ser, sobre todo, educada. Que no actúe donde no la has invitado, que no se quede con una tecla que era de la web, que no cargue 4,6 MB para enseñar un icono. Casi todos los bugs que encontré fueron de este tipo: no fallaba la función principal, fallaba la convivencia con la página.
Está en la Chrome Web Store.