Extensión de Chrome (Manifest V3) para guardar momentos concretos de vídeos y Shorts de YouTube y volver a ellos con un clic. El nombre combina “Tube” y “bookmarks”.
Estás viendo un vídeo, en el minuto 08:42 pasa algo que quieres recordar, pulsas un botón, escribes
Se cae de la silla y listo. Después abres la extensión y vuelves a ese segundo exacto.
Guarda una marca con toda la información necesaria para recuperar el momento:
| Campo | Ejemplo |
|---|---|
title |
#ParenLaMano Completo - 09/05 |
videoUrl |
https://www.youtube.com/watch?v=EyaMgKF9bxU |
videoId |
EyaMgKF9bxU |
timestampSeconds |
2822 |
timestampLabel |
47:02 |
note |
Se cae de la silla |
category |
funny |
favorite |
true |
channelName |
Vorterix (cuando se puede leer del DOM) |
thumbnailUrl |
miniatura oficial del vídeo |
createdAt / updatedAt |
fechas ISO 8601 |
isShort |
false |
Guardar
Ctrl + Shift + Y, o ⌘ + Shift + Y en macOS, por defecto) que
abre el panel de guardado en el vídeo actual.Organizar
Gestionar
alert() ni
confirm()).Reproducir
https://www.youtube.com/watch?v=VIDEO_ID&t=SEGUNDOSs.strict, noUncheckedIndexedAccess, verbatimModuleSyntax; sin any).chrome.storage.local como única persistencia. Sin backend, sin base de datos, sin red.No se usa React: la interfaz son dos pantallas con estado local y unas pocas listas. Con
document.createElement y textContent el resultado es más pequeño, más rápido de auditar y,
además, elimina por construcción cualquier riesgo de inyección de HTML.
public/
manifest.json Manifiesto (se copia tal cual a dist/)
icons/ Iconos PNG generados por script
src/
background/
service-worker.ts Único punto de escritura; atajo de teclado; apertura de marcas
content/
content-script.ts Orquestador dentro de YouTube
youtube-navigation.ts Detección de la navegación SPA
youtube-player.ts Lectura del <video> y de los metadatos
injected-button.ts Botón dentro de los controles del reproductor
timeline-markers.ts Marcas del vídeo actual sobre la barra de progreso
overlay-ui.ts Panel de nota y avisos (shadow DOM)
overlay.css Estilos del overlay (se inyectan como hoja construida)
popup/ popup.html · popup.ts · popup.css
options/ options.html · options.ts · options.css
storage/
bookmark-repository.ts Repositorio con cola de escritura
storage-area.ts Adaptador de chrome.storage (+ versión en memoria para tests)
shared/
models.ts constants.ts messages.ts result.ts
time.ts youtube-url.ts validation.ts csv.ts import-file.ts query.ts
dom.ts download.ts base.css
scripts/
generate-icons.mjs verify-dist.mjs clean.mjs dev.mjs
Flujo de datos. El popup y la página de opciones leen y escriben con el repositorio; todo lo que nace dentro de YouTube (botón del reproductor, panel de nota, atajo de teclado) pasa por el service worker. Así existe un solo camino de guardado y las validaciones no se duplican.
Mensajería tipada. src/shared/messages.ts define los identificadores, la forma de cada mensaje
y la respuesta que le corresponde (MessageResponseMap). No hay cadenas mágicas repartidas por el
código y toda respuesta viaja en un Result<T>, de modo que quien llama trata el error de forma
explícita.
Service worker efímero. Manifest V3 puede detener el service worker en cualquier momento. No se
guarda nada en memoria: cada operación relee chrome.storage.local. Si una pestaña de YouTube se
abrió antes de instalar la extensión y no tiene el content script, el service worker lo inyecta con
chrome.scripting antes de hablar con ella.
Reinstalación en caliente. Al recargar la extensión desde chrome://extensions, el content script
que ya estaba en las pestañas abiertas queda huérfano: sigue vivo en el DOM, pero sus llamadas a
chrome.* fallan. Por eso el content script no usa una simple bandera «ya cargado», que impediría
recuperarse: publica una instancia en window.__youtubeStar con un método dispose(), y cada nueva
carga desmonta la anterior (listeners, temporizadores, botón y overlay) antes de montarse. Así una
segunda inyección nunca duplica la interfaz y, además, la extensión vuelve a funcionar sin necesidad
de recargar la pestaña.
Redondeo del segundo. Se trunca con Math.floor, nunca se redondea al más cercano. YouTube salta
exactamente al segundo indicado en t=, así que truncar garantiza empezar igual o ligeramente
antes del instante que vio el usuario: el momento marcado no se pierde nunca. Redondear hacia
arriba podría dejar fuera el inicio de una frase o de un gag.
Shorts. Se detectan y se guardan igual que cualquier vídeo (con isShort: true para poder
mostrarlo en la interfaz), pero al abrirlos se usa siempre la URL estándar
watch?v=ID&t=Ns. El reproductor de Shorts ignora el parámetro t, de modo que conservar
/shorts/ID haría que el vídeo empezara desde el principio.
Detección de la navegación SPA. Se combinan tres señales: los eventos propios de YouTube
(yt-navigate-finish, yt-page-data-updated…), un MutationObserver sobre el elemento <title> (un
solo nodo, muy barato) y un temporizador de un segundo como red de seguridad. El temporizador cubre
los casos que los eventos no ven: sustitución del elemento <video> y reconstrucción de la barra de
controles al entrar en pantalla completa o modo cine. Se descartó observar todo el body porque en
YouTube dispara miles de veces por minuto sin aportar nada. La inserción del botón es idempotente
(comprueba si sigue conectado a la barra actual), así que no se duplican botones por muchas veces
que se llame.
Icono adaptado al reproductor. YouTube ha cambiado el lienzo de sus iconos: el reproductor clásico
usa viewBox="0 0 36 36" con width/height="100%" y el moderno («delhi») viewBox="0 0 24 24" con
tamaño fijo. En lugar de fijar uno, el botón copia esos atributos del icono de ajustes vecino y escala
su trazado en consecuencia, de modo que encaja y queda centrado en ambos. Si el reproductor es tan
estrecho que YouTube oculta los botones que no le caben, la inserción se deshace y toma el relevo el
botón flotante.
Aislamiento de la interfaz inyectada. El panel de nota y los avisos viven en un shadow root con
una hoja de estilo construida (CSSStyleSheet + adoptedStyleSheets). Ni YouTube afecta a esos
estilos ni la extensión toca los de YouTube, y al no insertar ningún <style> en la página la
política de seguridad de contenido de YouTube no puede bloquearlo. Las pulsaciones de teclas dentro
del panel se detienen en el host para que no activen los atajos de YouTube (espacio, k, flechas).
Duplicados. Una marca se considera repetida si coinciden vídeo, segundo y nota (normalizada). Dos notas distintas en el mismo segundo son intencionadas y se conservan las dos.
Importación en la página de opciones. Chrome cierra el popup al abrir el selector de archivos, así que la importación vive en la página de opciones, que es una pestaña normal. La exportación sí funciona desde el popup porque la descarga se dispara antes de que se cierre.
npm install
npm run dev
Compila en modo --watch (aplicación y content script en paralelo) sobre dist/. Una extensión se
carga desde el sistema de archivos, así que no se usa el servidor de desarrollo de Vite: todo tiene
que existir como archivo real. Tras cada cambio, pulsa Actualizar en chrome://extensions (y
recarga la pestaña de YouTube si tocaste el content script).
npm run build
Genera los iconos, limpia dist/, compila las dos pasadas y comprueba que en dist/ está todo lo que
declara el manifiesto. Al terminar imprime la ruta exacta que hay que cargar en Chrome.
Contenido de dist/:
dist/
manifest.json
service-worker.js
content-script.js
popup/popup.html
options/options.html
assets/*.js assets/*.css
icons/icon16.png icon32.png icon48.png icon128.png
Otros comandos:
npm run typecheck # tsc --noEmit
npm test # Vitest
npm run verify # comprueba dist/ sin recompilar
npm run icons # regenera los iconos PNG
npm install && npm run buildchrome://extensions.dist del proyecto, no la raíz.Si Chrome dice «No se ha podido cargar el archivo de manifiesto», casi siempre es porque se ha seleccionado la carpeta equivocada. La carpeta correcta es la que contiene
manifest.json,service-worker.jsycontent-script.js: es decir,dist/. El comandonpm run buildla imprime al terminar.
Guardar un momento
Ctrl + Shift + Y, o ⌘ + Shift + Y en macOS,
por defecto) abre el panel para describir y guardar el momento.Ctrl + Intro también guarda). El botón circular vuelve a leer el segundo actual del reproductor.Volver a un momento
Abre el popup y pulsa cualquier marca, o su botón ▶. Si el vídeo ya está abierto en otra pestaña, se reutiliza y salta al segundo guardado.
Buscar y filtrar
Escribe en el buscador (la tecla / lleva el foco allí), usa las categorías, el botón ★ Favoritos o
los desplegables de vídeo y canal. Limpiar quita todos los filtros. El orden y los filtros elegidos
se recuerdan para la próxima vez. Si las categorías no caben, recórrelas con las flechas laterales o
con la rueda del ratón sobre esa fila.
Ganar espacio para la lista
El popup mide 400 × 600 px como máximo, así que las dos secciones superiores se pliegan:
Con las dos plegadas, la lista pasa de una tarjeta visible a tres. Ambos estados se recuerdan.
Editar y eliminar
El lápiz abre la edición en línea (nota, categoría y favorito; Ctrl + Intro guarda, Esc cancela).
La papelera pide confirmación en la propia tarjeta.
Exportar e importar
Se admiten archivos CSV con una columna de vídeo y otra de tiempo. Se reconoce el formato habitual:
Title,Video ID,Bookmark Name,Bookmark Time
"Andrés Calamaro - Acústico Rock & Pop (1997) ","ILI0fpo6Y4Y","","47:30"
"#ParenLaMano Completo - 09/05 | Vorterix ","EyaMgKF9bxU","Nace Estelita","47:02"
Title/Título,
Video ID/ID, URL/Enlace, Bookmark Name/Nota, Bookmark Time/Tiempo, Channel/Canal,
Category/Categoría, Favorite/Favorito, Created At/Fecha.47:30, 1:01:53, 522 (segundos) y 1h2m3s.&, é…) se decodifican.| Permiso | Para qué se usa |
|---|---|
storage |
Guardar las marcas y las preferencias en chrome.storage.local. |
tabs |
Saber si la pestaña activa es un vídeo de YouTube y localizar una pestaña que ya tenga ese vídeo abierto para reutilizarla. |
scripting |
Inyectar el content script en pestañas de YouTube que se abrieron antes de instalar o actualizar la extensión. |
host_permissions: https://www.youtube.com/*, https://m.youtube.com/* |
Leer el reproductor y dibujar el botón. La extensión no tiene acceso a ningún otro sitio. |
Permisos que no se piden, a propósito:
downloads: la exportación usa un Blob y un enlace temporal, que no necesita permisos.activeTab: los permisos de host de YouTube ya cubren todo lo que hace falta; añadirlo sería
redundante.notifications: la confirmación se muestra dentro de la propia página de YouTube.unlimitedStorage: 10 000 marcas ocupan aproximadamente 3 MB, muy por debajo del límite de
chrome.storage.local.itemprop, pero
si YouTube lo cambia, la marca se guarda igualmente sin canal. El resto de campos no dependen de
ningún selector frágil.storage.local (10 MB)
en lugar de storage.sync (100 KB), que se quedaría corto enseguida. Para pasar las marcas a otro
equipo, exporta e importa.<video>. Si se guarda un momento mientras suena un anuncio,
el segundo guardado es el del anuncio. Es un caso raro y detectarlo de forma fiable exigiría
inspeccionar la API interna del reproductor.chrome:// no admite content scripts, así que el atajo de teclado no hace nada fuera de YouTube.npm test # una pasada
npm run test:watch
Pruebas automatizadas sobre las piezas con lógica de verdad:
| Archivo | Cubre |
|---|---|
shared/time.test.ts |
Truncado del segundo, formato mm:ss / h:mm:ss, parseo inverso, fechas relativas |
shared/youtube-url.test.ts |
Extracción del ID (watch, Shorts, youtu.be, embed, live), URLs con timestamp, miniaturas |
shared/validation.test.ts |
Normalización de datos de almacenamiento, validación del JSON importado, duplicados, fusión sin pérdidas |
shared/csv.test.ts |
Analizador CSV (comillas, comas internas, separadores), entidades HTML, tiempos flexibles, detección de formato |
shared/query.test.ts |
Búsqueda sin acentos, filtros combinados, las cinco ordenaciones, facetas |
storage/bookmark-repository.test.ts |
Alta, edición, borrado, escrituras simultáneas, datos corruptos, importación, errores de almacenamiento |
content/timeline-markers.test.ts |
Filtrado por vídeo, posición relativa, agrupación y extremos de la barra |
Las APIs de Chrome se aíslan detrás de StorageArea (src/storage/storage-area.ts), que tiene una
implementación en memoria para las pruebas: el repositorio se prueba entero sin navegador.
chrome.storage.sync para las marcas favoritas._locales.