YouTube Playback Plox

Guarda y retoma automáticamente el progreso de videos en YouTube sin necesidad de iniciar sesión.

目前為 2025-10-22 提交的版本,檢視 最新版本

// ==UserScript==
// @name         YouTube Playback Plox
// @namespace    youtube-playback-plox
// @version      0.0.1
// @description  Guarda y retoma automáticamente el progreso de videos en YouTube sin necesidad de iniciar sesión.
// @author       Alplox
// @match        https://www.youtube.com/*
// @match        https://www.youtu.be/*
// @match        https://www.youtube-nocookie.com/*
// @icon         https://raw.githubusercontent.com/Alplox/StartpagePlox/refs/heads/main/assets/favicon/favicon.ico
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(() => {
    'use strict';

    // ────────────────
    // 📦 Config
    // MARK: 📦 Config
    // ────────────────

    const CONFIG = {
        /** Diferencia mínima (en segundos) para considerar un cambio de posición como válido */
        minSeekDiff: 1.5,

        /** Días de expiración para diferentes tipos de videos */
        expireDays: {
            regular: 30,
            short: 1,
            preview: 10 / (24 * 60), // 10 minutos en días
        },

        /** Tiempo desde el final del video (en segundos) para considerarlo como "finalizado" */
        staticFinishSec: 90,

        /** Prefijo para claves en localStorage */
        storagePrefix: 'YT_PLAYBACK_PLOX_',

        /** Clave para guardar configuraciones del usuario en GM_* */
        userSettingsKey: 'YT_PLAYBACK_PLOX_userSettings',

        /** Valores predeterminados para configuraciones del usuario */
        defaultSettings: {
            showNotifications: true,
            minSecondsBetweenSaves: 10,
            showFloatingButtons: false
        },

        /** Clave para guardar filtros del usuario en GM_* */
        userFiltersKey: 'YT_PLAYBACK_PLOX_userFilters',

        /** Valores predeterminados para filtros del usuario */
        defaultFilters: {
            orderBy: "recent",
            filterBy: "all",
            searchQuery: ""
        }
    };

    // ────────────────
    // 🎨 Styles
    // MARK: 🎨 Styles
    // ────────────────

    function injectStyles() {
        if (document.getElementById('youtube-playback-plox-styles')) return; // evitar duplicados

        const style = document.createElement('style');
        style.id = 'youtube-playback-plox-styles';
        style.textContent = `
:root {
  /* Paleta base */
  --color-bg: #fff;
  --color-text: #222;
  --color-muted: #555;
  --color-light: #888;
  --color-link: #065fd4;
  --color-danger: #dc2626;
  --color-success: #16a34a;
  --color-success-dark: #15803d;
  --color-overlay: rgba(0, 0, 0, 0.4);
  --color-toast: #333;
  --color-primary: #2563eb;
  --color-primary-dark: #1e40af;
  --color-border: #ccc;

  /* Tipografía */
  --font-base: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;

  /* Espaciado */
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 1.5rem;

  /* Sombra */
  --shadow-md: 0 4px 20px rgba(0, 0, 0, 0.2);
  --shadow-modal: 0 4px 16px rgba(0, 0, 0, 0.25);

  /* Z-index */
  --z-overlay: 9999;
  --z-modal: 10000;
}

/* =========================
   Contenedores y Overlays
========================= */

.ypp-overlay,
.ypp-modalOverlay {
  position: fixed;
  top: 0;
  left: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100vw;
  height: 100vh;
  background: var(--color-overlay);
  z-index: var(--z-overlay);
}

.ypp-container {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: var(--color-bg);
  border-radius: 8px;
  box-shadow: var(--shadow-md);
  padding: var(--spacing-lg);
  z-index: var(--z-modal);
  width: 450px;
  max-height: 70vh;
  overflow-y: auto;
  font-family: var(--font-base);
  color: var(--color-text);
}

/* =========================
   Header, Footer, Layout
========================= */

.ypp-header,
.ypp-modalHeader {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: var(--spacing-md);
}

.ypp-footer {
  position: sticky;
  bottom: 0;
  background: var(--color-bg);
  padding: var(--spacing-md);
  margin-top: var(--spacing-lg);
  display: flex;
  justify-content: space-between;
  border-top: 2px solid var(--color-border);
  z-index: 10;
}

.ypp-settingsContent {
  display: flex;
  flex-direction: column;
  gap: var(--spacing-md);
  max-height: 60vh;
  overflow-y: auto;
}

.ypp-btnGroup {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
}

/* =========================
   Tipografía
========================= */

.ypp-emptyMsg {
  text-align: center;
  color: #666;
}

.ypp-playlistTitle {
  margin: var(--spacing-md) 0 var(--spacing-sm);
  color: var(--color-muted);
}

.ypp-titleLink {
  font-weight: 600;
  font-size: 1.4rem;
  color: var(--color-link);
  text-decoration: none;
  display: block;
  margin-bottom: 2px;
}

.ypp-titleLink:hover {
  text-decoration: underline;
}

.ypp-author,
.ypp-views {
  font-size: 1.1rem;
  color: var(--color-muted);
}

.ypp-timestamp,
.ypp-progressInfo {
  font-size: 1.3rem;
  margin-top: 4px;
}

.ypp-timestamp {
  color: var(--color-muted);
}

.ypp-progressInfo {
  color: red;
}

/* =========================
   Video List
========================= */

.ypp-videoWrapper {
  display: flex;
  align-items: center;
  margin-bottom: var(--spacing-md);
  border-bottom: 1px solid var(--color-border);
  padding-bottom: var(--spacing-sm);
}

.ypp-thumb {
  width: 90px;
  height: 50px;
  object-fit: cover;
  border-radius: 4px;
  margin-right: var(--spacing-sm);
  flex-shrink: 0;
}

.ypp-infoDiv {
  flex-grow: 1;
}

/* =========================
   Botones
========================= */

.ypp-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.5em 1em;
  font-weight: 500;
  color: var(--color-bg);
  background-color: var(--color-muted);
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: background-color 0.2s ease;

  &:hover {
    background-color: var(--color-text);
  }
}

.ypp-btn-outlined {
  background: transparent;
  border: 1px solid currentColor;
  color: var(--color-primary);

  &:hover {
    background-color: var(--color-primary);
    color: var(--color-bg);
  }
}

.ypp-btn-delete {
  background-color: transparent;

  &:hover {
    background-color: var(--color-danger);
    color: var(--color-bg);
  }
}

.ypp-save-button {
  background-color: var(--color-success);

  &:hover {
    background-color: var(--color-success-dark);
  }
}

/* =========================
   Toasts
========================= */

.ypp-toast-container {
  position: fixed;
  top: var(--spacing-md);
  right: var(--spacing-md);
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  z-index: var(--z-overlay);
}

.ypp-toast {
  background: var(--color-toast);
  color: white;
  padding: 0.75rem 1rem;
  border-radius: 4px;
  opacity: 0;
  transition: opacity 0.3s ease;
  font-size: 14px;
}

.ypp-toast.persistent {
  background: var(--color-muted);
}

/* =========================
   Modal
========================= */

.ypp-modalBox {
  background: var(--color-bg);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  padding: var(--spacing-lg);
  color: var(--color-text);
  max-width: 400px;
  width: 100%;
  max-height: 80vh;
  overflow-y: auto;
  box-shadow: var(--shadow-modal);
}

.ypp-modalTitle {
  font-weight: 600;
  color: #111;
}

.ypp-modalBody {
  font-size: 1.4rem;
}

/* =========================
   Inputs y Forms
========================= */

.ypp-label {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #333;
}

.ypp-input {
  width: 100%;
  padding: 6px 8px;
  border: 1px solid var(--color-border);
  border-radius: 4px;
  margin-top: 5px;
}

.ypp-input-small {
  width: 60px;
}

/* =========================
   Floating Button
========================= */

.ypp-floatingBtnContainer {
  position: fixed;
  bottom: var(--spacing-md);
  right: var(--spacing-md);
  z-index: var(--z-overlay);
  display: flex;
  gap: 10px;
}
`;
        document.head.appendChild(style);
    }

    // ────────────────
    // 💾 Storage + Settings
    // MARK: 💾 Storage + Settings
    // ────────────────

    const Storage = {
        /**
         * Obtiene un valor del almacenamiento local.
         * @param {string} key - Clave sin prefijo.
         * @returns {any|null} - Valor parseado o null si no existe.
         */
        get(key) {
            try {
                const raw = localStorage.getItem(`${CONFIG.storagePrefix}${key}`);
                return raw ? JSON.parse(raw) : null;
            } catch (error) {
                console.error(`Storage.get: Error al parsear la clave "${key}"`, error);
                return null;
            }
        },

        /**
         * Establece un valor en el almacenamiento local.
         * @param {string} key - Clave sin prefijo.
         * @param {any} value - Valor a almacenar (se serializa).
         */
        set(key, value) {
            try {
                const serialized = JSON.stringify(value);
                localStorage.setItem(`${CONFIG.storagePrefix}${key}`, serialized);
            } catch (error) {
                console.error(`Storage.set: Error al guardar la clave "${key}"`, error);
            }
        },

        /**
         * Elimina una clave del almacenamiento local.
         * @param {string} key - Clave sin prefijo.
         */
        del(key) {
            try {
                localStorage.removeItem(`${CONFIG.storagePrefix}${key}`);
            } catch (error) {
                console.error(`Storage.del: Error al eliminar la clave "${key}"`, error);
            }
        },

        /**
         * Devuelve todas las claves (sin prefijo) almacenadas con el prefijo definido.
         * @returns {string[]} - Lista de claves sin prefijo.
         */
        keys() {
            return Object.keys(localStorage)
                .filter((fullKey) => fullKey.startsWith(CONFIG.storagePrefix))
                .map((fullKey) => fullKey.slice(CONFIG.storagePrefix.length));
        }
    };

    const Settings = {
        /**
         * Obtiene la configuración del usuario desde el almacenamiento.
         * Fusiona con los valores predeterminados.
         * @returns {Promise<Object>} Configuración completa del usuario.
         */
        async get() {
            try {
                const raw = await GM_getValue(CONFIG.userSettingsKey, null);
                const parsed = raw ? JSON.parse(raw) : {};
                return { ...CONFIG.defaultSettings, ...parsed };
            } catch (error) {
                console.error('Error al cargar configuración del usuario:', error);
                return { ...CONFIG.defaultSettings };
            }
        },

        /**
         * Guarda la configuración del usuario en el almacenamiento.
         * @param {Object} settings - Objeto de configuración del usuario.
         * @returns {Promise<void>}
         */
        async set(settings) {
            try {
                const serialized = JSON.stringify(settings);
                await GM_setValue(CONFIG.userSettingsKey, serialized);
            } catch (error) {
                console.error('Error al guardar configuración del usuario:', error);
            }
        }
    };

    // ────────────────
    // 🔧 Utils
    // MARK: 🔧 Utils
    // ────────────────
    /**
     * Formatea un tiempo en segundos a formato "mm:ss" o "hh:mm:ss" si es necesario.
     * @param {number} seconds - Tiempo en segundos.
     * @returns {string} - Tiempo formateado.
     */
    const formatTime = (seconds) => {
        const iso = new Date(seconds * 1000).toISOString();
        const time = iso.slice(11, 19); // hh:mm:ss
        return time.startsWith('00:') ? time.slice(3) : time;
    };

    /**
        * Crea un elemento HTML con clase, texto o HTML interno.
        * 
        * @param {string} tag - Nombre de la etiqueta HTML a crear.
        * @param {Object} options - Opciones para configurar el elemento.
        * @param {string} [options.className] - Clase CSS para el elemento.
        * @param {string} [options.id] - ID a asignar al elemento.
        * @param {string} [options.text] - Texto plano a asignar al elemento.
        * @param {string} [options.html] - HTML crudo a asignar al elemento.
        * @returns {HTMLElement} Elemento creado.
        */
    function createElement(tag, {
        className = '',
        id = '',
        text = '',
        html = '',
        onClickEvent = null,
        atribute = {},
        props = {}
    } = {}) {
        const el = document.createElement(tag);
        if (className) el.className = className;
        if (id) el.id = id;
        if (text) el.textContent = text;
        if (html) el.innerHTML = html;
        if (onClickEvent && typeof onClickEvent === 'function') el.addEventListener('click', onClickEvent);

        // Atributos HTML (como title, type, etc.)
        if (atribute && typeof atribute === 'object') {
            Object.entries(atribute).forEach(([k, v]) => el.setAttribute(k, v));
        }

        // Propiedades DOM reales (como checked, disabled, value...)
        if (props && typeof props === 'object') {
            Object.entries(props).forEach(([k, v]) => {
                if (k in el) el[k] = v;
            });
        }

        return el;
    }

    // Mapa débil para asociar cada toast con su timeout
    const toastTimeouts = new WeakMap();

    /**
     * Muestra un mensaje tipo "toast" en pantalla.
     * - Si es un toast "persistente" (ej: guardado), se reutiliza siempre el mismo.
     * - Si no, se crean toasts independientes en stack.
     * @param {string} message - Texto a mostrar.
     * @param {number} duration - Tiempo en milisegundos que se muestra el mensaje.
     * @param {object} options - Config extra (ej: { persistent: true })
     */
    const showToast = (message, duration = 2500, options = {}) => {
        let container = document.querySelector('.ypp-toast-container');
        if (!container) {
            container = document.createElement('div');
            container.className = 'ypp-toast-container';
            document.body.appendChild(container);

            const updateVisibility = () => {
                const isFullscreen = !!document.fullscreenElement;
                container.style.display = isFullscreen ? 'none' : 'flex';
            };

            document.addEventListener('fullscreenchange', updateVisibility);
            window.addEventListener('yt-navigate-finish', updateVisibility);

            updateVisibility();
        }

        let toast;

        if (options.persistent) {
            // Buscar si ya existe un toast persistente
            toast = container.querySelector('.ypp-toast.persistent');
            if (!toast) {
                toast = document.createElement('div');
                toast.className = 'ypp-toast persistent';
                container.appendChild(toast);
                requestAnimationFrame(() => (toast.style.opacity = '1'));
            }
            // Actualizar texto sin reiniciar animación
            toast.textContent = message;

            // Reiniciar timeout de ocultado
            const prevTimeout = toastTimeouts.get(toast);
            if (prevTimeout) clearTimeout(prevTimeout);

            const timeoutId = setTimeout(() => {
                toast.style.opacity = '0';
                toast.addEventListener('transitionend', () => toast.remove(), { once: true });
                toastTimeouts.delete(toast);
            }, duration);

            toastTimeouts.set(toast, timeoutId);
        } else {
            // Toast normal (se apila)
            toast = document.createElement('div');
            toast.className = 'ypp-toast';
            toast.textContent = message;
            container.appendChild(toast);

            requestAnimationFrame(() => (toast.style.opacity = '1'));

            const timeoutId = setTimeout(() => {
                toast.style.opacity = '0';
                toast.addEventListener('transitionend', () => toast.remove(), { once: true });
                toastTimeouts.delete(toast);
            }, duration);

            toastTimeouts.set(toast, timeoutId);
        }
    };


    /** 
     * * Crea un modal con título, contenido y funcionalidad de cierre.
     * * @param {string} [title] - Título del modal.
     * * @param {string|HTMLElement} [content] - Contenido HTML o string.
     * * @returns {Object} - Objeto con referencias al host, contenido y función de cierre.
     * */
    function createModal(title = '', content = '') {
        const closeModal = () => {
            overlay.remove();
            document.body.style.overflow = ''; // ♻️ Restaura scroll
        };

        const overlay = createElement('div', {
            className: 'ypp-modalOverlay',
            atribute: { 'aria-modal': 'true', role: 'dialog' },
            onClickEvent: (e) => {
                if (e.target === overlay) closeModal(); // Cerrar al hacer clic fuera del modal
            }
        });
        const modal = createElement('div', { className: 'ypp-modalBox' });
        const header = createElement('div', { className: 'ypp-modalHeader' });
        const titleEl = createElement('h3', { className: 'ypp-modalTitle', text: title });

        const closeBtn = createElement('button', {
            className: 'ypp-btn',
            text: '✖',
            atribute: { 'aria-label': 'Cerrar', title: 'Cerrar', type: 'button'},
            onClickEvent: closeModal
        });

        header.appendChild(titleEl);
        header.appendChild(closeBtn);

        const body = createElement('div', { className: 'ypp-modalBody' });

        typeof content === 'string' ? body.innerHTML = content.replace(/\u200B/g, '') : body.appendChild(content);

        modal.appendChild(header);
        modal.appendChild(body);
        overlay.appendChild(modal);

        document.body.appendChild(overlay);
        document.body.style.overflow = 'hidden'; // 🚫 Desactiva scroll del fondo

        return {
            host: overlay,
            content: modal,
            close: closeModal,
        };
    }
    // ────────────────

    // 🔔 Notificaciones
    /* // @grant        GM_notification */
    /* function mostrarNotificacion(titulo, mensaje) {
        GM_notification({
            title: titulo,
            text: mensaje,
            timeout: 5000, // Opcional: tiempo en milisegundos antes de que la notificación desaparezca
            onclick: function () {
                window.focus();  // Al hacer clic, vuelve a poner el foco en la ventana
            }
        });
    }
    mostrarNotificacion("Hola", "Este es un mensaje de prueba."); */

    // Variables para controlar notificaciones de guardado
    let lastNotifyTimestamp = 0;
    let cachedSettings = null;

    async function handleNotification(timestamp) {
        if (!cachedSettings) {
            cachedSettings = await Settings.get();
        }

        if (!cachedSettings.showNotifications) return;

        const now = Date.now();
        if (now - lastNotifyTimestamp < cachedSettings.minSecondsBetweenSaves * 1000) return;

        lastNotifyTimestamp = now;
        showToast(`💾 Progreso guardado: ${formatTime(timestamp)}`, 2500, { persistent: true });
    }

    // ────────────────
    // 🔧 Helpers
    // MARK: 🔧 Helpers
    // ────────────────

    // Extrae información relevante del video actual
    function getVideoInfo(player, vid) {
        const vd = player.getVideoData() || {};
        const title = vd.title || vid;
        const author = vd.author || 'Desconocido';
        const duration = player.getDuration?.() || 0;

        // Thumbnail seguro
        let thumb = `https://i.ytimg.com/vi/${vid}/hqdefault.jpg`;
        if (vd.thumbnail_url && typeof vd.thumbnail_url === 'object' && vd.thumbnail_url.url) {
            thumb = vd.thumbnail_url.url;
        }

        // Views con limpieza simple
        const viewsEl = document.querySelector('.view-count');
        let views = 'N/A';
        if (viewsEl) {
            // Ejemplo: "1,234,567 vistas" o "1.2M views"
            views = viewsEl.textContent.trim();
        }

        const savedAt = Date.now();

        return { title, author, thumb, views, savedAt, duration };
    }

    const updateStatus = (player, videoEl, type, plId) => {
        const vid = player.getVideoData()?.video_id;
        if (!vid) return;

        const currentTime = videoEl.currentTime;
        const duration = videoEl.duration;

        // Validaciones básicas
        if (!duration || isNaN(currentTime) || currentTime < 0.5 || !isFinite(duration)) return;

        const finishThreshold = Math.min(duration * 0.01, CONFIG.staticFinishSec);
        const isFinished = duration - currentTime < finishThreshold;

        const now = Date.now();
        const info = getVideoInfo(player, vid);

        // Helper para guardar o borrar datos de video
        function saveOrDelete(key, data) {
            if (data) Storage.set(key, data);
            else Storage.del(key);
        }

        if (plId) {
            const playlist = Storage.get(plId) || { lastWatchedVideoId: '', videos: {} };

            if (isFinished) {
                delete playlist.videos[vid];
            } else {
                playlist.videos[vid] = { timestamp: currentTime, lastUpdated: now, videoType: 'playlist', ...info };
            }

            playlist.lastWatchedVideoId = vid;

            // Si no hay videos en la playlist, eliminar storage
            saveOrDelete(plId, Object.keys(playlist.videos).length ? playlist : null);
        } else {
            saveOrDelete(vid, isFinished ? null : { timestamp: currentTime, lastUpdated: now, videoType: type, ...info });
        }

        handleNotification(currentTime);
    };

    const resumePlayback = async (player, vid, videoEl, inPlaylist, plId, fromPlId) => {
        // Determinar la clave para obtener datos almacenados (playlist o video individual)
        const key = inPlaylist ? plId : vid;
        const data = Storage.get(key);
        if (!data) return;

        // Obtener el último tiempo guardado del video (según si está en playlist o no)
        let lastTime = inPlaylist ? data.videos?.[vid]?.timestamp : data.timestamp;
        let resumeId = vid;

        // Si está en playlist y hay un video diferente al actual que se debería reanudar
        if (
            inPlaylist &&
            data.lastWatchedVideoId &&
            vid !== data.lastWatchedVideoId &&
            plId !== fromPlId
        ) {
            resumeId = data.lastWatchedVideoId;
            lastTime = data.videos?.[resumeId]?.timestamp;

            // Obtener playlist y reproducir el video correcto si existe
            const playlist = player.getPlaylist?.();
            const idx = playlist?.indexOf(resumeId);
            if (idx !== undefined && idx >= 0) {
                player.playVideoAt(idx);
            }
        } else if (lastTime) {
            // Si no es el caso anterior, aplicar seek para reanudar en el tiempo guardado
            await applySeek(player, videoEl, lastTime);
        }
    };

    // Variables para manejar el listener activo y la última playlist procesada
    let activeCleanup = null;
    let lastPlaylistId = null;

    const processVideo = (container, player, videoEl) => {
        // Limpiar event listener anterior si existe
        if (activeCleanup) activeCleanup();

        // Extraer parámetros relevantes de la URL actual
        const url = new URL(location.href);
        const vid = url.searchParams.get('v') || player.getVideoData()?.video_id;
        const plId = url.searchParams.get('list');
        if (!vid) return;

        // Determinar tipo de video
        const type = url.pathname.startsWith('/shorts/')
            ? 'short'
            : container.id === /* 'inline-player' */ 'video-preview' // ID actualizado para previews
                ? 'preview'
                : 'regular';

        let attempted = false;

        // Manejador para actualizar el estado o reanudar la reproducción
        const handler = () => {
            if (!attempted) {
                attempted = true;
                resumePlayback(player, vid, videoEl, Boolean(plId), plId, lastPlaylistId);
            } else {
                updateStatus(player, videoEl, type, plId);
            }
        };

        videoEl.addEventListener('timeupdate', handler);

        // Guardar función para limpiar el listener en el futuro
        activeCleanup = () => videoEl.removeEventListener('timeupdate', handler);

        // Actualizar última playlist procesada
        lastPlaylistId = plId;
    };

    // ────────────────
    // ⏯ Seek
    // MARK: ⏯ Seek
    // ────────────────

    // Tiempo máximo para esperar al evento 'seeked' (en ms)
    const SEEK_TIMEOUT = 1500; // ms

    // Función para aplicar seek con espera y manejo de errores
    const applySeek = async (player, videoEl, time) => {
        if (!player || !videoEl) return;
        if (typeof time !== 'number' || isNaN(time)) return;

        const current = player.getCurrentTime();
        if (Math.abs(current - time) <= CONFIG.minSeekDiff) return; // no seek necesario

        await new Promise((resolve) => {
            const onSeeked = () => {
                clearTimeout(timeoutId);
                videoEl.removeEventListener('seeked', onSeeked);
                resolve();
            };

            const timeoutId = setTimeout(() => {
                videoEl.removeEventListener('seeked', onSeeked);
                resolve();
            }, SEEK_TIMEOUT);

            videoEl.addEventListener('seeked', onSeeked, { once: true });

            try {
                player.seekTo(time, true);
            } catch (error) {
                clearTimeout(timeoutId);
                videoEl.removeEventListener('seeked', onSeeked);
                resolve();
            }
        });

        showToast(`⏯ Retomado en ${formatTime(time)}`);
    };

    // ────────────────
    // 📂 Video List
    // MARK: 📂 Video List
    // ────────────────

    // Crea selectores de orden
    function createSortSelector(currentValue, onChange) {
        const wrapper = document.createElement('div');

        const label = createElement('label', { className: 'ypp-label', text: 'Ordenar por:', atribute: { for: 'sort-selector' } });

        const select = createElement('select', {
            className: 'ypp-input', id: 'sort-selector', html: `
        <option value="recent" ${currentValue === 'recent' ? 'selected' : ''}>📅 Más recientes</option>
        <option value="oldest" ${currentValue === 'oldest' ? 'selected' : ''}>📆 Más antiguos</option>
        <option value="title" ${currentValue === 'title' ? 'selected' : ''}>🔤 Título (A-Z)</option>`
        });

        select.onchange = () => onChange(select.value);

        label.appendChild(select);
        wrapper.appendChild(label);
        return wrapper;
    }

    function createFilterSelector(currentValue, onChange) {
        const wrapper = document.createElement('div');

        const label = createElement('label', { className: 'ypp-label', text: 'Filtrar por tipo:', atribute: { for: 'filter-selector' } });


        const select = createElement('select', {
            className: 'ypp-input', id: 'filter-selector', html: `
        <option value="all" ${currentValue === 'all' ? 'selected' : ''}>🔎 Todos</option>
        <option value="regular" ${currentValue === 'regular' ? 'selected' : ''}>▶️ Videos</option>
        <option value="short" ${currentValue === 'short' ? 'selected' : ''}>📱 Shorts</option>
        <option value="preview" ${currentValue === 'preview' ? 'selected' : ''}>🔍 Previews</option>
        <option value="playlist" ${currentValue === 'playlist' ? 'selected' : ''}>📁 Playlists</option>`
        });

        select.onchange = () => onChange(select.value);


        label.appendChild(select);
        wrapper.appendChild(label);
        return wrapper;
    }

    function createSearchInput(currentValue, onChange) {
        const wrapper = createElement('div');
        const input = createElement('input', { className: 'ypp-input', id: 'search-input' });
        input.type = 'text';
        input.placeholder = '🔍 Buscar por título o autor...';
        input.value = currentValue;


        input.addEventListener('input', () => {
            onChange(input.value.trim());
        });

        wrapper.appendChild(input);
        return wrapper;
    }

    // Guarda los filtros del usuario en almacenamiento
    async function saveFilters(newValues) {
        const currentRaw = await GM_getValue(CONFIG.userFiltersKey, '{}');
        const current = JSON.parse(currentRaw);
        const updated = { ...current, ...newValues };
        await GM_setValue(CONFIG.userFiltersKey, JSON.stringify(updated));
    }

    async function getSavedFilters() {
        const raw = await GM_getValue(CONFIG.userFiltersKey, '{}');
        try {
            const saved = raw ? JSON.parse(raw) : {};
            const merged = { ...CONFIG.defaultFilters, ...saved };
            return merged;
        } catch (e) {
            console.warn('Error parsing saved filters:', e);
            return { ...CONFIG.defaultFilters };
        }
    }

    // Crear entrada de video en la lista
    let videosOverlay = null;
    let videosContainer = null;
    let listContainer = null;

    let currentOrderBy, currentFilterBy, currentSearchQuery;

    function updateVideoList() {
        const keys = Storage.keys().filter(k =>
            !k.startsWith('userSettings')
        );

        // Limpiar lista vieja
        listContainer.innerHTML = '';

        if (keys.length === 0) {
            const p = createElement('p', { className: 'ypp-emptyMsg', text: 'No hay videos guardados.' });
            listContainer.appendChild(p);
            return;
        }

        // Filtrado, búsqueda y orden según los filtros actuales
        let keyDataPairs = keys
            .map(k => [k, Storage.get(k)])
            .filter(([, d]) => !!d)
            .filter(([, d]) => {
                if (currentFilterBy === 'playlist') return !!d.videos;
                if (currentFilterBy === 'all') return true;
                return d.videoType === currentFilterBy;
            })
            .filter(([, d]) => {
                if (!currentSearchQuery) return true;
                const query = currentSearchQuery.toLowerCase();
                if (d.videos) {
                    return Object.values(d.videos).some(v =>
                        (v.title || '').toLowerCase().includes(query) ||
                        (v.author || '').toLowerCase().includes(query)
                    );
                } else {
                    return (d.title || '').toLowerCase().includes(query) ||
                        (d.author || '').toLowerCase().includes(query);
                }
            });

        const getSortValue = (key, data) => {
            if (currentOrderBy === 'title') return (data.title || key).toLowerCase();
            if (currentOrderBy === 'oldest') return data.savedAt || 0;
            return -(data.savedAt || 0); // recent
        };

        keyDataPairs.sort((a, b) => {
            const valA = getSortValue(a[0], a[1]);
            const valB = getSortValue(b[0], b[1]);
            if (typeof valA === 'string') return valA.localeCompare(valB);
            return valA - valB;
        });

        for (const [key, data] of keyDataPairs) {
            if (data.videos) {
                const h3 = createElement('h3', { className: 'ypp-playlistTitle', text: `📁 Playlist: ${key}` });
                listContainer.appendChild(h3);

                const videosSorted = Object.entries(data.videos)
                    .filter(([, v]) => {
                        if (currentFilterBy !== 'all' && currentFilterBy !== 'playlist' && v.videoType !== currentFilterBy) return false;
                        if (!currentSearchQuery) return true;
                        const q = currentSearchQuery.toLowerCase();
                        return (v.title || '').toLowerCase().includes(q) || (v.author || '').toLowerCase().includes(q);
                    })
                    .sort(([, a], [, b]) => {
                        const valA = getSortValue('', a);
                        const valB = getSortValue('', b);
                        return typeof valA === 'string' ? valA.localeCompare(valB) : valA - valB;
                    });

                for (const [videoId, info] of videosSorted) {
                    listContainer.appendChild(createVideoEntry(videoId, info, key));
                }
            } else {
                listContainer.appendChild(createVideoEntry(key, data, null));
            }
        }
    }

    function closeModalVideos() {
        if (videosOverlay) {
            videosOverlay.remove();
            videosOverlay = null;
            videosContainer.remove();
            videosContainer = null;
            listContainer.remove();
            listContainer = null;
            document.body.style.overflow = ''; // ♻️ Restaura scroll
        }
    }

    const createFloatingButtons = async () => {
        const settings = await Settings.get();
        if (!settings.showFloatingButtons) return;

        const wrapper = createElement('div', { className: 'ypp-floatingBtnContainer' });
        const btnConfig = createElement('div', { className: 'ypp-btn', text: '⚙️ YT Playback Plox', onClickEvent: showSettingsUI });

        wrapper.appendChild(btnConfig);
        document.body.appendChild(wrapper);

        // Ocultar en modo teatro o fullscreen
        const updateVisibility = () => {
            const isFullscreen = !!document.fullscreenElement;
            wrapper.style.display = isFullscreen ? 'none' : 'flex';
        };

        document.addEventListener('fullscreenchange', updateVisibility);
        window.addEventListener('yt-navigate-finish', updateVisibility);

        updateVisibility();
    };

    function syncUIWithCurrentFilters() {
        // Actualizar los selectores e input en el modal si existen
        const sortSelect = document.getElementById('sort-selector');
        const filterSelect = document.getElementById('filter-selector');
        const searchInput = document.getElementById('search-input');

        if (sortSelect) sortSelect.value = currentOrderBy;
        if (filterSelect) filterSelect.value = currentFilterBy;
        if (searchInput) searchInput.value = currentSearchQuery;
    }


    // ────────────────
    // 👀 Show saved videos
    // MARK: 👀 Show saved
    // ────────────────

    async function showSavedVideosList() {
        if (!videosOverlay) {
            const saved = await getSavedFilters();

            currentOrderBy = saved.orderBy ?? CONFIG.defaultFilters.orderBy;
            currentFilterBy = saved.filterBy ?? CONFIG.defaultFilters.filterBy;
            currentSearchQuery = saved.searchQuery ?? CONFIG.defaultFilters.searchQuery;

            videosOverlay = createElement('div', { className: 'ypp-overlay' });

            videosContainer = createElement('div', { className: 'ypp-container' });
            if (!videosContainer) {
                console.error("Failed to create videosContainer");
                return;
            }

            listContainer = createElement('div', { id: 'video-list-container' });

            const header = createElement('div', { className: 'ypp-header' });
            const title = createElement('h2', { text: 'YouTube Playback Plox' });

            const closeBtn = createElement('button', {
                className: 'ypp-btn',
                text: '✖',
                atribute: { 'aria-label': 'Cerrar' },
                onClickEvent: closeModalVideos
            });

            header.appendChild(title);
            header.appendChild(closeBtn);

            videosContainer.appendChild(header);

            // Añadir selectores e input con manejadores para actualizar filtros y lista
            videosContainer.appendChild(createSortSelector(currentOrderBy, async (selected) => {
                currentOrderBy = selected;
                await saveFilters({ orderBy: selected });
                updateVideoList();
            }));

            videosContainer.appendChild(createFilterSelector(currentFilterBy, async (selected) => {
                currentFilterBy = selected;
                await saveFilters({ filterBy: selected });
                updateVideoList();
            }));

            videosContainer.appendChild(createSearchInput(currentSearchQuery, async (query) => {
                currentSearchQuery = query;
                await saveFilters({ searchQuery: query });
                updateVideoList();
            }));


            // Footer
            const footer = createElement('div', { className: 'ypp-footer' });

            const exportDataToFile = () => {
                const exportData = {};

                const keys = Storage.keys().filter(k =>
                    !k.startsWith('userSettings')
                );

                keys.forEach(k => {
                    const data = Storage.get(k);
                    if (data) exportData[k] = data;
                });

                const blob = new Blob(
                    [JSON.stringify(exportData, null, 2)],
                    { type: 'application/json' }
                );

                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = 'youtube-playback-plox-backup.json';
                document.body.appendChild(a);
                a.click();
                a.remove();
                URL.revokeObjectURL(url);

                showToast('📤 Datos exportados');
            };

            const importDataFromFile = () => {
                // Reutilizar el input si ya existe
                let inputFile = document.getElementById('ypp-import-file');
                if (!inputFile) {
                    inputFile = createElement('input', {
                        id: 'ypp-import-file',
                        atribute: {
                            type: 'file'
                        },
                        props: {
                            accept: 'application/json'
                        }
                    });

                    inputFile.addEventListener('change', async (e) => {
                        const file = e.target.files[0];
                        if (!file) return;

                        try {
                            const text = await file.text();
                            const importedData = JSON.parse(text);

                            if (typeof importedData !== 'object' || importedData === null) {
                                throw new Error('Formato no válido');
                            }

                            let count = 0;
                            for (const [key, value] of Object.entries(importedData)) {
                                Storage.set(key, value);
                                count++;
                            }

                            showToast(`📥 Importados ${count} elementos`);
                            closeModalVideos();
                            showSavedVideosList();
                        } catch (err) {
                            console.error('Error al importar datos:', err);
                            showToast('⚠️ Error al importar. Asegúrate de que el archivo sea válido.');
                        } finally {
                            inputFile.value = ''; // Permitir seleccionar el mismo archivo de nuevo
                        }
                    });
                    document.body.appendChild(inputFile);
                }
                inputFile.click();
            };

            const btnExport = createElement('button', { className: 'ypp-btn', text: '📤 Exportar', onClickEvent: exportDataToFile });
            const btnImport = createElement('button', { className: 'ypp-btn', text: '📥 Importar', onClickEvent: importDataFromFile });

            footer.appendChild(btnExport);
            footer.appendChild(btnImport);

            videosContainer.appendChild(listContainer);
            videosContainer.appendChild(footer);

            videosOverlay.addEventListener('click', closeModalVideos);

            document.body.appendChild(videosOverlay);
            document.body.appendChild(videosContainer);
        } else {
            syncUIWithCurrentFilters();
        }
        updateVideoList();
    }

    // ────────────────
    // 🔏 Create video entry
    // MARK: 🔏 Create entry
    // ────────────────

    function createVideoEntry(videoId, info, playlistKey = null) {
        const videoTime = formatTime(info.timestamp || 0);
        const duration = info.duration || 0;
        const watched = info.timestamp || 0;
        const remaining = Math.max(duration - watched, 0);
        const percent = duration ? Math.min(100, Math.round((watched / duration) * 100)) : null;
        const wrapper = createElement('div', { className: 'ypp-videoWrapper' });

        // Miniatura
        const thumb = createElement('img', {
            className: 'ypp-thumb',
            atribute: {
                title: info.title || videoId,
                loading: 'lazy',
                alt: info.title || 'Miniatura',
                src: info.thumb || `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
            },
            props: { draggable: false }
        });

        wrapper.appendChild(thumb);

        // Info
        const infoDiv = createElement('div', { className: 'ypp-infoDiv' });

        const titleLink = createElement('a', {
            className: 'ypp-titleLink', text: info.title || videoId,
            atribute: {
                title: info.title || videoId,
                href: `https://www.youtube.com/watch?v=${videoId}${playlistKey ? '&list=' + playlistKey : ''}`
            },
            props: { target: '_blank', rel: 'noopener noreferrer' }
        });


        const author = createElement('div', { className: 'ypp-author', text: info.author || 'Desconocido' });
        const views = createElement('div', { className: 'ypp-views', text: info.views || 'N/A' });
        const timestamp = createElement('div', { className: 'ypp-timestamp', text: `Progreso: ${videoTime}` });

        infoDiv.appendChild(titleLink);
        infoDiv.appendChild(author);
        infoDiv.appendChild(views);
        infoDiv.appendChild(timestamp);

        if (percent !== null) {
            const progressInfo = createElement('div', { className: 'ypp-progressInfo', text: `📊 ${percent}% visto (${formatTime(remaining)} restantes)` });
            infoDiv.appendChild(progressInfo);
        }

        wrapper.appendChild(infoDiv);

        // Botón de borrar
        const btnDelete = createElement('button', {
            className: 'ypp-btn ypp-btn-delete',
            atribute: { title: 'Eliminar entrada' },
            text: '🗑️',
            onClickEvent: () => {
                if (playlistKey) {
                    const playlist = Storage.get(playlistKey);
                    if (playlist?.videos?.[videoId]) {
                        delete playlist.videos[videoId];
                        Object.keys(playlist.videos).length
                            ? Storage.set(playlistKey, playlist)
                            : Storage.del(playlistKey);
                    }
                } else {
                    Storage.del(videoId);
                }

                showToast('🗑️ Entrada eliminada');
                showSavedVideosList(); // recarga
            }
        });

        wrapper.appendChild(btnDelete);
        return wrapper;
    }

    // ────────────────
    // showSettingsUI
    //  MARK: showSettingsUI
    // ────────────────

    async function showSettingsUI() {
        // Prevenir múltiples instancias del modal
        if (document.querySelector('.settings-modal')) {
            return;
        }

        closeModalVideos(); // Cerrar lista de videos si está abierta

        const settings = await Settings.get();
        const content = createElement('div', { className: 'ypp-settingsContent' });

        // Grupo: Mostrar notificaciones de guardado
        const notifGroup = createElement('div');
        const notifLabel = createElement('label', {
            className: 'ypp-label', text: 'Mostrar notificaciones de guardado', atribute: { for: 'toggleNotif', },
        });

        const toggleNotif = createElement('input', {
            id: 'toggleNotif',
            atribute: { title: 'Mostrar notificaciones de guardado', for: 'toggleNotif', type: 'checkbox' },
            props: { checked: settings.showNotifications }
        });

        notifLabel.appendChild(toggleNotif);
        notifGroup.appendChild(notifLabel);
        content.appendChild(notifGroup);

        // Grupo: Intervalo entre guardados
        const intervalGroup = document.createElement('div');

        const intervalLabel = createElement('label', {
            className: 'ypp-label',
            text: 'Intervalo segundos mínimos entre guardados: ',
            atribute: { for: 'interval' }
        });

        const intervalInput = createElement('input', {
            className: 'ypp-input ypp-input-small',
            id: 'interval',
            atribute: { title: 'Segundos', min: '1', type: 'number' },
            props: { value: settings.minSecondsBetweenSaves }
        });

        intervalLabel.appendChild(intervalInput);
        intervalGroup.appendChild(intervalLabel);
        content.appendChild(intervalGroup);

        // Grupo: Mostrar boton flotante
        const buttonsGroup = document.createElement('div');

        const buttonsLabel = createElement('label', {
            className: 'ypp-label',
            atribute: {
                title: 'Mostrar boton flotante', for: 'toggleButtons'
            },
            text: ' Mostrar boton flotante'
        });

        const toggleButtons = createElement('input', {
            id: 'toggleButtons',
            atribute: {
                title: 'Mostrar boton flotante',
                type: 'checkbox'
            },
            props: {
                checked: settings.showFloatingButtons
            }
        });

        buttonsLabel.appendChild(toggleButtons);
        buttonsGroup.appendChild(buttonsLabel);
        content.appendChild(buttonsGroup);


        const buttonGroup = createElement('div', { className: 'ypp-btnGroup' });

        const saveBtn = createElement('button', {
            className: 'ypp-btn ypp-save-button',
            id: 'saveBtn',
            text: 'Guardar',
            onClickEvent: async () => {
                const newSettings = {
                    showNotifications: toggleNotif.checked,
                    minSecondsBetweenSaves: Math.max(1, parseInt(intervalInput.value, 10)),
                    showFloatingButtons: toggleButtons.checked,
                };
                await Settings.set(newSettings);
                showToast('✅ Configuración guardada');
                location.reload();
            }
        });

        // Botón Ver videos guardados
        const viewBtn = createElement('button', {
            className: 'ypp-btn ypp-btn-outlined',
            id: 'viewSavedBtn',
            text: '📼 Ver videos guardados',
            onClickEvent: () => {
                host.remove();
                showSavedVideosList();
            }
        });

        buttonGroup.appendChild(viewBtn);
        buttonGroup.appendChild(saveBtn);
        content.appendChild(buttonGroup);

        const { host } = createModal('⚙️ Configuración', content);
        host.classList.add('settings-modal');
    }

    // ────────────────
    // GM_registerMenuCommand
    //  MARK: GM_registerMenuCommand
    // ────────────────

    GM_registerMenuCommand('⚙️ Configuración', showSettingsUI);
    GM_registerMenuCommand('📋 Ver videos guardados', showSavedVideosList);

    // ────────────────
    // Init
    //  MARK: Init
    // ────────────────

    const init = () => {
        injectStyles();
        const observePlayer = () => {
            const selectors = ['#movie_player', '#shorts-player'];

            const containers = selectors
                .map(selector => document.querySelector(selector))
                .filter(Boolean);

            for (const container of containers) {
                const videoEl = container.querySelector('video');
                // En algunos casos, player puede estar en container.player_ o ser el container mismo
                const player = container.player_ || container;
                if (player && videoEl) {
                    processVideo(container, player, videoEl);
                }
            }
        };

        observePlayer();
        createFloatingButtons();
        // Escuchar evento personalizado que YouTube dispara al navegar internamente
        window.addEventListener('yt-navigate-finish', observePlayer);
    };

    init();

    // ────────────────
    // Código experimental para guardar avances desde previews de miniaturas homepage
    //  MARK: Código experimental
    // ────────────────

    /* const init = () => {
      injectStyles();
    
      // Detecta players principales
      const observePlayer = () => {
        const selectors = ['#movie_player', '#shorts-player', '#inline-player'];
        selectors.forEach(sel => {
          const container = document.querySelector(sel);
          if (!container) return;
          const videoEl = container.querySelector('video');
          const player = container.player_ || container;
          if (player && videoEl) {
            processVideo(container, player, videoEl);
          }
        });
      };
    
      // Detecta previews en miniaturas
      const hookThumbnails = () => {
        document.querySelectorAll('ytd-thumbnail').forEach(thumbnail => {
          if (thumbnail.dataset.hooked) return;
          thumbnail.dataset.hooked = 'true';
    
          thumbnail.addEventListener('mouseenter', () => {
            // Buscar video en DOM normal
            let videoEl = thumbnail.querySelector('video');
            // Buscar en shadowRoot si existe
            if (!videoEl && thumbnail.shadowRoot) {
              videoEl = thumbnail.shadowRoot.querySelector('video');
            }
            if (videoEl) {
              console.log('Preview detectada:', videoEl);
              processVideo(thumbnail, thumbnail, videoEl);
            }
          });
        });
      };
    
      // Observer global para detectar nuevos thumbnails o videos
      const observer = new MutationObserver(() => {
        hookThumbnails();
        document.querySelectorAll('ytd-thumbnail video').forEach(videoEl => {
          if (!videoEl.dataset.processed) {
            videoEl.dataset.processed = 'true';
            console.log('Preview detectada (observer):', videoEl);
            processVideo(videoEl.closest('ytd-thumbnail') || document.body, videoEl, videoEl);
          }
        });
      });
      observer.observe(document.body, { childList: true, subtree: true });
    
      observePlayer();
      hookThumbnails();
      createFloatingButtons();
    
      window.addEventListener('yt-navigate-finish', () => {
        observePlayer();
        hookThumbnails();
      });
    };
    
    init(); */
})();