// ==UserScript== // @name YouTube Playback Plox // @name:en YouTube Playback Plox // @name:es YouTube Reproducción Plox // @name:fr YouTube Lecture Plox // @name:de YouTube Wiedergabe Plox // @name:it YouTube Riproduzione Plox // @name:pt-BR YouTube Reprodução Plox // @name:nl YouTube Afspelen Plox // @name:pl YouTube Odtwarzanie Plox // @name:sv YouTube Uppspelning Plox // @name:da YouTube Afspilning Plox // @name:no YouTube Avspilling Plox // @name:fi YouTube Toisto Plox // @name:cs YouTube Přehrávání Plox // @name:sk YouTube Prehrávanie Plox // @name:hu YouTube Lejátszás Plox // @name:ro YouTube Redare Plox // @name:be YouTube Воспроизведение Plox // @name:bg YouTube Възпроизвеждане Plox // @name:el YouTube Αναπαραγωγή Plox // @name:sr YouTube Репродукција Plox // @name:hr YouTube Reprodukcija Plox // @name:sl YouTube Predvajanje Plox // @name:lt YouTube Grotuvas Plox // @name:lv YouTube Atskaņošana Plox // @name:uk YouTube Відтворення Plox // @name:ru YouTube Воспроизведение Plox // @name:tr YouTube Oynatma Plox // @name:ar يوتيوب بلايباك Plox // @name:fa پخش یوتیوب Plox // @name:he YouTube השמעה Plox // @name:hi YouTube प्लेबैक Plox // @name:bn YouTube প্লেব্যাক Plox // @name:te YouTube ప్లేబ్యాక్ Plox // @name:ta YouTube பிளேபாக் Plox // @name:mr YouTube प्लेबॅक Plox // @name:zh-CN YouTube 播放 Plox // @name:zh-TW YouTube 播放 Plox // @name:zh-HK YouTube 播放 Plox // @name:ja YouTube 再生 Plox // @name:ko YouTube 재생 Plox // @name:th YouTube เล่นต่อ Plox // @name:vi YouTube Phát lại Plox // @name:id YouTube Pemutaran Plox // @name:ms YouTube Main Semula Plox // @name:tl YouTube Playback Plox // @name:my YouTube ဖလေ့ဘက် Plox // @name:sw YouTube Uchezesha Plox // @name:am የYouTube ተጫዋች Plox // @name:ha YouTube Playback Plox // @name:ur YouTube پلے بیک Plox // @name:ca YouTube Reproducció Plox // @name:zu YouTube Playback Plox // @name:yue YouTube 播放 Plox // @name:es-419 YouTube Reproducción Plox // @description Guarda y retoma automáticamente el progreso de vídeos en YouTube sin necesidad de iniciar sesión. // @description:en Automatically saves and resumes video playback progress on YouTube without needing to log in. // @description:es Guarda y retoma automáticamente el progreso de vídeos en YouTube sin necesidad de iniciar sesión. // @description:fr Enregistre et reprend automatiquement la progression de la lecture des vidéos sur YouTube sans avoir besoin de se connecter. // @description:de Speichert und setzt den Fortschritt von YouTube-Videos automatisch fort, ohne dass eine Anmeldung erforderlich ist. // @description:it Salva e riprende automaticamente la riproduzione dei video su YouTube senza bisogno di accedere. // @description:pt-BR Salva e retoma automaticamente o progresso da reprodução de vídeos no YouTube sem precisar fazer login. // @description:nl Slaat automatisch de voortgang van video's op YouTube op en hervat deze zonder in te loggen. // @description:pl Automatycznie zapisuje i wznawia postęp odtwarzania wideo na YouTube bez logowania. // @description:sv Sparar och återupptar automatiskt videoframsteg på YouTube utan att behöva logga in. // @description:da Gemmer og genoptager automatisk videoafspilning på YouTube uden at logge ind. // @description:no Lagrer og gjenopptar automatisk videofremdrift på YouTube uten å logge inn. // @description:fi Tallentaa ja jatkaa automaattisesti YouTube-videoiden toistopistettä ilman kirjautumista. // @description:cs Automaticky ukládá a obnovuje postup přehrávání videí na YouTube bez nutnosti přihlášení. // @description:sk Automaticky ukladá a obnovuje priebeh prehrávania videí na YouTube bez potreby prihlásenia. // @description:hu Automatikusan menti és folytatja a YouTube-videók lejátszási előrehaladását bejelentkezés nélkül. // @description:ro Salvează și reia automat progresul redării videoclipurilor pe YouTube fără a fi nevoie să te conectezi. // @description:be Автоматично зберігає та відновлює прогрес відтворення відео на YouTube без входу в акаунт. // @description:bg Автоматично записва и възобновява прогреса на видеото в YouTube без нужда от вход. // @description:el Αποθηκεύει και συνεχίζει αυτόματα την πρόοδο αναπαραγωγής βίντεο στο YouTube χωρίς να χρειάζεται σύνδεση. // @description:sr Аутоматски чува и наставља напредак репродукције видео записа на YouTube-у без пријављивања. // @description:hr Automatski sprema i nastavlja napredak reprodukcije videozapisa na YouTubeu bez prijave. // @description:sl Samodejno shrani in nadaljuje napredek predvajanja videoposnetkov na YouTubu brez prijave. // @description:lt Automatiškai išsaugo ir atnaujina YouTube vaizdo įrašų atkūrimo pažangą be prisijungimo. // @description:lv Automātiski saglabā un atsāk video atskaņošanas progresu YouTube bez pieteikšanās. // @description:uk Автоматично зберігає та відновлює прогрес відтворення відео на YouTube без входу в акаунт. // @description:ru Автоматически сохраняет и возобновляет прогресс воспроизведения видео на YouTube без входа в аккаунт. // @description:tr YouTube'daki video oynatma ilerlemesini otomatik olarak kaydeder ve devam ettirir, giriş yapmaya gerek yok. // @description:ar يقوم بحفظ واستئناف تقدم تشغيل الفيديوهات على يوتيوب تلقائيًا دون الحاجة لتسجيل الدخول. // @description:fa پیشرفت پخش ویدیوها در یوتیوب را به صورت خودکار ذخیره و ادامه می‌دهد بدون نیاز به ورود. // @description:he שומר ומחדש אוטומטית את התקדמות הניגון של סרטונים ביוטיוב ללא צורך בהתחברות. // @description:hi YouTube पर वीडियो प्लेबैक की प्रगति को स्वचालित रूप से सहेजें और पुनः प्रारंभ करें, लॉगिन की आवश्यकता नहीं। // @description:bn YouTube ভিডিও প্লেব্যাকের অগ্রগতি স্বয়ংক্রিয়ভাবে সংরক্ষণ এবং পুনরায় শুরু করুন, লগইনের প্রয়োজন নেই। // @description:te YouTube వీడియో ప్లేబ్యాక్ పురోగతిని ఆటోమేటిక్‌గా సేవ్ చేసి, తిరిగి ప్రారంభిస్తుంది, లాగిన్ అవసరం లేదు. // @description:ta YouTube வீடியோக்களின் பிளேபாக் முன்னேற்றத்தை தானாகச் சேமித்து மீண்டும் தொடங்கும், உள்நுழைவு தேவையில்லை. // @description:mr YouTube व्हिडिओ प्लेबॅक प्रगती आपोआप जतन करते आणि पुन्हा सुरू करते, लॉगिन आवश्यक नाही. // @description:zh-CN 自动保存并恢复 YouTube 视频的播放进度,无需登录。 // @description:zh-TW 自動儲存及繼續 YouTube 影片播放進度,無需登入。 // @description:zh-HK 自動儲存及繼續 YouTube 影片播放進度,無需登入。 // @description:ja YouTube の動画再生の進行状況を自動で保存・再開します。ログインは不要です。 // @description:ko YouTube 동영상 재생 진행 상황을 자동으로 저장하고 이어서 재생합니다. 로그인 불필요. // @description:th บันทึกและเล่นต่อความคืบหน้าของวิดีโอบน YouTube โดยอัตโนมัติ โดยไม่ต้องเข้าสู่ระบบ. // @description:vi Tự động lưu và tiếp tục tiến trình phát video trên YouTube mà không cần đăng nhập. // @description:id Menyimpan dan melanjutkan kemajuan pemutaran video di YouTube secara otomatis tanpa perlu login. // @description:ms Menyimpan dan menyambung semula kemajuan main balik video di YouTube secara automatik tanpa perlu log masuk. // @description:tl Awtomatikong ini-save at ipinagpapatuloy ang progreso ng video playback sa YouTube nang hindi nagla-log in. // @description:my YouTube ဗီဒီယိုဖလေ့ဘက် တိုးတက်မှုကို အလိုအလျောက် သိမ်းဆည်းပြီး ထပ်မံစတင်နိုင်သည်။ ဝင်ရောက်ရန် မလိုအပ်ပါ။ // @description:sw Hifadhi na endelea kwa kiotomatiki maendeleo ya uchezaji wa video kwenye YouTube bila kuingia. // @description:am በYouTube ላይ የቪዲዮ መጫወቻ እድገትን በራሱ ያስቀምጣል እና ያቀጥላል በመግባት ያስፈልጋል። // @description:ha Ajiye kuma ci gaba da ci gaban kallon bidiyo a YouTube ta atomatik ba tare da shiga ba. // @description:ur YouTube پر ویڈیوز کی پلے بیک کی پیش رفت کو خودکار طریقے سے محفوظ اور دوبارہ شروع کریں، لاگ ان کی ضرورت نہیں۔ // @description:ca Desa i reprèn automàticament el progrés de reproducció de vídeos a YouTube sense necessitat d'iniciar sessió. // @description:zu Igcina futhi uqhubeke ngokuzenzakalelayo nokuqhubeka kwevidiyo ku-YouTube ngaphandle kokungena. // @description:yue 自動儲存及繼續 YouTube 影片播放進度,無需登入。 // @description:es-419 Guarda y reanuda automáticamente el progreso de reproducción de videos en YouTube sin necesidad de iniciar sesión. // @homepage https://github.com/Alplox/Youtube-Playback-Plox // @supportURL https://github.com/Alplox/Youtube-Playback-Plox/issues // @version 0.0.8 // @author Alplox // @match https://www.youtube.com/* // @exclude https://www.youtube.com/live_chat* // @icon https://raw.githubusercontent.com/Alplox/StartpagePlox/refs/heads/main/assets/favicon/favicon.ico // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @run-at document-end // @namespace youtube-playback-plox // @license MIT // @downloadURL https://raw.githubusercontent.com/Alplox/Youtube-Playback-Plox/refs/heads/main/youtube-playback-plox.user.js // @updateURL https://raw.githubusercontent.com/Alplox/Youtube-Playback-Plox/refs/heads/main/youtube-playback-plox.meta.js // @require https://update.greasyfork.org/scripts/549881/1708128/YouTube%20Helper%20API.js // ==/UserScript== // ------------------------------------------ // MARK: 🔍 SISTEMA DE LOGGING // ------------------------------------------ (function () { 'use strict'; // Sistema de niveles: silent(0), error(1), warn(2), info(3), debug(4) const LEVELS = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 }; let currentLevel = LEVELS.silent; // Cambiar a 'debug' para ver todo, o 'warn'/'error' para menos const styleFor = (kind) => { switch (kind) { case 'info': return 'color: #4FC1FF;'; case 'debug': return 'color: #6a9955;'; case 'warn': return 'color: #ce9178; font-weight: bold;'; case 'error': return 'color: #f44747; font-weight: bold;'; default: return ''; } }; window.MyScriptLogger = { // Debug detallado log: (context, ...args) => { if (currentLevel >= LEVELS.debug) { console.log(`%c[${context}]`, styleFor('debug'), ...args); } }, debug: (context, ...args) => { if (currentLevel >= LEVELS.debug) { console.log(`%c[${context}]`, styleFor('debug'), ...args); } }, // Informativo de etapas/éxitos info: (context, ...args) => { if (currentLevel >= LEVELS.info) { console.info(`%c[${context}]`, styleFor('info'), ...args); } }, warn: (context, ...args) => { if (currentLevel >= LEVELS.warn) { console.warn(`%c[${context}]`, styleFor('warn'), ...args); } }, error: (context, ...args) => { // Los errores siempre se muestran console.error(`%c[${context}]`, styleFor('error'), ...args); } }; })(); // Atajo para no tener que escribir window.MyScriptLogger cada vez const { log, info, warn, error: conError } = window.MyScriptLogger; // --- INICIO CARGA LÓGICA PRINCIPAL DEL USERSCRIPT --- (() => { 'use strict'; /** * Polyfill ligero para CustomEvent en navegadores antiguos. * Crea window.CustomEvent si no existe o no es una función nativa. * @returns {void} */ (function polyfillCustomEvent() { try { if (typeof window.CustomEvent === 'function') return; } catch (_) { /* noop */ } try { function CustomEventPolyfill(event, params) { params = params || { bubbles: false, cancelable: false, detail: null }; const evt = document.createEvent('CustomEvent'); evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); return evt; } CustomEventPolyfill.prototype = (window.Event || function () { }).prototype; window.CustomEvent = CustomEventPolyfill; } catch (_) { /* noop */ } })(); // ------------------------------------------ // MARK: 🌐 Carga de Traducciones // ------------------------------------------ // URL del archivo de traducciones const TRANSLATIONS_URL = 'https://raw.githubusercontent.com/Alplox/Youtube-Playback-Plox/refs/heads/main/translations.json'; const TRANSLATIONS_URL_BACKUP = 'https://cdn.jsdelivr.net/gh/Alplox/Youtube-Playback-Plox@refs/heads/main/translations.json'; const TRANSLATIONS_EXPECTED_VERSION = '0.0.8'; // Variables globales para las traducciones let TRANSLATIONS = {}; let LANGUAGE_FLAGS = {}; // Traducciones básicas de fallback en caso de error const FALLBACK_FLAGS = { "en-US": { "emoji": "🇺🇸", "code": "en-US", "name": "English (US)" }, "es-ES": { "emoji": "🇪🇸", "code": "es-ES", "name": "Español" }, "fr": { "emoji": "🇫🇷", "code": "fr", "name": "Français" } }; const FALLBACK_TRANSLATIONS = { "en-US": { "settings": "Settings", "savedVideos": "View saved videos", "close": "Close", "save": "Save", "cancel": "Cancel", "delete": "Delete", "undo": "Undo", "enableSavingFor": "Enable saving for", "regularVideos": "Regular videos", "shorts": "Shorts", "liveStreams": "Live streams", "live": "Live", "showNotifications": "Show save notifications", "minSecondsBetweenSaves": "Minimum seconds between saves", "showFloatingButton": "Show floating button", "language": "Language", "alertStyle": "Alert style in playback bar", "alertIconText": "Icon + Text", "alertIconOnly": "Icon Only", "alertTextOnly": "Text Only", "alertHidden": "Hidden", "noSavedVideos": "No saved videos.", "sortBy": "Sort by", "mostRecent": "Most recent", "oldest": "Oldest", "titleAZ": "Title (A-Z)", "filterByType": "Filter by type", "all": "All", "videos": "Videos", "playlist": "Playlist", "searchByTitleOrAuthor": "Search by title or author...", "export": "Export", "import": "Import", "progressSaved": "Progress saved", "storageFull": "Storage full - Unable to save progress", "dataExported": "Data exported", "itemsImported": "Imported {count} items", "importError": "Error importing. Make sure the file is valid.", "exportError": "Error exporting data", "invalidFormat": "Invalid format", "invalidJson": "Invalid JSON", "invalidDatabase": "Invalid database", "noValidVideos": "No valid videos found to import", "allDataCleared": "All data cleared", "noDataToRestore": "No data to restore", "allDataRestored": "All data restored", "clearAllDataConfirm": "Are you sure you want to delete all data?", "omitedVideos": "Omitted videos", "fileTooLarge": "File is too large (max {size})", "importingFromFreeTube": "Importing from FreeTube...", "importingFromFreeTubeAsSQLite": "Importing from FreeTube as SQLite...", "videosImported": "videos imported", "noVideosImported": "no videos could be imported", "errors": "errors", "noVideosFoundInFreeTubeDB": "No videos found in FreeTube database", "videosImportedFromFreeTubeDB": "videos imported from FreeTube database", "noVideosImportedFromFreeTubeDB": "no videos could be imported from FreeTube database", "fileEmpty": "File is empty", "processingFile": "Processing file...", "configurationSaved": "Configuration saved", "startTimeSet": "Start time set to", "fixedTimeRemoved": "Fixed time removed.", "itemDeleted": "deleted.", "unknownError": "Unknown error", "retryNow": "Retry now", "retryCompleted": "Retry completed", "progress": "Progress", "alwaysStartFrom": "Always start from", "resumedAt": "Resumed at", "percentWatched": "% watched", "remaining": "remaining", "setStartTime": "Set start time", "changeOrRemoveStartTime": "Always start from {time} (Click to change or remove)", "enterStartTime": "Enter the start time you always want to use (example: 1:23)", "enterStartTimeOrEmpty": "Enter the start time you always want to use (example: 1:23) or leave empty to remove", "deleteEntry": "Delete entry", "youtubePlaybackPlox": "YouTube Playback Plox", "playlistPrefix": "Playlist", "unknown": "Unknown", "notAvailable": "N/A", "clearAll": "Clear all", "clearAllConfirm": "Are you sure you want to delete ALL saved videos? This action can be undone.", "allItemsCleared": "All items cleared", "viewAllHistory": "View all history", "viewCompletedVideos": "View completed videos", "completed": "Completed", "completedVideos": "Completed videos", "videosWithFixedTime": "Videos with fixed time", "views": "Views", "enableProgressBarGradient": "Enable color gradient in progress bar", "staticFinishPercent": "Percentage to mark video as completed", "openChannel": "Open channel", "openPlaylist": "Open playlist", "createPlaylist": "Create playlist", "selectVideos": "Select videos", "selectedVideos": "Selected videos", "generatePlaylistLink": "Generate playlist link", "playlistLinkGenerated": "Playlist link generated", "copyLink": "Copy link", "linkCopied": "Link copied to clipboard", "selectAtLeastOne": "Select at least one video", "tooManyVideos": "Too many videos selected (max 200)", "miniplayerVideos": "Miniplayer videos", "inlinePreviews": "Inline previews (Home)", "removeFromPlaylist": "Remove from playlist", "confirmRemoveFromPlaylist": "Are you sure you want to remove this video from the playlist? It will be kept as an individual video.", "playlistAssociationRemoved": "Playlist association removed", "loading": "Loading", "rendered": "rendered", "previews": "Previews", "migratingData": "Migrating saved data from previous version...", "migratingDataProgress": "Migrating data... {count} entries processed", "migrationComplete": "Migration completed: {migrated} videos successfully migrated", "migrationNoData": "No data found to migrate" }, "es-ES": { "settings": "Configuración", "savedVideos": "Ver videos guardados", "close": "Cerrar", "save": "Guardar", "cancel": "Cancelar", "delete": "Eliminar", "undo": "Deshacer", "enableSavingFor": "Activar guardado para", "regularVideos": "Videos regulares", "shorts": "Shorts", "liveStreams": "Directos (Livestreams)", "live": "Directo", "showNotifications": "Mostrar notificaciones de guardado", "minSecondsBetweenSaves": "Intervalo segundos mínimos entre guardados", "showFloatingButton": "Mostrar botón flotante", "language": "Idioma", "alertStyle": "Estilo de alertas en la barra de reproducción", "alertIconText": "Icono + Texto", "alertIconOnly": "Solo Icono", "alertTextOnly": "Solo Texto", "alertHidden": "Oculto", "noSavedVideos": "No hay videos guardados.", "sortBy": "Ordenar por", "mostRecent": "Más recientes", "oldest": "Más antiguos", "titleAZ": "Título (A-Z)", "filterByType": "Filtrar por tipo", "all": "Todos", "videos": "Videos", "playlist": "Playlist", "searchByTitleOrAuthor": "Buscar por título o autor...", "export": "Exportar", "import": "Importar", "progressSaved": "Progreso guardado", "storageFull": "Almacenamiento lleno - No se puede guardar el progreso", "dataExported": "Datos exportados", "itemsImported": "Importados {count} elementos", "importError": "Error al importar. Asegúrate de que el archivo sea válido.", "exportError": "Error al exportar datos", "invalidFormat": "Formato inválido", "invalidJson": "JSON inválido", "invalidDatabase": "Base de datos inválida", "noValidVideos": "No se encontraron videos válidos para importar", "allDataCleared": "Todos los datos eliminados", "noDataToRestore": "No hay datos para restaurar", "allDataRestored": "Todos los datos restaurados", "clearAllDataConfirm": "¿Estás seguro de que quieres eliminar todos los datos?", "omitedVideos": "Videos omitidos", "fileTooLarge": "El archivo es demasiado grande (máx {size})", "importingFromFreeTube": "Importando desde FreeTube...", "importingFromFreeTubeAsSQLite": "Importando desde FreeTube como SQLite...", "videosImported": "videos importados", "noVideosImported": "no se pudo importar ningún video", "errors": "errores", "noVideosFoundInFreeTubeDB": "No se encontraron videos en la base de datos de FreeTube", "videosImportedFromFreeTubeDB": "videos importados desde la base de datos de FreeTube", "noVideosImportedFromFreeTubeDB": "no se pudo importar ningún video desde la base de datos de FreeTube", "fileEmpty": "El archivo está vacío", "processingFile": "Procesando archivo...", "configurationSaved": "Configuración guardada", "startTimeSet": "Tiempo de inicio establecido en", "fixedTimeRemoved": "Tiempo fijo eliminado.", "itemDeleted": "eliminado.", "unknownError": "Error desconocido", "retryNow": "Reintentar ahora", "retryCompleted": "Reintentos completados", "progress": "Progreso", "alwaysStartFrom": "Siempre desde", "resumedAt": "Reanudado en", "percentWatched": "% visto", "remaining": "restantes", "setStartTime": "Establecer tiempo de inicio", "changeOrRemoveStartTime": "Siempre empezar en {time} (Click para cambiar o eliminar)", "enterStartTime": "Introduce el tiempo de inicio que siempre quieres usar (ejemplo: 1:23)", "enterStartTimeOrEmpty": "Introduce el tiempo de inicio que siempre quieres usar (ejemplo: 1:23) o deja vacío para eliminar", "deleteEntry": "Eliminar entrada", "youtubePlaybackPlox": "YouTube Playback Plox", "playlistPrefix": "Playlist", "unknown": "Desconocido", "notAvailable": "N/A", "clearAll": "Eliminar todo", "clearAllConfirm": "¿Estás seguro de que quieres eliminar TODOS los videos guardados? Esta acción se puede deshacer.", "allItemsCleared": "Todos los elementos eliminados", "viewAllHistory": "Ver todo el historial", "viewCompletedVideos": "Ver videos completados", "completed": "Completado", "completedVideos": "Videos completados", "videosWithFixedTime": "Videos con tiempo fijo", "views": "Vistas", "enableProgressBarGradient": "Habilitar degradado de colores en barra de progreso", "staticFinishPercent": "Porcentaje para marcar video como completado", "openChannel": "Abrir canal", "openPlaylist": "Abrir playlist", "createPlaylist": "Crear playlist", "selectVideos": "Seleccionar videos", "selectedVideos": "Videos seleccionados", "generatePlaylistLink": "Generar enlace de playlist", "playlistLinkGenerated": "Enlace de playlist generado", "copyLink": "Copiar enlace", "linkCopied": "Enlace copiado al portapapeles", "selectAtLeastOne": "Selecciona al menos un video", "tooManyVideos": "Demasiados videos seleccionados (máx 200)", "miniplayerVideos": "Videos en miniplayer", "inlinePreviews": "Previsualizaciones en inicio (Home)", "removeFromPlaylist": "Quitar de la playlist", "confirmRemoveFromPlaylist": "¿Estás seguro de que quieres quitar este video de la playlist? Se mantendrá como video individual.", "playlistAssociationRemoved": "Asociación de playlist eliminada", "loading": "Cargando", "rendered": "renderizados", "previews": "Previsualizaciones", "migratingData": "Migrando datos guardados desde versión anterior...", "migratingDataProgress": "Migrando datos... {count} entradas procesadas", "migrationComplete": "Migración completada: {migrated} videos migrados correctamente", "migrationNoData": "No se encontraron datos para migrar" }, "fr": { "settings": "Paramètres", "savedVideos": "Voir les vidéos enregistrées", "close": "Fermer", "save": "Enregistrer", "cancel": "Annuler", "delete": "Supprimer", "undo": "Annuler", "enableSavingFor": "Activer la sauvegarde pour", "regularVideos": "Vidéos régulières", "shorts": "Shorts", "liveStreams": "Diffusions en direct", "live": "Diffusions en direct", "showNotifications": "Afficher les notifications de sauvegarde", "minSecondsBetweenSaves": "Secondes minimales entre les sauvegardes", "showFloatingButton": "Afficher le bouton flottant", "language": "Langue", "alertStyle": "Style d'alerte dans la barre de lecture", "alertIconText": "Icône + Texte", "alertIconOnly": "Icône uniquement", "alertTextOnly": "Texte uniquement", "alertHidden": "Masqué", "noSavedVideos": "Aucune vidéo enregistrée.", "sortBy": "Trier par", "mostRecent": "Plus récent", "oldest": "Plus ancien", "titleAZ": "Titre (A-Z)", "filterByType": "Filtrer par type", "all": "Tous", "videos": "Vidéos", "playlist": "Playlist", "searchByTitleOrAuthor": "Rechercher par titre ou auteur...", "export": "Exporter", "import": "Importer", "progressSaved": "Progrès enregistré", "dataExported": "Données exportées", "itemsImported": "{count} éléments importés", "importError": "Erreur lors de l'importation. Assurez-vous que le fichier est valide.", "exportError": "Erreur lors de l'exportation des données", "invalidFormat": "Format invalide", "invalidJson": "JSON invalide", "invalidDatabase": "Base de données invalide", "noValidVideos": "Aucune vidéo valide trouvée à importer", "allDataCleared": "Toutes les données ont été effacées", "noDataToRestore": "Aucune donnée à restaurer", "allDataRestored": "Toutes les données restaurées", "clearAllDataConfirm": "Êtes-vous sûr de vouloir supprimer toutes les données ?", "omitedVideos": "Vidéos omises", "fileTooLarge": "Le fichier est trop volumineux (max {size})", "importingFromFreeTube": "Importation depuis FreeTube...", "importingFromFreeTubeAsSQLite": "Importation depuis FreeTube en tant que SQLite...", "videosImported": "vidéos importées", "noVideosImported": "aucune vidéo n'a pu être importée", "errors": "erreurs", "noVideosFoundInFreeTubeDB": "Aucune vidéo trouvée dans la base de données FreeTube", "videosImportedFromFreeTubeDB": "vidéos importées depuis la base de données FreeTube", "noVideosImportedFromFreeTubeDB": "aucune vidéo n'a pu être importée depuis la base de données FreeTube", "fileEmpty": "Le fichier est vide", "processingFile": "Traitement du fichier...", "configurationSaved": "Configuration enregistrée", "startTimeSet": "Heure de début définie à", "fixedTimeRemoved": "Heure fixe supprimée.", "itemDeleted": "supprimé.", "unknownError": "Erreur inconnue", "retryNow": "Réessayer maintenant", "retryCompleted": "Réessais terminés", "progress": "Progrès", "alwaysStartFrom": "Toujours commencer à", "resumedAt": "Repris à", "percentWatched": "% regardé", "remaining": "restant", "setStartTime": "Définir l'heure de début", "changeOrRemoveStartTime": "Toujours commencer à {time} (Cliquez pour changer ou supprimer)", "enterStartTime": "Entrez l'heure de début que vous souhaitez toujours utiliser (exemple: 1:23)", "enterStartTimeOrEmpty": "Entrez l'heure de début que vous souhaitez toujours utiliser (exemple: 1:23) ou laissez vide pour supprimer", "deleteEntry": "Supprimer l'entrée", "youtubePlaybackPlox": "YouTube Playback Plox", "playlistPrefix": "Playlist", "unknown": "Inconnu", "notAvailable": "N/A", "clearAll": "Tout effacer", "clearAllConfirm": "Êtes-vous sûr de vouloir supprimer TOUTES les vidéos enregistrées ? Cette action peut être annulée.", "allItemsCleared": "Tous les éléments effacés", "viewAllHistory": "Voir tout l'historique", "viewCompletedVideos": "Voir les vidéos terminées", "completed": "Terminé", "completedVideos": "Vidéos terminées", "videosWithFixedTime": "Vidéos avec un temps fixe", "views": "Vues", "enableProgressBarGradient": "Activer le dégradé de couleurs dans la barre de progression", "staticFinishPercent": "Pourcentage pour marquer la vidéo comme terminée", "openChannel": "Ouvrir la chaîne", "openPlaylist": "Ouvrir la playlist", "createPlaylist": "Créer une playlist", "selectVideos": "Sélectionner des vidéos", "selectedVideos": "Vidéos sélectionnées", "generatePlaylistLink": "Générer le lien de la playlist", "playlistLinkGenerated": "Lien de la playlist généré", "copyLink": "Copier le lien", "linkCopied": "Lien copié dans le presse-papiers", "selectAtLeastOne": "Sélectionnez au moins une vidéo", "tooManyVideos": "Trop de vidéos sélectionnées (max 200)", "miniplayerVideos": "Vidéos en miniplayer", "inlinePreviews": "Aperçus intégrés (Accueil)", "removeFromPlaylist": "Retirer de la playlist", "confirmRemoveFromPlaylist": "Êtes-vous sûr de vouloir retirer cette vidéo de la playlist ? Elle sera conservée comme vidéo individuelle.", "playlistAssociationRemoved": "Association de playlist supprimée", "loading": "Chargement", "rendered": "rendus", "previews": "Aperçus", "migratingData": "Migration des données enregistrées depuis la version précédente...", "migratingDataProgress": "Migration des données... {count} éléments traités", "migrationComplete": "Migration terminée : {migrated} vidéos migrées avec succès", "migrationNoData": "Aucune donnée trouvée à migrer" } }; // Función para cargar las traducciones desde el archivo JSON externo async function loadTranslations() { const CACHE_KEY = `${CONFIG.storagePrefix}translations_cache_v1`; const TTL_MS = 6 * 60 * 60 * 1000; // 6 horas // 1) Intentar usar caché (GM_* preferido; luego localStorage) try { if (typeof GM_getValue === 'function') { const raw = await GM_getValue(CACHE_KEY, null); if (raw) { const cached = JSON.parse(raw); const isFresh = cached?.ts && (Date.now() - cached.ts) < TTL_MS; const cachedVersion = cached?.version ?? cached?.data?.VERSION; const versionMatches = !TRANSLATIONS_EXPECTED_VERSION || cachedVersion === TRANSLATIONS_EXPECTED_VERSION; if (isFresh && cached?.data && versionMatches) { info('loadTranslations', 'Usando traducciones desde caché GM_*'); return cached.data; } } } } catch (_) { } try { const raw = localStorage.getItem(CACHE_KEY); if (raw) { const cached = JSON.parse(raw); const isFresh = cached?.ts && (Date.now() - cached.ts) < TTL_MS; const cachedVersion = cached?.version ?? cached?.data?.VERSION; const versionMatches = !TRANSLATIONS_EXPECTED_VERSION || cachedVersion === TRANSLATIONS_EXPECTED_VERSION; if (isFresh && cached?.data && versionMatches) { info('loadTranslations', 'Usando traducciones desde caché localStorage'); return cached.data; } } } catch (_) { } // 2) Helper para cargar desde URL con GM_xmlhttpRequest o fetch const fetchUrl = async (url) => { if (typeof GM_xmlhttpRequest === 'function') { return await new Promise((resolve, reject) => { try { GM_xmlhttpRequest({ method: 'GET', url, timeout: 5000, onload: (response) => { try { resolve(JSON.parse(response.responseText)); } catch (e) { reject(e); } }, onerror: (e) => reject(e), ontimeout: () => reject(new Error('timeout')) }); } catch (err) { reject(err); } }); } // Fallback a fetch nativo if (typeof fetch === 'function') { const resp = await fetch(url, { cache: 'no-store' }); const text = await resp.text(); return JSON.parse(text); } throw new Error('No hay método de red disponible'); }; // 3) Intentar URLs primarias/secundarias const urls = [TRANSLATIONS_URL, TRANSLATIONS_URL_BACKUP]; let data = null; for (const url of urls) { try { const candidate = await fetchUrl(url); if (candidate?.LANGUAGE_FLAGS && Object.keys(candidate.LANGUAGE_FLAGS).length > 0 && candidate?.TRANSLATIONS && Object.keys(candidate.TRANSLATIONS).length > 0) { info('loadTranslations', 'Traducciones externas cargadas correctamente desde: ' + url); data = candidate; break; } else { warn('loadTranslations', 'Traducciones inválidas desde: ' + url); } } catch (e) { warn('loadTranslations', 'Fallo al cargar traducciones desde ' + url, e); } } if (!data) { conError('loadTranslations', 'No se pudieron cargar traducciones externas, usando fallback'); data = { LANGUAGE_FLAGS: FALLBACK_FLAGS, TRANSLATIONS: FALLBACK_TRANSLATIONS }; } // 4) Guardar en caché const cachePayload = JSON.stringify({ ts: Date.now(), version: data?.VERSION ?? TRANSLATIONS_EXPECTED_VERSION ?? null, data }); try { if (typeof GM_setValue === 'function') await GM_setValue(CACHE_KEY, cachePayload); } catch (_) { } try { localStorage.setItem(CACHE_KEY, cachePayload); } catch (_) { } return data; } // ------------------------------------------ // MARK: 📦 Config // ------------------------------------------ const CONFIG = { /** Diferencia mínima (en segundos) para considerar un cambio de posición como válido */ minSeekDiff: 1.5, /** Prefijo para claves en localStorage */ storagePrefix: 'YT_PLAYBACK_PLOX_', /** Enumeración de estilos de alerta */ alertStylesSettings: { icon_only: 'iconOnly', text_only: 'textOnly', icon_and_text: 'iconText', no_icon_no_text: 'hidden' }, /** Clave para guardar configuraciones del usuario en GM_* */ userSettingsKey: 'YT_PLAYBACK_PLOX_userSettings', /** Valores predeterminados para configuraciones del usuario */ defaultSettings: { showNotifications: true, minSecondsBetweenSaves: 1, showFloatingButtons: false, saveRegularVideos: true, // Por defecto, guardar videos regulares saveShorts: false, // Por defecto, no guardar Shorts saveLiveStreams: false, // Por defecto, no guardar directos de URL tipo "/live" o "/watch" con player en directo, si ya es VOD lo toma como regular language: 'en-US', // Idioma predeterminado alertStyle: 'iconText', // Estilo de alerta predeterminado enableProgressBarGradient: true, // Por defecto, habilitar degradado de colores en barra de progreso staticFinishPercent: 95, // Porcentaje desde el final para considerar video como completado (95% = 5% antes del final) saveInlinePreviews: false, // Guardar previsualizaciones inline (Homepage) desactivado por defecto saveMiniplayerVideos: true, // Guardar videos en miniplayer (default: activo) }, /** 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: "" } }; // ------------------------------------------ // MARK: 🌐 Funciones de traducción // ------------------------------------------ let currentLanguage = CONFIG.defaultSettings.language; // Idioma predeterminado // Función para obtener el texto traducido function t(key, params = {}, defaultText = null) { let actualDefaultText = defaultText; let actualParams = params; // Soporte para valor por defecto como segundo argumento: t('key', 'Default Text') if (typeof params === 'string') { actualDefaultText = params; actualParams = {}; } // Si params no es objeto, asegurarlo if (typeof actualParams !== 'object' || actualParams === null) { actualParams = {}; } // Si no se pasó defaultText explícito, usar la key como fallback if (!actualDefaultText) { actualDefaultText = key; } if (!TRANSLATIONS[currentLanguage] || !TRANSLATIONS[currentLanguage][key]) { // Si no hay traducción, intentar con el idioma por defecto (ej: en-US) const fallbackLang = CONFIG.defaultSettings.language; if (TRANSLATIONS[fallbackLang] && TRANSLATIONS[fallbackLang][key]) { return replaceParams(TRANSLATIONS[fallbackLang][key], actualParams); } // Si no hay ni en el idioma por defecto, devolver el valor por defecto return replaceParams(actualDefaultText, actualParams); } return replaceParams(TRANSLATIONS[currentLanguage][key], actualParams); } // Función para reemplazar parámetros en las traducciones function replaceParams(text, params) { if (!text || typeof text !== 'string') return text; return text.replace(/{(\w+)}/g, (match, param) => { return params[param] !== undefined ? params[param] : match; }); } /** * Fusiona profundamente mapas de traducciones por idioma, priorizando las externas. * @param {Object} base - Traducciones base/fallback (por idioma) * @param {Object} override - Traducciones externas (por idioma) * @returns {Object} Mapa de traducciones resultante por idioma */ function deepMergeTranslations(base, override) { try { const result = { ...(base || {}) }; const over = override && typeof override === 'object' ? override : {}; for (const lang of Object.keys(over)) { const baseLang = result[lang] || {}; const overLang = over[lang] || {}; result[lang] = { ...baseLang, ...overLang }; } return result; } catch (_) { return { ...(base || {}) }; } } // Función para cambiar el idioma async function setLanguage(lang, options = { persist: true }) { log('setLanguage', 'lang que llega:', lang); let validLang = lang; if (!TRANSLATIONS[validLang]) { const primary = lang.split('-')[0]; validLang = Object.keys(TRANSLATIONS).find(k => k === primary || k.startsWith(primary + '-')); } if (!validLang) validLang = CONFIG.defaultSettings.language; currentLanguage = validLang; // Persistir solo si se solicita (evitar escrituras redundantes durante init) if (options?.persist) { try { const settings = await Settings.get(); settings.language = validLang; await Settings.set(settings); } catch (e) { conError('setLanguage', 'Error persistiendo idioma', e); } } log('setLanguage', 'lang que sale:', validLang); return true; } // Función para detectar el idioma del navegador function detectBrowserLanguage() { const primaryLang = navigator.language || navigator.userLanguage; // "es-ES" o "en" const candidates = (Array.isArray(navigator.languages) && navigator.languages.length) ? navigator.languages : (primaryLang ? [primaryLang] : []); log('detectBrowserLanguage', 'candidates:', candidates); // Coincidencia exacta priorizando navigator.languages[0] for (const lang of candidates) { if (TRANSLATIONS[lang]) return lang; } // Coincidencia por prefijo (ejemplo: "es" -> "es-ES" o "es-419") for (const lang of candidates) { const prefix = (lang || '').split('-')[0]; const matched = Object.keys(TRANSLATIONS).find(k => k === prefix || k.startsWith(prefix + '-')); if (matched) { log('detectBrowserLanguage', 'matched by prefix:', matched); return matched; } } warn(`Idioma del navegador '${primaryLang}' no soportado, usando default.`); return CONFIG.defaultSettings.language; } // ------------------------------------------ // 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 { /* Base (Light) - Solo variables --ypp- */ --ypp-bg: #ffffff; --ypp-text: #222222; --ypp-muted: #555555; --ypp-light: #888888; --ypp-link: #065fd4; --ypp-danger: #dc2626; --ypp-success: #16a34a; --ypp-success-dark: #15803d; --ypp-overlay: rgba(0, 0, 0, 0.4); --ypp-toast: #333333; --ypp-primary: #2563eb; --ypp-primary-dark: #1e40af; --ypp-border: #cccccc; --ypp-playlist-bg: #f0f8ff; /* Fondo sutil para items de playlist */ --ypp-bg-secondary: #f5f5f5; /* Tipografía */ --ypp-white: #ffffff; --ypp-font-base: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; /* Espaciado */ --ypp-spacing-sm: 0.5rem; --ypp-spacing-md: 1rem; --ypp-spacing-lg: 1.5rem; /* Sombra */ --ypp-shadow-md: 0 4px 20px rgba(0, 0, 0, 0.2); --ypp-shadow-modal: 0 4px 16px rgba(0, 0, 0, 0.25); --ypp-shadow: 0 12px 24px rgba(0, 0, 0, 0.15); /* Z-index */ --ypp-z-overlay: 9999; --ypp-z-modal: 10000; --ypp-z-toast: 10001; /* Inputs */ --ypp-input: #f5f5f5; --ypp-input-border: #cccccc; --ypp-input-focus: #1a73e8; /* Light */ } html[dark], body.dark-theme { --ypp-bg: #0f0f0f; --ypp-text: #f1f1f1; --ypp-muted: #aaaaaa; --ypp-light: #aaaaaa; --ypp-link: #3ea6ff; --ypp-border: #303030; --ypp-bg-secondary: #1a1a1a; --ypp-overlay: rgba(0, 0, 0, 0.8); --ypp-input: #1a1a1a; --ypp-input-border: #303030; /* Overrides específicas para UI en oscuro */ --ypp-input-focus: #065fd4; --ypp-shadow: 0 12px 24px rgba(0, 0, 0, 0.5); } .ypp-sombra { box-shadow: 0.8px 0.8px 2.7px rgba(0, 0, 0, 0.062), 2.1px 2.1px 6.9px rgba(0, 0, 0, 0.089), 4.3px 4.3px 14.2px rgba(0, 0, 0, 0.111), 8.8px 8.8px 29.2px rgba(0, 0, 0, 0.138), 24px 24px 80px rgba(0, 0, 0, 0.2); -webkit-box-shadow: 0.8px 0.8px 2.7px rgba(0, 0, 0, 0.062), 2.1px 2.1px 6.9px rgba(0, 0, 0, 0.089), 4.3px 4.3px 14.2px rgba(0, 0, 0, 0.111), 8.8px 8.8px 29.2px rgba(0, 0, 0, 0.138), 24px 24px 80px rgba(0, 0, 0, 0.2); } .ypp-svgFolderIcon, .ypp-svgSaveIcon, .ypp-svgPinIcon, .ypp-svgTimerIcon, .ypp-svgPlayOrPauseIcon { vertical-align: middle; height: 100%; margin: 0 0px 2px 0px; } .ypp-d-flex { display: flex; } .ypp-d-none { display: none !important; } /* ========================= 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(--ypp-overlay); z-index: var(--ypp-z-overlay); } .ypp-videosContainer { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--ypp-bg, #0f0f0f); border: 1px solid var(--ypp-border, #303030); border-radius: 12px; width: 90%; max-width: 800px; max-height: 85vh; color: var(--ypp-text, #f1f1f1); box-shadow: var(--ypp-shadow, 0 12px 24px rgba(0, 0, 0, 0.5)); z-index: var(--ypp-z-modal); display: flex; flex-direction: column; opacity: 0; transform: translate(-50%, -50%) translateY(20px) scale(0.95); animation: videosModalSlideIn 0.3s ease-out forwards; } @keyframes videosModalSlideIn { to { opacity: 1; transform: translate(-50%, -50%) translateY(0) scale(1); } } .ypp-container { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--ypp-bg); border-radius: 8px; box-shadow: var(--ypp-shadow-md); padding: 0; /* Padding manejado por hijos */ z-index: var(--ypp-z-modal); width: 550px; /* Un poco más ancho para los nuevos botones */ max-height: 80vh; display: flex; flex-direction: column; font-family: var(--ypp-font-base); color: var(--ypp-text); } .ypp-time-display { color: var(--ypp-white); font-weight: bold; margin-left: 10px; font-size: 1.4rem; background: /* #4a4a4a91; */ hsla(0,0%,6.7%,0.4); padding: var(--ypp-spacing-sm) var(--ypp-spacing-md); border-radius: 14px; cursor: pointer; transition: all 0.2s ease; &:hover { background: var(--ypp-success-dark); } } /* .ytp-time-current, .ytp-time-separator, .ytp-time-duration { display: none !important; visibility: hidden !important; } */ .ytp-live .ytp-time-current, .ytp-live .ytp-time-separator, .ytp-live .ytp-time-duration { display: none !important; visibility: visible !important; } /* Estilo específico para mensajes en Shorts - integrado en el player */ .ypp-shorts-time-display { background: hsla(109.7, 56.1%, 22.4%, 0.7); color: #fff; padding: 4px 0px; font-size: 13px; font-weight: 700; /* backdrop-filter: blur(10px); */ cursor: pointer; transition: all 0.2s ease; pointer-events: auto; justify-content: center; display: flex; gap: 6px; position: relative; /* asegurar stacking context */ z-index: var(--ypp-z-toast, 10001); /* asegurar visibilidad por encima de overlays transitorios */ margin: 4px auto 0; /* centrar en metapanel */ border-radius: var(--ypp-spacing-md); /* Truncado de texto */ white-space: nowrap; overflow: hidden; &:hover { background: var(--ypp-success-dark, #15803d); transform: translateY(-1px); } } /* Fallback flotante cuando el metapanel está oculto */ .ypp-shorts-time-display.ypp-floating { position: absolute; left: 50%; transform: translateX(-50%); bottom: 64px; /* por encima de botones de acción */ z-index: var(--ypp-z-toast, 10001); } /* ========================= Header, Footer, Layout ========================= */ .ypp-header, .ypp-modalHeader { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--ypp-border); flex-shrink: 0; } .ypp-filters { padding: var(--ypp-spacing-md) var(--ypp-spacing-lg); border-bottom: 1px solid var(--ypp-border); display: flex; flex-direction: column; gap: var(--ypp-spacing-md); flex-shrink: 0; gap: 0; } .ypp-footer { padding: var(--ypp-spacing-md) var(--ypp-spacing-lg); border-top: 2px solid var(--ypp-border); display: flex; flex-direction: column; gap: var(--ypp-spacing-sm); z-index: 10; flex-shrink: 0; } .ypp-footer-row { display: flex; justify-content: center; align-items: center; gap: var(--ypp-spacing-sm); flex-wrap: wrap; } .ypp-footer-row-bottom { justify-content: space-between; } #video-list-container { flex-grow: 1; /* Ocupar el espacio restante */ overflow: hidden; /* El scroll lo maneja el virtual scroller */ padding: 0; /* Padding se aplica a los elementos internos */ position: relative; display: flex; flex-direction: column; } #ypp-virtual-scroller-container { flex-grow: 1; overflow-y: auto; overflow-x: hidden; padding: var(--ypp-spacing-md) var(--ypp-spacing-lg); } /* Virtual Scroller Styles */ .ypp-virtual-spacer { position: relative; width: 100%; } .ypp-virtual-item { position: absolute; left: 0; right: 0; width: 100%; box-sizing: border-box; } .ypp-virtual-loading { display: flex; align-items: center; justify-content: center; padding: var(--ypp-spacing-lg); color: var(--ypp-text-muted); font-size: 1.2rem; } .ypp-virtual-stats { position: sticky; top: 0; background: var(--ypp-bg); padding: 8px var(--ypp-spacing-lg); border-bottom: 1px solid var(--ypp-border); font-size: 0.9rem; color: var(--ypp-text-muted); z-index: 10; display: flex; justify-content: space-between; align-items: center; } .ypp-settingsContent { display: flex; flex-direction: column; gap: var(--ypp-spacing-md); max-height: 60vh; overflow-y: auto; } .ypp-btnGroup { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 16px 24px; background: var(--ypp-bg, #0f0f0f); border-radius: 0 0 12px 12px; flex-shrink: 0; margin-top: auto; } .ypp-saving-options{ display: flex; flex-direction: column; background: var(--ypp-border); border-radius: 6px; padding: 10px; } .ypp-container-saving-options { background: var(--ypp-bg-secondary); border-radius: 6px; padding: 6px; gap: 8px; display: flex; flex-direction: column; margin-bottom: 10px; } .ypp-label-save-type{ margin: 0 0 0 10px } /* ========================= Tipografía ========================= */ .ypp-emptyMsg { text-align: center; color: #aaa; padding: 40px 24px; font-size: 1.4rem; } .ypp-playlistTitle { margin: 8px 0 4px; color: #065fd4; cursor: pointer; text-decoration: none; display: block; font-size: 1.2rem; font-weight: 500; } .ypp-playlistTitle:hover { color: #0550b3; text-decoration: underline; } .ypp-titleLink { font-weight: 600; font-size: 1.4rem; color: var(--ypp-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(--ypp-muted); } .ypp-author-link { color: var(--ypp-link); text-decoration: none; transition: color 0.2s; } .ypp-author-link:hover { color: var(--ypp-primary-dark); text-decoration: underline; } .ypp-timestamp, .ypp-progressInfo { font-size: 1.3rem; margin-top: 4px; display: flex; align-items: center; gap: 4px; } .ypp-timestamp { color: var(--ypp-muted); } .ypp-timestamp.forced { color: var(--ypp-primary-dark); font-weight: bold; } .ypp-timestamp.completed { color: var(--ypp-success); font-weight: bold; } .ypp-timestamp.forced.completed { /* Video con tiempo fijo Y completado: color mixto */ color: #15803d; font-weight: bold; background: linear-gradient(90deg, var(--ypp-primary-dark) 0%, var(--ypp-success) 100%); background-clip: text; } /* ========================= Video List ========================= */ .ypp-videoWrapper { display: flex; align-items: center; min-height: 120px; /* Altura fija para virtualización precisa */ border-bottom: 1px solid var(--ypp-border); padding: var(--ypp-spacing-sm) var(--ypp-spacing-md); box-sizing: border-box; background: var(--ypp-bg); } .ypp-videoWrapper.playlist-item { border-radius: 6px; transition: all 0.2s ease; height: 140px !important; } .ypp-videoWrapper.regular-item { background-color: var(--ypp-bg-secondary); border-left: 4px solid var(--ypp-border); height: 120px !important; /* Altura estándar */ } .ypp-playlist-indicator { display: flex; align-items: center; margin: 4px 0; font-size: 0.85em; opacity: 1; background: rgba(0, 0, 0, 0.75); color: #ffffff; padding: 3px 8px; border-radius: 6px; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); backdrop-filter: blur(3px); font-weight: 600; border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; } .ypp-videoWrapper { overflow: hidden !important; } .ypp-playlist-link { display: inline-flex; align-items: center; margin-left: 8px; opacity: 0.7; transition: opacity 0.2s ease; } .ypp-playlist-link:hover { opacity: 1; } .ypp-playlist-header { height: 40px !important; max-height: 40px !important; display: flex; align-items: center; padding: 0 var(--ypp-spacing-md); box-sizing: border-box; font-weight: bold; color: var(--ypp-text-highlight); background: var(--ypp-bg); border-bottom: 1px solid var(--ypp-border); overflow: hidden; } .ypp-playlist-header a { color: inherit; text-decoration: none; display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; /* Necesario para que text-overflow funcione en flex child */ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ypp-playlist-header a:hover { text-decoration: underline; } .ypp-virtual-item { position: absolute !important; left: 0; width: 100%; } /* Estilos para modo de selección */ .ypp-videoWrapper.selection-mode { cursor: pointer; transition: all 0.2s ease; } .ypp-videoWrapper.selection-mode:hover { background-color: var(--ypp-bg) !important; /* transform: translateX(-1px); */ } .ypp-video-checkbox { min-width: 15px; margin: 0 10px; transform: scale(1.2); cursor: pointer; } /* Estilos para el área de playlist integrada */ .ypp-playlist-creation-area { margin-top: 12px; padding: 15px; background-color: var(--ypp-bg-secondary); border: 1px solid var(--ypp-border); border-radius: 6px; display: none; } .ypp-playlist-creation-area.active { display: block; } .ypp-playlist-textarea { width: 100%; height: 50px; max-height: 40px; border: 1px solid var(--ypp-border); border-radius: 4px; font-family: 'Courier New', monospace; font-size: 11px; line-height: 1.3; background-color: var(--ypp-bg); color: var(--ypp-text); resize: none; overflow-y: auto; word-wrap: break-word; } .ypp-playlist-actions { display: flex; gap: 10px; margin-top: 10px; justify-content: center; } .ypp-footer-row.hidden { display: none; } .ypp-thumb { max-width: 110px; max-height: 80px; object-fit: cover; border-radius: 4px; margin-right: var(--ypp-spacing-sm); flex-shrink: 0; } .ypp-infoDiv { flex-grow: 1; min-width: 0; /* Permite que el contenedor se encoja correctamente */ } .ypp-containerButtonsTime { display: flex; align-items: center; gap: 8px; margin-left: auto; } .ypp-sort-select, .ypp-filter-select { background: #1a1a1a; border: 1px solid #303030; color: #f1f1f1; padding: 8px 12px; border-radius: 6px; font-size: 1.3rem; cursor: pointer; transition: border-color 0.2s ease, background-color 0.2s ease; flex: 1; width: auto; margin-bottom: 8px; } .ypp-sort-select:focus, .ypp-filter-select:focus { outline: none; border-color: #065fd4; background: #252525; } .ypp-sort-select option, .ypp-filter-select option { background: #1a1a1a; color: #f1f1f1; } .ypp-search-input { background: #1a1a1a; border: 1px solid #303030; color: #f1f1f1; padding: 8px 12px; border-radius: 6px; font-size: 1.3rem; transition: border-color 0.2s ease, background-color 0.2s ease; flex: 1; min-width: 200px; } .ypp-search-input:focus { outline: none; border-color: #065fd4; background: #252525; } .ypp-search-input::placeholder { color: #888; } /* ========================= Botones ========================= */ .ypp-btn { display: inline-flex; align-items: center; justify-content: center; padding: 5px 14px; font-weight: 500; font-size: 1.4rem; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; border: none; outline: none; position: relative; overflow: hidden; min-height: 20px; gap: 8px; background: var(--ypp-primary); color: var(--ypp-white); } .ypp-btn::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.1); opacity: 0; transition: opacity 0.2s ease; } .ypp-btn:hover::before { opacity: 1; } .ypp-btn:active { transform: scale(0.98); } .ypp-btn:hover { background: var(--ypp-primary-dark); } .ypp-btn:active { background: #0441a1; } .ypp-btn-outlined { background: transparent; border: 1px solid #065fd4; color: inherit; &:hover { background: rgba(6, 95, 212, 0.3); color: inherit; } } .ypp-save-button { background: transparent; border: 1px solid var(--ypp-success); color: inherit; &:hover { background: rgba(22, 212, 6, 0.3); color: inherit; } &:active { background: #008855; } } .ypp-btn-secondary { background: #f1f1f1; color: #0f0f0f; } .ypp-btn-secondary:hover { background: var(--ypp-success-dark); color: var(--ypp-bg); } .ypp-btn-secondary:active { background: #d9d9d9; } .ypp-btn-delete { background: transparent; border: 1px solid var(--ypp-danger); color: var(--ypp-danger);; } .ypp-btn-delete:hover { background: var(--ypp-danger); color: inherit; } .ypp-btn-delete:active { background: rgba(255, 68, 68, 0.2); } .ypp-btn-danger { background: #ff4444; color: #ffffff; } .ypp-btn-danger:hover { background: var(--ypp-danger); } .ypp-btn-danger:active { background: #dd2222; } .ypp-btn-small { padding: 8px; width: 36px; height: 36px; min-height: 36px; flex-shrink: 0; border-radius: 18px; } .ypp-btn-close{ background: var(--ypp-text); border: 1px solid #303030; color: var(--ypp-bg); } .ypp-btn-close:hover { background: var(--ypp-danger); } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideUp { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } /* ========================= Toasts ========================= */ .ypp-toast-container { position: fixed; top: var(--ypp-spacing-md); right: var(--ypp-spacing-md); display: flex; flex-direction: column; gap: 0.5rem; z-index: var(--ypp-z-toast); pointer-events: none; } .ypp-toast { display: flex; gap: 10px; justify-content: center; align-items: center; background: var(--ypp-bg, #0f0f0f); color: var(--ypp-text, #f1f1f1); padding: 12px 16px; border-radius: 8px; border: 1px solid var(--ypp-border, #303030); font-size: 1.4rem; max-width: 300px; animation: slideInRight 0.3s ease-out; transition: opacity 0.2s ease; backdrop-filter: blur(10px); pointer-events: auto; } .ypp-toast.persistent { position: relative; } .ypp-toast-close { background: var(--ypp-text); border: 1px solid #303030; color: var(--ypp-bg); width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.2s ease; font-size: 12px; padding: 0; } .ypp-toast-close:hover { background: var(--ypp-danger); } .ypp-toast-action { background: var(--ypp-primary); border: none; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: auto; } /* ========================= Modal ========================= */ .ypp-modalOverlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: var(--ypp-overlay, rgba(0, 0, 0, 0.8)); backdrop-filter: blur(4px); z-index: var(--ypp-z-modal); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.2s ease-out; } .ypp-modalBox { background: var(--ypp-bg, #0f0f0f); border: 1px solid var(--ypp-border, #303030); border-radius: 12px; padding: 0; color: var(--ypp-text, #f1f1f1); max-width: 500px; width: 90%; max-height: 85vh; overflow: hidden; box-shadow: var(--ypp-shadow, 0 12px 24px rgba(0, 0, 0, 0.5)); animation: slideUp 0.3s ease-out; display: flex; flex-direction: column; opacity: 0; transform: translateY(20px) scale(0.95); animation: modalSlideIn 0.3s ease-out forwards; } @keyframes modalSlideIn { to { opacity: 1; transform: translateY(0) scale(1); } } .ypp-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; border-bottom: 1px solid var(--ypp-border, #303030); background: var(--ypp-bg, #0f0f0f); border-radius: 12px 12px 0 0; flex-shrink: 0; } .ypp-header h2 { margin: 0; color: var(--ypp-text, #f1f1f1); font-size: 1.8rem; font-weight: 500; } .ypp-modalTitle { font-weight: 500; color: var(--ypp-text, #f1f1f1); font-size: 1.6rem; margin: 0; flex: 1; } .ypp-modalBody { font-size: 1.4rem; padding: 10px 24px; flex: 1; background: var(--ypp-bg, #0f0f0f); min-height: 0; } /* ========================= Inputs y Forms ========================= */ .ypp-label { display: flex; align-items: center; color: var(--ypp-text, #f1f1f1); font-size: 1.4rem; transition: color 0.2s ease; white-space: nowrap; margin: 8px 0; } .ypp-label input[type="checkbox"] { margin-right: 12px; width: 18px; height: 18px; accent-color: var(--ypp-input-focus, #065fd4); } .ypp-label-language { gap: 12px; } .ypp-label-filters { margin: 0 8px 0 0; } .ypp-input { width: 100%; padding: 12px 16px; margin-bottom: 16px; background: var(--ypp-input, #1a1a1a); border: 1px solid var(--ypp-input-border, #303030); border-radius: 8px; color: var(--ypp-text, #f1f1f1); font-size: 1.4rem; transition: border-color 0.2s ease, background-color 0.2s ease; } .ypp-input:focus { outline: none; border-color: var(--ypp-input-focus, #065fd4); background: var(--ypp-bg-secondary, #252525); } .ypp-input::placeholder { color: var(--ypp-text-secondary, #888); } .ypp-percent-symbol { margin-left: 6px; } .ypp-select { width: 100%; padding: 12px 16px; background: var(--ypp-input, #1a1a1a); border: 1px solid var(--ypp-input-border, #303030); border-radius: 8px; color: var(--ypp-text, #f1f1f1); font-size: 1.4rem; cursor: pointer; transition: border-color 0.2s ease, background-color 0.2s ease; } .ypp-select:focus { outline: none; border-color: var(--ypp-input-focus, #065fd4); background: var(--ypp-bg-secondary, #252525); } .ypp-select option { background: var(--ypp-input, #1a1a1a); color: var(--ypp-text, #f1f1f1); } .ypp-input-small { margin-left: 10px; border-radius: 10px; padding: 2px 16px; } /* ========================= Floating Button ========================= */ .ypp-floatingBtnContainer { position: fixed; bottom: var(--ypp-spacing-md); right: var(--ypp-spacing-md); z-index: var(--ypp-z-overlay); display: flex; gap: 10px; } /* ========================= Selector de Idioma con Banderas ========================= */ .ypp-language-selector { display: flex; align-items: center; gap: 8px; } .ypp-language-flag { font-size: 1.2em; margin-right: 5px; } `; document.head.appendChild(style); } // ------------------------------------------ // MARK: 🎨 Theme // ------------------------------------------ function isYouTubeDarkTheme() { // Detectar si YouTube está en modo oscuro const htmlElement = document.documentElement; const computedStyle = getComputedStyle(htmlElement); // Verificar tema oscuro return ( htmlElement.getAttribute('dark') === 'true' || htmlElement.hasAttribute('dark') || computedStyle.getPropertyValue('--yt-spec-base-background') === '#0f0f0f' || computedStyle.getPropertyValue('--yt-spec-text-primary') === '#f1f1f1' || document.body.classList.contains('dark-theme') || document.querySelector('ytd-masthead')?.getAttribute('dark') === 'true' ); } // ------------------------------------------ // MARK: 🎨 SVG Icons // ------------------------------------------ // SVGs como strings para reemplazar emojis const SVG_ICONS = { folder: '', timer: '', check: '', save: '', chart: '', settings: '', close: '', // play: '', trash: '', download: '', upload: '', externalLink: '', playlist: '', copy: '', // calendar: '', // sort: '', locked: '', unlocked: '', pin: '', playOrPause: '', warning: '', import: '', export: '', error: '' }; // ------------------------------------------ // MARK: 🎨 Estilo barra progreso // ------------------------------------------ /** * Aplica degradado de colores a la barra de progreso del reproductor de YouTube usando CSS * @param {number} currentTime - Tiempo actual del video en segundos * @param {number} duration - Duración total del video en segundos * @param {string} type - Tipo de video ('shorts', 'video' o 'watch') */ function updateProgressBarGradient(currentTime, duration, type = 'watch') { try { // Verificar si la funcionalidad está deshabilitada en la configuración if (!cachedSettings.enableProgressBarGradient) { return; } if (!duration || duration <= 0) return; const percent = Math.min(100, Math.round((currentTime / duration) * 100)); const progressColor = getProgressColor(percent); // Detectar si estamos en shorts const isShorts = type === 'shorts' || window.location.pathname.includes('/shorts/'); if (isShorts) { // Selectores específicos para shorts con estructura correcta const shortsProgressHost = document.querySelector('.desktopShortsPlayerControlsHost .ytPlayerProgressBarHost, .ytPlayerProgressBarHost'); const shortsPlayedBar = document.querySelector('.ytProgressBarLineProgressBarPlayed'); const shortsHoveredBar = document.querySelector('.ytProgressBarLineProgressBarHovered'); const shortsPlayheadDot = document.querySelector('.ytProgressBarPlayheadProgressBarPlayheadDot'); if (shortsProgressHost) { // Aplicar variables CSS para el degradado en shorts shortsProgressHost.style.setProperty('--ytp-progress-color', progressColor, 'important'); shortsProgressHost.style.setProperty('--ytp-progress-percent', `${percent}%`, 'important'); // Aplicar estilos directamente a los elementos de la barra de shorts if (shortsPlayedBar) { shortsPlayedBar.style.backgroundColor = progressColor; shortsPlayedBar.style.setProperty('background', progressColor, 'important'); } if (shortsHoveredBar) { shortsHoveredBar.style.backgroundColor = progressColor; shortsHoveredBar.style.setProperty('background', progressColor, 'important'); } if (shortsPlayheadDot) { shortsPlayheadDot.style.backgroundColor = progressColor; shortsPlayheadDot.style.setProperty('background', progressColor, 'important'); } } } else { // Selectores para videos regulares const progressContainer = document.querySelector('.ytp-progress-bar'); const playProgress = document.querySelector('.ytp-play-progress'); const hoverProgress = document.querySelector('.ytp-hover-progress'); if (progressContainer) { // Aplicar variables CSS para el degradado progressContainer.style.setProperty('--ytp-progress-color', progressColor, 'important'); progressContainer.style.setProperty('--ytp-progress-percent', `${percent}%`, 'important'); // Aplicar estilos directamente a la barra de progreso if (playProgress) { playProgress.style.backgroundColor = progressColor; playProgress.style.setProperty('background', progressColor, 'important'); } if (hoverProgress) { hoverProgress.style.backgroundColor = progressColor; hoverProgress.style.setProperty('background', progressColor, 'important'); } } } } catch (error) { // Silenciar errores para no afectar el funcionamiento principal } } // Inyecta CSS personalizado para la barra de progreso de YouTube (regular y shorts) function injectProgressBarCSS() { // Verificar si la funcionalidad está deshabilitada en la configuración if (!cachedSettings.enableProgressBarGradient) { log('injectProgressBarCSS', 'Degradado de barra de progreso deshabilitado en configuración'); return; } // Verificar si ya existe el estilo para evitar duplicados if (document.getElementById('ypp-progress-bar-styles')) { log('injectProgressBarCSS', 'CSS ya existe, omitiendo inyección'); return; } const css = ` /* Barra de progreso personalizada con degradado de colores - Videos regulares */ .ytp-progress-bar { --ytp-progress-color: #ff4533; --ytp-progress-percent: 0%; } .ytp-play-progress { background: var(--ytp-progress-color) !important; transition: background 0.3s ease !important; } .ytp-hover-progress { background: var(--ytp-progress-color) !important; transition: background 0.3s ease !important; } .ytp-progress-bar-container { background: linear-gradient(to right, var(--ytp-progress-color) 0%, var(--ytp-progress-color) var(--ytp-progress-percent), rgba(255, 255, 255, 0.2) var(--ytp-progress-percent), rgba(255, 255, 255, 0.2) 100%) !important; background-size: 100% 100% !important; transition: background 0.3s ease !important; } .ytp-load-progress { background: rgba(255, 255, 255, 0.3) !important; } /* Shorts - barra de progreso específica con estructura correcta */ .desktopShortsPlayerControlsHost .ytPlayerProgressBarHost, .ytPlayerProgressBarHost { --ytp-progress-color: #ff4533; --ytp-progress-percent: 0%; } /* Barra de progreso principal de shorts */ .ytProgressBarLineProgressBarPlayed { background: var(--ytp-progress-color) !important; transition: background 0.3s ease !important; } /* Barra de hover en shorts */ .ytProgressBarLineProgressBarHovered { background: var(--ytp-progress-color) !important; transition: background 0.3s ease !important; } /* Contenedor principal de la barra de shorts */ .ytProgressBarLineProgressBarLine { background: linear-gradient(to right, var(--ytp-progress-color) 0%, var(--ytp-progress-color) var(--ytp-progress-percent), rgba(255, 255, 255, 0.2) var(--ytp-progress-percent), rgba(255, 255, 255, 0.2) 100%) !important; background-size: 100% 100% !important; transition: background 0.3s ease !important; } /* Fondo de carga en shorts */ .ytProgressBarLineProgressBarLoaded { background: rgba(255, 255, 255, 0.3) !important; } /* Punto del seek (playhead) en shorts */ .ytProgressBarPlayheadProgressBarPlayheadDot { background: var(--ytp-progress-color) !important; transition: background 0.3s ease !important; } /* Asegurar que los estilos se apliquen sobre los de YouTube */ .ytp-progress-bar .ytp-play-progress, .ytp-chrome-controls .ytp-progress-bar .ytp-play-progress { background: var(--ytp-progress-color) !important; } .ytp-progress-bar .ytp-hover-progress, .ytp-chrome-controls .ytp-progress-bar .ytp-hover-progress { background: var(--ytp-progress-color) !important; } /* Para el punto del seek (thumb) - regular */ .ytp-scrubber-container .ytp-scrubber { background: var(--ytp-progress-color) !important; } .ytp-scrubber-button { background: var(--ytp-progress-color) !important; } /* Soporte para el rediseño Moderno (Delhi) */ .ytp-delhi-modern .ytp-time-wrapper:not(.ytp-miniplayer-ui *) { min-width: 0; position: relative; display: flex !important; height: var(--yt-delhi-pill-height, 48px); border-radius: 28px; padding: 0 16px; -webkit-backdrop-filter: var(--yt-frosted-glass-backdrop-filter-override, blur(16px)); backdrop-filter: var(--yt-frosted-glass-backdrop-filter-override, blur(16px)); background: var(--yt-spec-overlay-background-medium-light, rgba(0,0,0,.3)); text-shadow: 0 0 2px #000; align-items: center; gap: 8px; cursor: default; /* No interceptar clicks que no son nuestros */ pointer-events: auto; } /* Corregir orden en el rediseño Delhi: el botón del script debe ir al final */ .ytp-delhi-modern .ytp-time-wrapper .ytp-time-current, .ytp-delhi-modern .ytp-time-wrapper .ytp-time-separator, .ytp-delhi-modern .ytp-time-wrapper .ytp-time-duration { order: 1; /* El tiempo debe estar visible para que YouTube calcule bien los offsets de click */ display: inline-block !important; } .ytp-delhi-modern .ytp-time-wrapper .ytp-live-badge, .ytp-delhi-modern .ytp-time-wrapper .live-badge { order: 2 !important; margin-left: 4px; /* Asegurar que el badge sea clickeable */ pointer-events: auto !important; cursor: pointer !important; } /* puede descuadrar "punto rojo" de boton en vivo */ /* .ytp-delhi-modern .ytp-time-wrapper .ytp-live-badge.ytp-live-badge-is-livehead, .ytp-delhi-modern .ytp-time-wrapper .live-badge.ytp-live-badge-is-livehead { display: flex !important; } */ /* Livestream real (clase agregada por el script) */ .ypp-is-livestream .ytp-time-contents, .ypp-is-livestream .ytp-time-current, .ypp-is-livestream .ytp-time-separator, .ypp-is-livestream .ytp-time-duration { display: none !important; } .ypp-is-livestream .ytp-live-badge.ytp-live-badge-is-livehead { display: inline-flex !important; align-items: center !important; } .ypp-is-livestream .ytp-live-badge.ytp-live-badge-is-livehead::before { position: relative !important; top: 0 !important; transform: none !important; } .ypp-is-livestream .ytp-live-badge.ytp-live-badge-is-livehead[disabled]::before { display: inline-block !important; vertical-align: middle !important; top: -1px !important; } .ypp-is-livestream .ypp-time-display { max-height: 24px !important; line-height: normal !important; padding: 2px 8px !important; border-radius: 12px !important; overflow: visible !important; white-space: nowrap !important; } .ytp-delhi-modern .ytp-time-wrapper .ypp-time-display { order: 3 !important; margin-left: 4px !important; white-space: nowrap; pointer-events: auto; align-self: center !important; height: auto !important; min-height: 0 !important; max-height: 24px !important; line-height: 20px !important; padding: 2px 8px !important; border-radius: 999px !important; box-sizing: border-box !important; display: inline-flex !important; align-items: center !important; overflow: hidden !important; } /* Estilos generales para el botón del script en la barra */ .ypp-time-display { cursor: pointer; font-weight: bold; color: #fff; padding: 2px 6px; border-radius: 4px; background: rgba(255, 255, 255, 0.1); transition: background 0.2s; display: inline-flex; align-items: center; gap: 4px; } .ypp-time-display:hover { background: rgba(255, 255, 255, 0.2); } `; try { // Crear y añadir el estilo const style = document.createElement('style'); style.id = 'ypp-progress-bar-styles'; style.textContent = css; document.head.appendChild(style); log('injectProgressBarCSS', 'CSS inyectado para barra de progreso (regular y shorts)'); } catch (error) { conError('injectProgressBarCSS', 'Error al inyectar CSS:', error); } } /** * Calcula el color del progreso basado en el porcentaje y el tema (rojo -> naranja -> verde) * @param {number} percent - Porcentaje de progreso (0-100) * @returns {string} Color en formato hexadecimal */ function getProgressColor(percent) { if (percent === null || percent === undefined) return '#666666'; // Detectar si estamos en tema claro const isLightTheme = !isYouTubeDarkTheme(); // Rangos de color para tema oscuro (colores oscuros para mejor contraste en playlists): // 0-33%: Rojo oscuro (#dd4444 -> #ff8844) // 34-66%: Naranja oscuro (#ff8844 -> #ffcc44) // 67-100%: Verde oscuro (#ffcc44 -> #00cc00) // Rangos de color para tema claro (colores oscuros con mejor contraste): // 0-33%: Rojo oscuro (#cc0000 -> #dd6600) // 34-66%: Naranja oscuro (#dd6600 -> #cc9900) // 67-100%: Verde oscuro (#cc9900 -> #008800) let color; if (isLightTheme) { // Colores para tema claro (más oscuros para mejor contraste) if (percent <= 33) { // Rojo oscuro a naranja oscuro const ratio = percent / 33; const r = Math.round(204 - (204 - 221) * ratio); // 204 -> 221 const g = Math.round(0 + (102 - 0) * ratio); // 0 -> 102 const b = 0; color = `rgb(${r}, ${g}, ${b})`; } else if (percent <= 66) { // Naranja oscuro a amarillo oscuro const ratio = (percent - 33) / 33; const r = Math.round(221 - (221 - 204) * ratio); // 221 -> 204 const g = Math.round(102 + (153 - 102) * ratio); // 102 -> 153 const b = 0; color = `rgb(${r}, ${g}, ${b})`; } else if (percent <= 95) { // Amarillo oscuro a verde oscuro const ratio = (percent - 66) / 29; const r = Math.round(204 - (204 - 0) * ratio); // 204 -> 0 const g = Math.round(153 + (136 - 153) * ratio); // 153 -> 136 const b = 0; color = `rgb(${r}, ${g}, ${b})`; } else { // Verde oscuro (casi completado o completado) color = '#008800'; } } else { // Colores mejorados para tema oscuro (más oscuros para contraste en playlists) if (percent <= 33) { // Rojo oscuro a naranja const ratio = percent / 33; const r = Math.round(221 - (221 - 255) * ratio); // 221 -> 255 const g = Math.round(68 + (136 - 68) * ratio); // 68 -> 136 const b = 68; color = `rgb(${r}, ${g}, ${b})`; } else if (percent <= 66) { // Naranja a amarillo const ratio = (percent - 33) / 33; const r = 255; const g = Math.round(136 + (204 - 136) * ratio); // 136 -> 204 const b = Math.round(68 + (68 - 68) * ratio); // 68 -> 68 color = `rgb(${r}, ${g}, ${b})`; } else if (percent <= 95) { // Amarillo a verde oscuro const ratio = (percent - 66) / 29; const r = Math.round(255 - (255 - 0) * ratio); // 255 -> 0 const g = Math.round(204 + (204 - 204) * ratio); // 204 -> 204 const b = Math.round(68 + (68 - 68) * ratio); // 68 -> 68 color = `rgb(${r}, ${g}, ${b})`; } else { // Verde oscuro (casi completado o completado) - mejor contraste en fondos celestes color = '#00cc00'; } } return color; } // ------------------------------------------ // MARK: 💾 Storage + Settings // ------------------------------------------ /** * Objeto Storage para gestionar el almacenamiento local del navegador. * Proporciona métodos para guardar, obtener y eliminar datos, * así como para listar claves almacenadas con un prefijo específico. */ // Metaclaves para control de migración y configuración const STORAGE_META_KEYS = new Set([ 'INDEX_v1', '_test_', 'idb_migrated' ]); const STORAGE_MIGRATION_STATE_KEY = `${CONFIG.storagePrefix} idb_migrated`; const storageCache = new Map(); // Nueva capa asíncrona de almacenamiento (IndexedDB primario + caché en memoria + fallback) const StorageAsync = (() => { const logger = { info: (...args) => { try { log('StorageAsync', ...args); } catch (_) { console.info('[StorageAsync]', ...args); } }, warn: (...args) => { try { warn('StorageAsync', ...args); } catch (_) { console.warn('[StorageAsync]', ...args); } }, error: (...args) => { try { conError('StorageAsync', ...args); } catch (_) { console.error('[StorageAsync]', ...args); } } }; // Estado de inicialización let isReady = false; let initError = null; let readyPromise = null; /** * Inicializa la capa asíncrona: detecta IndexedDB, migra datos si es necesario y llena caché. */ /** * Normaliza una clave removiendo el prefijo del script si lo tiene. * Las claves en IndexedDB se almacenan SIN prefijo para coherencia * con el nuevo sistema Storage que no agrega prefijos. * @param {string} key - Clave original (puede tener prefijo o no) * @returns {string} Clave sin prefijo */ function stripStoragePrefix(key) { if (typeof key === 'string' && key.startsWith(CONFIG.storagePrefix)) { return key.slice(CONFIG.storagePrefix.length); } return key; } /** * Determina si una clave pertenece al script (tiene prefijo o es una clave interna conocida). * Evita migrar claves ajenas de localStorage o GM que no pertenecen al script. * @param {string} key - Clave a verificar * @returns {boolean} true si la clave pertenece al script */ function isScriptKey(key) { if (typeof key !== 'string') return false; // Claves con prefijo del script if (key.startsWith(CONFIG.storagePrefix)) return true; // Claves internas de migración/normalización if (key.startsWith('ypp_')) return true; return false; } /** * Inicializa la capa asíncrona: detecta IndexedDB, migra datos si es necesario y llena caché. * Solo migra claves con prefijo YT_PLAYBACK_PLOX_ y las almacena en IndexedDB sin prefijo. */ async function initialize() { if (readyPromise) return readyPromise; readyPromise = (async () => { try { logger.info('Iniciando StorageAsync...'); // Detectar si ya se migró const migrated = localStorage.getItem(STORAGE_MIGRATION_STATE_KEY) === '1'; let legacySnapshot = []; if (!migrated && IndexedDBAdapter.isSupported) { // Recolectar snapshot de localStorage/GM filtrando solo claves del script let allKeys = []; try { if (typeof GM_listValues !== 'undefined') { const gmRaw = await GM_listValues(); allKeys = Array.isArray(gmRaw) ? gmRaw : []; } else if (typeof localStorage !== 'undefined') { allKeys = Object.keys(localStorage); } } catch (err) { logger.warn('Error al obtener claves para migración:', err); } // Filtrar: solo claves del script, excluyendo metaclaves const filteredKeys = (allKeys || []).filter(k => isScriptKey(k) && !STORAGE_META_KEYS.has(k)); logger.info(`Migración: ${filteredKeys.length} claves del script encontradas(de ${allKeys.length} totales)`); for (const rawKey of filteredKeys) { // Strip prefix para coherencia con nuevo Storage sin prefijos const normalizedKey = stripStoragePrefix(rawKey); let value = null; try { if (typeof GM_getValue !== 'undefined') { value = await GM_getValue(rawKey); } else if (typeof localStorage !== 'undefined') { const item = localStorage.getItem(rawKey); if (item) value = JSON.parse(item); } } catch (err) { logger.warn(`Error al leer clave ${rawKey}: `, err); } if (value !== null) { legacySnapshot.push({ key: normalizedKey, value: JSON.stringify(value) }); } } } // Bootstrap IndexedDB (migrará si es necesario) const result = await IndexedDBAdapter.bootstrap(legacySnapshot); if (result.source === 'legacy') { localStorage.setItem(STORAGE_MIGRATION_STATE_KEY, '1'); logger.info(`Migración completada: ${result.entries.length} entradas migradas a IndexedDB`); } // Poblar caché en memoria desde IndexedDB for (const entry of result.entries) { storageCache.set(entry.key, entry.value); } isReady = true; logger.info('StorageAsync listo. Backend:', IndexedDBAdapter.isSupported ? 'IndexedDB' : 'fallback'); } catch (err) { initError = err; logger.error('Falló inicialización de StorageAsync:', err); // En caso de error, mantener caché vacía y delegar a API sincrónica existente } })(); return readyPromise; } /** * Obtiene un valor desde caché (síncrono) o desde IndexedDB (asíncrono). */ async function get(key) { await initialize(); if (storageCache.has(key)) { try { return JSON.parse(storageCache.get(key)); } catch (_) { return null; } } // Si no está en caché y IndexedDB disponible, buscarlo if (IndexedDBAdapter.isSupported) { try { const raw = await new Promise((resolve, reject) => { IndexedDBAdapter.runInStore('readonly', (store) => store.get(key)).then((req) => resolve(req?.result?.value)).catch(reject); }); if (raw !== undefined) { storageCache.set(key, raw); return JSON.parse(raw); } } catch (err) { logger.warn(`Error al leer ${key} desde IndexedDB: `, err); } } return null; } /** * Guarda un valor en IndexedDB y actualiza caché. */ async function set(key, value) { await initialize(); const serialized = JSON.stringify(value); storageCache.set(key, serialized); if (IndexedDBAdapter.isSupported) { try { await IndexedDBAdapter.put(key, serialized); } catch (err) { logger.warn(`Error al escribir ${key} en IndexedDB, usando fallback: `, err); // Lanzar el error para que el manejador superior lo capture throw err; } } } /** * Elimina una clave de IndexedDB y de la caché. */ async function del(key) { await initialize(); storageCache.delete(key); if (IndexedDBAdapter.isSupported) { try { await IndexedDBAdapter.del(key); } catch (err) { logger.warn(`Error al eliminar ${key} en IndexedDB: `, err); } } } /** * Lista todas las claves desde IndexedDB o caché. */ async function keys() { await initialize(); if (IndexedDBAdapter.isSupported) { try { const entries = await IndexedDBAdapter.getAllEntries(); return entries.map(e => e.key).filter(k => !STORAGE_META_KEYS.has(k)); } catch (err) { logger.warn('Error al listar claves desde IndexedDB, usando caché:', err); } } // Fallback a caché en memoria return Array.from(storageCache.keys()).filter(k => !STORAGE_META_KEYS.has(k)); } /** * Devuelve el estado actual del backend. */ function getBackendInfo() { return { ready: isReady, error: initError, indexedDBSupported: IndexedDBAdapter.isSupported, cacheSize: storageCache.size, migrated: localStorage.getItem(STORAGE_MIGRATION_STATE_KEY) === '1' }; } return { initialize, get, set, del, keys, getBackendInfo }; })(); const IndexedDBAdapter = (() => { const DB_NAME = 'YTPlaybackPloxDB'; const STORE_NAME = 'savedVideos'; const DB_VERSION = 1; const isSupported = (() => { try { return typeof indexedDB !== 'undefined'; } catch (_) { return false; } })(); let dbPromise = null; let operationQueue = Promise.resolve(); const idbLogger = { info: (...args) => { try { log('IndexedDB', ...args); } catch (_) { console.info('[IndexedDB]', ...args); } }, warn: (...args) => { try { warn('IndexedDB', ...args); } catch (_) { console.warn('[IndexedDB]', ...args); } }, error: (...args) => { try { conError('IndexedDB', ...args); } catch (_) { console.error('[IndexedDB]', ...args); } } }; function openDatabase() { if (dbPromise) return dbPromise; if (!isSupported) return Promise.reject(new Error('IndexedDB no soportado')); dbPromise = new Promise((resolve, reject) => { try { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: 'key' }); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); request.onblocked = () => idbLogger.warn('Inicialización bloqueada esperando pestañas previas'); } catch (error) { reject(error); } }); return dbPromise; } /** * Ejecuta una operación en el object store de IndexedDB. * Captura el resultado del IDBRequest vía onsuccess (no tx.oncomplete) * para compatibilidad con Chrome/Edge (Blink limpia IDBRequest.result * después de oncomplete, a diferencia de Firefox/Gecko). * @param {'readonly'|'readwrite'} mode - Modo de la transacción * @param {(store: IDBObjectStore) => IDBRequest} executor - Función que ejecuta la operación * @returns {Promise} Resultado de la operación */ function runInStore(mode, executor) { return openDatabase().then((db) => { return new Promise((resolve, reject) => { try { const tx = db.transaction(STORE_NAME, mode); const store = tx.objectStore(STORE_NAME); const request = executor(store); // Capturar resultado en onsuccess del IDBRequest, // donde .result está garantizado en todos los navegadores let capturedResult; if (request && typeof request.addEventListener === 'function') { request.onsuccess = () => { capturedResult = request.result; }; } tx.oncomplete = () => resolve(capturedResult); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); } catch (error) { reject(error); } }); }); } function enqueue(operation) { operationQueue = operationQueue .then(() => operation().catch((error) => { idbLogger.error('Operación fallida', error); })) .catch((error) => idbLogger.error('Error en cola IndexedDB', error)); return operationQueue; } function sanitizeEntries(rawEntries) { if (!Array.isArray(rawEntries)) return []; return rawEntries .map((entry) => ({ key: entry?.key, value: typeof entry?.value === 'string' ? entry.value : null, updatedAt: Number.isFinite(entry?.updatedAt) ? entry.updatedAt : Date.now() })) .filter((entry) => typeof entry.key === 'string' && typeof entry.value === 'string'); } function getAllEntries() { return runInStore('readonly', (store) => store.getAll()).then(sanitizeEntries); } function putEntry(key, value) { return runInStore('readwrite', (store) => store.put({ key, value, updatedAt: Date.now() })); } function deleteEntry(key) { return runInStore('readwrite', (store) => store.delete(key)); } function bulkPut(entries = []) { if (!entries.length) return Promise.resolve(); return runInStore('readwrite', (store) => { let lastRequest = null; entries.forEach(({ key, value }) => { lastRequest = store.put({ key, value, updatedAt: Date.now() }); }); return lastRequest; }); } async function bootstrap(legacySnapshot = []) { if (!isSupported) return { entries: [], source: 'unsupported' }; const existingEntries = await getAllEntries(); if (existingEntries.length > 0) { idbLogger.info(`Recuperando ${existingEntries.length} entradas desde IndexedDB`); return { entries: existingEntries, source: 'idb' }; } if (legacySnapshot.length > 0) { idbLogger.info(`Migrando ${legacySnapshot.length} entradas legadas a IndexedDB`); await bulkPut(legacySnapshot); return { entries: legacySnapshot, source: 'legacy' }; } return { entries: [], source: 'empty' }; } return { isSupported, bootstrap, put: (key, value) => { if (!isSupported) return Promise.resolve(); return enqueue(() => putEntry(key, value)); }, del: (key) => { if (!isSupported) return Promise.resolve(); return enqueue(() => deleteEntry(key)); }, getAllEntries, runInStore }; })(); const Storage = { /** * Guarda un valor en el backend disponible (ahora delega a StorageAsync). */ async set(key, value) { // TEST: Forzar storage_full para testear alerta // return { success: false, reason: 'storage_full', error: new Error('QuotaExceededError') }; try { await StorageAsync.set(key, value); } catch (err) { conError('Storage', `Storage.set: Error al guardar la clave "${key}"`, err); // Detectar errores de cuota y devolver resultado específico if (err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED') { return { success: false, reason: 'storage_full', error: err }; } return { success: false, reason: 'storage_error', error: err }; } return { success: true }; }, /** * Obtiene un valor del almacenamiento (ahora delega a StorageAsync). */ async get(key) { try { return await StorageAsync.get(key); } catch (err) { conError('Storage', `Storage.get: Error al obtener la clave "${key}"`, err); return null; } }, /** * Elimina un valor (ahora delega a StorageAsync). */ async del(key) { try { await StorageAsync.del(key); } catch (err) { conError('Storage', `Storage.del: Error al eliminar la clave "${key}"`, err); } }, /** * Lista claves (ahora delega a StorageAsync). */ async keys() { try { return await StorageAsync.keys(); } catch (err) { conError('Storage', 'Storage.keys: Error al listar claves', err); return []; } }, /** * Diagnóstico del backend actual. */ getBackendInfo() { return StorageAsync.getBackendInfo(); } }; /** * Objeto Settings para gestionar la configuración del usuario. * Proporciona métodos asíncronos para obtener y establecer * la configuración del usuario utilizando GM_getValue y GM_setValue. */ const Settings = { /** * Obtiene la configuración del usuario. * @returns {Promise} Una promesa que resuelve un objeto con * los ajustes del usuario, combinando los ajustes por defecto * con los ajustes almacenados. */ async get() { try { const raw = await GM_getValue(CONFIG.userSettingsKey, null); // const parsed = raw ? JSON.parse(raw) : {}; /** @type {Record} **/ let parsed = {}; if (raw && typeof raw === 'object') { // Algunos managers o migraciones pueden guardar/retornar un objeto directamente. parsed = raw; } else if (typeof raw === 'string' && raw.trim()) { parsed = JSON.parse(raw); } return { ...CONFIG.defaultSettings, ...parsed }; } catch (error) { conError('Settings', 'Error al cargar configuración del usuario:', error); return { ...CONFIG.defaultSettings }; } }, /** * Obtiene la configuración del usuario e incluye metadatos sobre qué claves estaban presentes * en el storage (antes de mezclar defaults). * usar idioma del navegador solo si el usuario nunca eligió idioma. * @returns {Promise<{settings: Object, hadLanguageInStorage: boolean}>} */ async getWithMeta() { try { const raw = await GM_getValue(CONFIG.userSettingsKey, null); /** @type {Record} */ let parsed = {}; if (raw && typeof raw === 'object') { parsed = raw; } else if (typeof raw === 'string' && raw.trim()) { parsed = JSON.parse(raw); } return { settings: { ...CONFIG.defaultSettings, ...parsed }, hadLanguageInStorage: Object.prototype.hasOwnProperty.call(parsed || {}, 'language') }; } catch (error) { conError('Settings', 'Error al cargar configuración del usuario (meta):', error); return { settings: { ...CONFIG.defaultSettings }, hadLanguageInStorage: false }; } }, /** * Establece la configuración del usuario. * @param {Object} settings - Un objeto que contiene los nuevos ajustes del usuario. * @returns {Promise} Una promesa que resuelve cuando la configuración es guardada. */ async set(settings) { try { const serialized = JSON.stringify(settings); await GM_setValue(CONFIG.userSettingsKey, serialized); } catch (error) { conError('Settings', 'Error al guardar configuración del usuario:', error); } } }; // ------------------------------------------ // MARK: 📊 Variables Globales // ------------------------------------------ // Variables para controlar el estado de inicialización let YTHelper = null; // YouTube Helper API, Redeclarada en waitForHelper let isNavigating = false; // Variable global para registrar navegación let isResuming = false; // Variable global para registrar reanudación let navigationDebounceTimeout = null; // Timeout para evitar múltiples navegaciones let currentPageType = null; // Tipo de página actual (home, watch, playlist, etc.) // Ad Monitor let isAdPlaying = false; // Estado global de anuncios (para compartir entre módulos) let isScriptPaused = false; // Variable global para controlar pausa total del script let lastAdEndTime = 0; // Timestamp de cuando terminó el último anuncio // Estados de anuncios por tipo let isAdShortsPlaying = false; // Anuncio activo en Shorts let isAdWatchPlaying = false; // Anuncio activo en reproductor principal (watch/embed/miniplayer) // Helper para decidir bloqueo por anuncios según tipo const isAdBlockedFor = (type) => { try { const t = (type || '').toLowerCase(); if (t === 'shorts') return !!isAdShortsPlaying || !!isScriptPaused; // watch/embed/home/miniplayer/preview return !!isAdWatchPlaying || !!isScriptPaused; } catch (_) { return !!isAdPlaying || !!isScriptPaused; } }; /** * Obtiene el id del contenedor de reproductor asociado a un elemento (movie_player o shorts-player). * @param {HTMLElement|null|undefined} el Elemento de video o nodo dentro del contenedor. * @returns {'movie_player'|'shorts-player'|null} Id del contenedor o null. */ const getPlayerContainerId = (el) => { try { const cont = el?.closest?.('#movie_player, #shorts-player'); const id = cont?.id || null; return (id === 'movie_player' || id === 'shorts-player') ? id : null; } catch (_) { return null; } }; /** * Contexto por contenedor para evitar contaminación de estados cuando coexisten players (miniplayer + shorts). * @returns {ReturnType} */ const createPlayerContextManager = () => { /** @type {Map<'movie_player'|'shorts-player', any>} */ const contexts = new Map(); /** * Crea o devuelve un contexto por contenedor. * @param {'movie_player'|'shorts-player'} containerId */ const get = (containerId) => { if (contexts.has(containerId)) return contexts.get(containerId); const ctx = { containerId, videoEl: null, player: null, videoId: null, logicalType: null, playlistId: null, timeupdateHandler: null, timeupdateRebindIntervalId: null, progressPollIntervalId: null, secondaryProgressPollIntervalId: null, lastTimeUpdateTick: 0, lastSaveTime: 0, lastSaveTimesByVideoId: Object.create(null), isResuming: false, lastResumeId: null, }; contexts.set(containerId, ctx); return ctx; }; /** * Devuelve un contexto asociado a un elemento; fallback a movie_player si no se puede detectar. * @param {HTMLElement|null|undefined} el */ const forElement = (el) => { const id = getPlayerContainerId(el) || 'movie_player'; return get(id); }; /** * Limpia timers/listeners de un contexto. * @param {'movie_player'|'shorts-player'} containerId * @param {{preserveVideoEl?: boolean}} [opts] */ const cleanup = (containerId, opts = {}) => { const ctx = contexts.get(containerId); if (!ctx) return; try { if (ctx.timeupdateRebindIntervalId) clearInterval(ctx.timeupdateRebindIntervalId); } catch (_) { } try { if (ctx.progressPollIntervalId) clearInterval(ctx.progressPollIntervalId); } catch (_) { } try { if (ctx.secondaryProgressPollIntervalId) clearInterval(ctx.secondaryProgressPollIntervalId); } catch (_) { } ctx.timeupdateRebindIntervalId = null; ctx.progressPollIntervalId = null; ctx.secondaryProgressPollIntervalId = null; ctx.lastTimeUpdateTick = 0; ctx.lastSaveTime = 0; ctx.videoId = null; ctx.logicalType = null; ctx.playlistId = null; ctx.isResuming = false; ctx.lastResumeId = null; if (!opts.preserveVideoEl) { try { if (ctx.videoEl && ctx.timeupdateHandler) ctx.videoEl.removeEventListener('timeupdate', ctx.timeupdateHandler); } catch (_) { } ctx.videoEl = null; ctx.timeupdateHandler = null; } }; /** * Limpia todos los contextos. * @param {{preserveMoviePlayer?: boolean}} [opts] */ const cleanupAll = (opts = {}) => { for (const [id] of contexts) { cleanup(id, { preserveVideoEl: opts.preserveMoviePlayer && id === 'movie_player' }); } }; /** * Obtiene el contexto correcto para un video específico considerando coexistencia. * @param {string} videoId - ID del video * @returns {Object|null} Contexto del player apropiado o null si no se encuentra */ const getContextForVideo = (videoId) => { if (!videoId) return null; const playerInfo = PlayerStateManager.getPrimaryPlayerForVideo(videoId); if (!playerInfo.containerId) return null; return get(playerInfo.containerId); }; /** * Verifica si hay coexistencia de players y cuál es el estado actual. * @returns {Object} Información sobre el estado de coexistencia */ const getCoexistenceState = () => { const playerState = PlayerStateManager.detectActivePlayers(); const activeContexts = []; // Verificar qué contextos están realmente activos for (const [containerId, ctx] of contexts) { if (ctx.videoId) { const playerInfo = PlayerStateManager.getPrimaryPlayerForVideo(ctx.videoId, playerState); activeContexts.push({ containerId, videoId: ctx.videoId, logicalType: ctx.logicalType, isPrimary: playerInfo.containerId === containerId, playerType: playerInfo.playerType }); } } return { playerState, activeContexts, hasCoexistence: playerState.hasMiniPlayer && (playerState.hasShortsPlayer || playerState.hasWatchPlayer), coexistenceInfo: PlayerStateManager.getCoexistenceInfo() }; }; /** * Fuerza la actualización del tipo lógico de un contexto basado en el estado actual. * @param {'movie_player'|'shorts-player'} containerId */ const updateLogicalType = (containerId) => { const ctx = contexts.get(containerId); if (!ctx || !ctx.videoId) return; const playerInfo = PlayerStateManager.getPrimaryPlayerForVideo(ctx.videoId); ctx.logicalType = playerInfo.playerType; }; return { get, forElement, cleanup, cleanupAll, contexts, getContextForVideo, getCoexistenceState, updateLogicalType }; }; const PlayerContexts = createPlayerContextManager(); // MARK: 🎯 Sistema Centralizado de Estados de Player /** * Sistema centralizado para detectar y manejar estados de múltiples players simultáneos. * Reduce redundancia y mejora el manejo de miniplayer + shorts coexistentes. */ const createPlayerStateManager = () => { /** * Tipos de player detectados en la página * @typedef {Object} PlayerTypes * @property {boolean} hasMiniPlayer - Hay un miniplayer activo * @property {boolean} hasShortsPlayer - Hay un reproductor de shorts activo * @property {boolean} hasWatchPlayer - Hay un reproductor de watch/embed activo * @property {string|null} miniPlayerVideoId - ID del video en miniplayer * @property {string|null} activeShortsVideoId - ID del video activo en shorts * @property {string|null} activeWatchVideoId - ID del video activo en watch */ /** * Detecta todos los players activos en la página actual. * @returns {PlayerTypes} Estado completo de los players detectados */ const detectActivePlayers = () => { const result = { hasMiniPlayer: false, hasShortsPlayer: false, hasWatchPlayer: false, miniPlayerVideoId: null, activeShortsVideoId: null, activeWatchVideoId: null }; const pageType = getYouTubePageType(); try { // Detectar miniplayer - SOLO cuando esté realmente minimizado const miniPlayerEl = document.querySelector('.ytp-miniplayer-ui, #miniplayer, ytd-miniplayer[miniplayer-active]'); if (miniPlayerEl) { const moviePlayer = document.querySelector('#movie_player'); if (moviePlayer) { const videoData = moviePlayer.getVideoData?.(); if (videoData?.video_id) { result.hasMiniPlayer = true; result.miniPlayerVideoId = videoData.video_id; } } } // Detectar shorts player const shortsPlayer = document.querySelector('#shorts-player, ytd-shorts video.html5-main-video'); if (shortsPlayer) { // Intentar obtener el video ID del shorts activo const activeShort = document.querySelector('ytd-reel-video-renderer[is-active]'); if (activeShort) { const videoId = activeShort.getAttribute('video-id') || activeShort.querySelector('[video-id]')?.getAttribute('video-id'); if (videoId) { result.hasShortsPlayer = true; result.activeShortsVideoId = videoId; } } } // Detectar watch player principal (solo en páginas watch/embed) if (pageType === 'watch' || pageType === 'embed') { const moviePlayer = document.querySelector('#movie_player'); if (moviePlayer && !result.hasMiniPlayer) { // No contar como watch si ya es miniplayer const videoData = moviePlayer.getVideoData?.(); if (videoData?.video_id) { result.hasWatchPlayer = true; result.activeWatchVideoId = videoData.video_id; } } } } catch (error) { log('PlayerStateManager', 'Error detectando players activos:', error); } return result; }; /** * Determina cuál es el player principal según el contexto y configuración. * @param {string} videoId - ID del video a evaluar * @param {PlayerTypes} [playerState] - Estado de players (se detecta si no se proporciona) * @returns {Object} Información del player principal */ const getPrimaryPlayerForVideo = (videoId, playerState = null) => { if (!playerState) { playerState = detectActivePlayers(); } const result = { playerType: 'unknown', // 'miniplayer', 'shorts', 'watch', 'unknown' isCoexisting: false, // Si hay múltiples players activos containerId: null, // ID del contenedor para PlayerContexts playerElement: null, // Elemento del player pageType: getYouTubePageType() // Tipo de página actual }; // Contar players activos const activePlayers = [playerState.hasMiniPlayer, playerState.hasShortsPlayer, playerState.hasWatchPlayer] .filter(Boolean).length; result.isCoexisting = activePlayers > 1; // Lógica de prioridad según videoId y contexto de página if (playerState.hasMiniPlayer && playerState.miniPlayerVideoId === videoId) { result.playerType = 'miniplayer'; result.containerId = 'movie_player'; result.playerElement = document.querySelector('#movie_player'); } else if (playerState.hasShortsPlayer && playerState.activeShortsVideoId === videoId) { result.playerType = 'shorts'; result.containerId = 'shorts-player'; result.playerElement = document.querySelector('#shorts-player, ytd-shorts'); } else if (playerState.hasWatchPlayer && playerState.activeWatchVideoId === videoId) { result.playerType = 'watch'; result.containerId = 'movie_player'; result.playerElement = document.querySelector('#movie_player'); } else { // Fallback basado en tipo de página if (result.pageType === 'watch' || result.pageType === 'embed') { result.playerType = 'watch'; result.containerId = 'movie_player'; result.playerElement = document.querySelector('#movie_player'); } else if (result.pageType === 'shorts') { result.playerType = 'shorts'; result.containerId = 'shorts-player'; result.playerElement = document.querySelector('#shorts-player, ytd-shorts'); } } return result; }; /** * Verifica si un video específico está siendo reproducido por el miniplayer. * @param {string} videoId - ID del video a verificar * @returns {boolean} true si el miniplayer está reproduciendo este video */ const isVideoInMiniPlayer = (videoId) => { if (!videoId) return false; try { const moviePlayer = document.querySelector('#movie_player'); const videoData = moviePlayer?.getVideoData?.(); return videoData?.video_id === videoId; } catch (error) { return false; } }; /** * Obtiene información de coexistencia para logging/debugging. * @returns {string} Descripción del estado actual de players */ const getCoexistenceInfo = () => { const state = detectActivePlayers(); const activeTypes = []; if (state.hasMiniPlayer) activeTypes.push(`Mini(${state.miniPlayerVideoId?.slice(0, 8)}...)`); if (state.hasShortsPlayer) activeTypes.push(`Shorts(${state.activeShortsVideoId?.slice(0, 8)}...)`); if (state.hasWatchPlayer) activeTypes.push(`Watch(${state.activeWatchVideoId?.slice(0, 8)}...)`); return activeTypes.length > 0 ? activeTypes.join(' + ') : 'Ninguno'; }; return { detectActivePlayers, getPrimaryPlayerForVideo, isVideoInMiniPlayer, getCoexistenceInfo }; }; const PlayerStateManager = createPlayerStateManager(); // MARK: 📊 Caché de Estado de Players /** * Sistema de caché para estado de players - análisis fuera de funciones */ const PlayerStateCache = (() => { let cachedState = null; let lastUpdate = 0; const CACHE_DURATION = 1000; // 1 segundo /** * Obtiene el estado actual de players, usando caché si está fresco * @returns {PlayerTypes} Estado actual de players */ const getCurrentState = () => { const now = Date.now(); if (!cachedState || (now - lastUpdate) > CACHE_DURATION) { cachedState = PlayerStateManager.detectActivePlayers(); lastUpdate = now; // Logging detallado del estado detectado const coexistenceInfo = PlayerStateManager.getCoexistenceInfo(); log('PlayerStateCache', `🔍 Estado actualizado: ${coexistenceInfo} `); if (cachedState.hasMiniPlayer) { log('PlayerStateCache', `📱 Miniplayer detectado: ${cachedState.miniPlayerVideoId} `); } if (cachedState.hasShortsPlayer) { log('PlayerStateCache', `📹 Shorts detectado: ${cachedState.activeShortsVideoId} `); } if (cachedState.hasWatchPlayer) { log('PlayerStateCache', `📺 Watch detectado: ${cachedState.activeWatchVideoId} `); } } return cachedState; }; /** * Fuerza la actualización del caché */ const invalidate = () => { cachedState = null; lastUpdate = 0; }; /** * Obtiene información del player primario para un video específico * @param {string} videoId - ID del video * @returns {Object} Información del player primario */ const getPrimaryPlayer = (videoId) => { return PlayerStateManager.getPrimaryPlayerForVideo(videoId, getCurrentState()); }; return { getCurrentState, invalidate, getPrimaryPlayer }; })(); // ------------------------------------------ // MARK: 🔧 Utils // ------------------------------------------ // MARK: 🔧 Formateo de Tiempo /** * Formatea un valor de tiempo (en segundos o string) a un string en formato "MM:SS" o "HH:MM:SS". * * @param {number|string} input - Valor de tiempo a formatear. * @returns {string} - String con el tiempo formateado. * Ejemplos: * formatTime(65) // "01:05" * formatTime("5:30") // "05:30" * formatTime("1:05:30") // "01:05:30" * formatTime("invalid") // "00:00" */ const formatTime = (input) => { let seconds; // Si es un número, lo usa directamente if (typeof input === 'number' && !isNaN(input)) { seconds = input; } // Si es un string, intenta convertirlo else if (typeof input === 'string') { // Maneja formatos como "5:30" o "05:30" if (input.includes(':')) { const parts = input.split(':').map(part => parseInt(part, 10)); // Si es MM:SS if (parts.length === 2) { seconds = parts[0] * 60 + parts[1]; } // Si es HH:MM:SS else if (parts.length === 3) { seconds = parts[0] * 3600 + parts[1] * 60 + parts[2]; } else { conError('Formato de tiempo no válido:', input); return '00:00'; } } // Intenta convertir directamente a número else { seconds = parseFloat(input); } } // Caso por defecto else { conError('Valor de entrada no válido:', input); return '00:00'; } // Validación final if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) { conError('Valor de segundos no válido:', input); return '00:00'; } const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); return hours > 0 ? `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')} ` : `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')} `; }; /** * Parsea un string de tiempo en formato "MM:SS" o "HH:MM:SS" a segundos. * * @param {string} timeStr - String con el tiempo en formato "MM:SS" o "HH:MM:SS". * @returns {number} Número de segundos correspondiente al string. Retorna 0 si el formato es inválido. * * @example * // Formato MM:SS → minutos y segundos * parseTimeToSeconds("5:30"); // → 330 * * @example * // Formato HH:MM:SS → horas, minutos y segundos * parseTimeToSeconds("1:05:30"); // → 3930 * * @example * // Formato inválido → 0 * parseTimeToSeconds("invalid"); // → 0 * * @example * // Cadena vacía o no string → 0 * parseTimeToSeconds(""); // → 0 * parseTimeToSeconds(null); // → 0 */ const parseTimeToSeconds = (timeStr) => { if (typeof timeStr !== 'string' || !timeStr.includes(':')) return 0; const parts = timeStr.split(':').map(Number); // Retorna 0 si algún valor es NaN if (parts.some(isNaN)) return 0; if (parts.length === 2) return parts[0] * 60 + parts[1]; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; return 0; }; /** * Convierte una duración en formato ISO 8601 (PT4M35S) a segundos * @param {string} isoDuration - Duración en formato ISO (ej: "PT4M35S", "PT1H2M3S") * @returns {number} Duración en segundos * @example * parseISODuration("PT4M35S"); // → 275 * parseISODuration("PT1H2M3S"); // → 3723 * parseISODuration("PT30S"); // → 30 * parseISODuration("invalid"); // → 0 */ const parseISODuration = (isoDuration) => { if (typeof isoDuration !== 'string' || !isoDuration.startsWith('PT')) return 0; // Expresión regular para extraer horas, minutos y segundos const regex = /PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/; const matches = isoDuration.match(regex); if (!matches) return 0; const hours = parseInt(matches[1]) || 0; const minutes = parseInt(matches[2]) || 0; const seconds = parseInt(matches[3]) || 0; return hours * 3600 + minutes * 60 + seconds; }; /** * Normaliza un valor de tiempo a segundos. * * @param {number|string} value - Valor de tiempo a normalizar. * Puede ser un número (ya en segundos) * o una cadena en formato "SS", "MM:SS" o "HH:MM:SS". * @returns {number} Número de segundos (0 si el valor es inválido o no existe). * * @example * // Número directo → devuelve el mismo número * normalizeSeconds(65); // → 65 * * @example * // "MM:SS" → minutos y segundos * normalizeSeconds("5:30"); // → 330 * * @example * // "HH:MM:SS" → horas, minutos y segundos * normalizeSeconds("1:05:30"); // → 3930 * * @example * // Sin argumento o null → 0 * normalizeSeconds(); // → 0 * normalizeSeconds(null); // → 0 * * @example * // Valor inválido → 0 * normalizeSeconds("invalid"); // → 0 */ const normalizeSeconds = (value) => { if (!value) return 0; if (typeof value === 'number') return value; if (typeof value === 'string') return parseTimeToSeconds(value.trim()); return 0; }; // MARK: 🔧 SetInnerHTML /** * Asigna HTML de forma segura para compatibilidad con Trusted Types (Chrome) * * @param {HTMLElement} element - Elemento HTML al que se le asignará el HTML. * @param {string} html - HTML a asignar en su innerHTML. */ let _ttPolicy = null; function getTrustedTypesPolicy() { if (_ttPolicy) return _ttPolicy; if (window.trustedTypes && window.trustedTypes.createPolicy) { try { // Intentar crear la política. Si ya existe, esto fallará. _ttPolicy = window.trustedTypes.createPolicy('youtube-playback-plox', { createHTML: (string) => string }); } catch (e) { // Si falla (probablemente porque ya existe), intentar recuperarla si es posible o usar fallback console.warn('TrustedTypes policy creation failed:', e); } } return _ttPolicy; } function setInnerHTML(element, html) { const policy = getTrustedTypesPolicy(); if (policy) { element.innerHTML = policy.createHTML(html); } else { // Fallback para navegadores sin Trusted Types o si falló la creación element.innerHTML = html; } } // MARK: 🔧 Crear Elemento /** * Crea un elemento HTML con varias opciones de configuración. * * @param {string} tag - Nombre del tag HTML a crear, e.g., 'div', 'span'. * @param {Object} [options] - Opciones para configurar el elemento. * @param {string} [options.className] - Clases CSS del elemento. * @param {string} [options.id] - ID del elemento. * @param {string} [options.text] - Texto interno del elemento. * @param {string} [options.html] - HTML interno del elemento (usa setInnerHTML seguro). * @param {Function} [options.onClickEvent] - Función legacy para el evento click. * @param {Object.} [options.events] - Eventos a añadir, e.g., { click: fn, mouseover: fn }. * @param {Object.} [options.atribute] - Atributos HTML a añadir, e.g., { src: 'img.png' }. * @param {Object.} [options.props] - Propiedades del elemento, e.g., { value: '123' }. * @param {Object.} [options.styles] - Estilos CSS a aplicar, e.g., { color: 'red', fontSize: '14px' }. * @param {Array} [options.children] - Hijos a añadir al elemento, strings o nodos. * @returns {HTMLElement} - El elemento HTML creado y configurado. */ function createElement(tag, { className = '', id = '', text = '', html = '', onClickEvent = null, events = {}, atribute = {}, props = {}, styles = {}, children = [] } = {}) { const el = document.createElement(tag); if (className) el.className = className; if (id) el.id = id; if (text) el.textContent = text; if (html) setInnerHTML(el, html); // Soporte legacy (función onClickEvent) if (onClickEvent && typeof onClickEvent === 'function') { el.addEventListener('click', onClickEvent); } // Soporte para múltiples eventos if (events && typeof events === 'object') { Object.entries(events).forEach(([event, handler]) => { if (typeof handler === 'function') { el.addEventListener(event, handler); } }); } // Atributos if (atribute && typeof atribute === 'object') { Object.entries(atribute).forEach(([k, v]) => el.setAttribute(k, v)); } // Propiedades directas if (props && typeof props === 'object') { Object.entries(props).forEach(([k, v]) => { if (k in el) el[k] = v; }); } // Estilos CSS if (styles && typeof styles === 'object') { Object.entries(styles).forEach(([property, value]) => { el.style[property] = value; }); } // Añadir children if (Array.isArray(children)) { children.forEach(child => { if (typeof child === 'string') { el.appendChild(document.createTextNode(child)); } else if (child instanceof Node) { el.appendChild(child); } }); } return el; } // MARK: 🔧 YouTube Helper API /** * Espera a que YouTube Helper API esté listo. * * @param {number} retries - Número de reintentos (opcional, por defecto 0). * @returns {Promise} - Promesa que se resuelve cuando YouTube Helper API está listo. */ function waitForHelper(retries = 1) { return new Promise((resolve, reject) => { const MAX_RETRIES = 10; const RETRY_INTERVAL = 1000; const helper = (typeof youtubeHelperApi !== 'undefined') ? youtubeHelperApi : null; if (helper) { // Si ya está inicializado completamente if (helper?.player?.api) return resolve(helper); // Si existe pero aún no se inicializó helper.eventTarget.addEventListener('yt-helper-api-ready', () => { resolve(helper); }, { once: true }); return; } // Si no existe todavía, reintenta if (retries < MAX_RETRIES) { warn('init', `[YTHelper] No disponible, reintentando... (${retries + 1}/${MAX_RETRIES})`); setTimeout(() => resolve(waitForHelper(retries + 1)), RETRY_INTERVAL); } else { reject(new Error('YouTube Helper API no disponible tras varios intentos')); } }); } // MARK: 🔧 YouTube Thumbnail Loading /** * Devuelve la URL de la miniatura de un video de YouTube. * Utiliza un enfoque progresivo para obtener la miniatura más alta disponible. * @param {string} videoId - ID del video de YouTube * @returns {Promise} - URL de la miniatura o placeholder */ function loadYouTubeThumbnail(videoId) { const base = `https://i.ytimg.com/vi/${videoId}`; const levels = ['maxresdefault', 'hqdefault', 'mqdefault']; // fallback progresivo const placeholder = 'data:image/svg+xml;base64,CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjcyIiB2aWV3Qm94PSIwIDAgMTI4IDcyIj4KICA8cmVjdCB3aWR0aD0iMTI4IiBoZWlnaHQ9IjcyIiBmaWxsPSIjMjAyMDIwIi8+CiAgPHBvbHlnb24gcG9pbnRzPSI1MCwyMiA1MCw1MCA3OCwzNiIgZmlsbD0iI2ZmZiIvPgo8L3N2Zz4='; return new Promise((resolve) => { let index = 0; function tryNext() { if (index >= levels.length) { resolve(placeholder); // no se encontró ninguna válida return; } const url = `${base}/${levels[index]}.jpg`; const img = new Image(); img.onload = () => { // YouTube placeholder de maxresdefault suele tener tamaño 120x90 if (img.naturalWidth <= 120 || img.naturalHeight <= 90) { index++; tryNext(); } else { resolve(url); // imagen válida encontrada } }; img.onerror = () => { index++; tryNext(); // intentar siguiente nivel }; img.src = url; } tryNext(); }); } // MARK: 🔧 Debounce /** * Crea una función "debounceada" que retrasa la ejecución de la función original * hasta que haya pasado un tiempo determinado sin que se vuelva a invocar. * * @param {Function} fn - La función que se quiere ejecutar con retraso. * @param {number} delay - Tiempo de espera (en milisegundos) antes de ejecutar `fn`. * @returns {Function} - Una nueva función que, al llamarse repetidamente, * solo ejecutará `fn` una vez pasado el tiempo indicado. */ const debounce = (fn, delay) => { // Variable para almacenar el identificador del temporizador let timer; // Retorna una nueva función que "envuelve" a la original return (...args) => { // Si el temporizador ya estaba activo, se cancela clearTimeout(timer); // Se crea un nuevo temporizador que ejecutará la función después del delay timer = setTimeout(() => fn(...args), delay); }; }; // MARK: 🎯 VirtualScroller /** * Sistema de virtualización para listas grandes. * Solo renderiza los items visibles en el viewport más un buffer, * reduciendo dramáticamente el número de nodos DOM. * * @example * const scroller = new VirtualScroller({ * container: listContainer, * items: videoItems, * itemHeight: 120, * renderItem: async (item) => createVideoEntry(item.videoId, item.info), * bufferSize: 5 * }); */ class VirtualScroller { /** * @param {Object} options - Configuración del scroller * @param {HTMLElement} options.container - Contenedor scrollable * @param {Array} options.items - Array de items a renderizar * @param {number} options.itemHeight - Altura estimada de cada item en px * @param {Function} options.renderItem - Función async que renderiza un item * @param {number} [options.bufferSize=5] - Número de items extra a renderizar arriba/abajo * @param {Function} [options.onRender] - Callback cuando se completa un render */ constructor(options) { this.container = options.container; this.items = options.items || []; // Función para obtener altura de un item específico, fallback a itemHeight fijo this.getItemHeight = options.getItemHeight || (() => options.itemHeight || 120); this.renderItem = options.renderItem; this.bufferSize = options.bufferSize ?? 5; this.onRender = options.onRender || null; this.renderedItems = new Map(); this.renderingItems = new Set(); this.spacer = null; this.destroyed = false; this.scrollHandler = null; this.lastScrollTop = -1; // Cache de posiciones Y acumuladas this.itemOffsets = []; this.totalHeight = 0; this._init(); } /** * Inicializa el scroller creando el spacer y bindando eventos * @private */ _init() { if (!this.container) { console.warn('[VirtualScroller] Container no proporcionado'); return; } // Limpiar contenido previo setInnerHTML(this.container, ''); // Crear spacer virtual para mantener altura correcta del scroll this.spacer = document.createElement('div'); this.spacer.className = 'ypp-virtual-spacer'; this.container.appendChild(this.spacer); this._calculateOffsets(); // Bind scroll con debounce para mejor rendimiento this.scrollHandler = debounce(() => this._onScroll(), 16); // ~60fps this.container.addEventListener('scroll', this.scrollHandler, { passive: true }); // Render inicial this._render(); } /** * Pre-calcula la posición Y (offset) de cada item sumando las alturas anteriores. * @private */ _calculateOffsets() { this.itemOffsets = new Array(this.items.length); let offset = 0; for (let i = 0; i < this.items.length; i++) { this.itemOffsets[i] = offset; const h = this.getItemHeight(this.items[i], i); offset += h; } this.totalHeight = offset; if (this.spacer) { this.spacer.style.height = `${this.totalHeight}px`; } } _onScroll() { if (this.destroyed) return; const scrollTop = this.container.scrollTop; if (Math.abs(scrollTop - this.lastScrollTop) > 50) { // Umbral bajo para respuesta rápida this.lastScrollTop = scrollTop; this._render(); } } /** * Búsqueda binaria para encontrar el índice del primer item visible * @private */ _findStartIndex(scrollTop) { let low = 0; let high = this.items.length - 1; while (low <= high) { const mid = Math.floor((low + high) / 2); const offset = this.itemOffsets[mid]; const height = this.getItemHeight(this.items[mid], mid); if (offset + height < scrollTop) { low = mid + 1; } else if (offset > scrollTop) { high = mid - 1; } else { return mid; } } return Math.min(low, this.items.length - 1); } /** * Calcula qué items deben estar visibles usando búsqueda binaria * @private * @returns {{startIdx: number, endIdx: number}} */ _getVisibleRange() { const scrollTop = this.container.scrollTop; const viewHeight = this.container.clientHeight; const scrollBottom = scrollTop + viewHeight; let startIdx = this._findStartIndex(scrollTop); startIdx = Math.max(0, startIdx - this.bufferSize); let endIdx = startIdx; // Avanzar linearmente para encontrar el final while (endIdx < this.items.length && this.itemOffsets[endIdx] < scrollBottom) { endIdx++; } endIdx = Math.min(this.items.length, endIdx + this.bufferSize); return { startIdx, endIdx }; } /** * Renderiza los items visibles * @private */ async _render() { if (this.destroyed || !this.spacer) return; const { startIdx, endIdx } = this._getVisibleRange(); // Limpieza: remover items fuera de rango for (const [idx, el] of this.renderedItems) { if (idx < startIdx || idx >= endIdx) { el.remove(); this.renderedItems.delete(idx); } } // Renderizado: añadir items nuevos const renderPromises = []; for (let i = startIdx; i < endIdx; i++) { if (!this.renderedItems.has(i) && !this.renderingItems.has(i)) { this.renderingItems.add(i); renderPromises.push(this._renderItemAt(i)); } } if (renderPromises.length > 0) { await Promise.all(renderPromises); } if (this.onRender) { this.onRender({ visibleStart: startIdx, visibleEnd: endIdx, totalItems: this.items.length, renderedCount: this.renderedItems.size }); } } async _renderItemAt(index) { if (this.destroyed || index >= this.items.length) { this.renderingItems.delete(index); return; } try { const item = this.items[index]; const el = await this.renderItem(item, index); if (this.destroyed) return; // Posicionamiento absoluto usando offset pre-calculado el.classList.add('ypp-virtual-item'); el.style.setProperty('position', 'absolute', 'important'); el.style.top = `${this.itemOffsets[index]}px`; el.style.width = '100%'; this.spacer.appendChild(el); this.renderedItems.set(index, el); } catch (err) { console.error('[VirtualScroller] Error rendering item:', index, err); } finally { this.renderingItems.delete(index); } } /** * Actualiza los items y re-renderiza * @param {Array} newItems - Nuevo array de items */ updateItems(newItems) { this.items = newItems || []; // Limpiar todos los elementos renderizados for (const el of this.renderedItems.values()) { el.remove(); } this.renderedItems.clear(); this.renderingItems.clear(); // Actualizar altura y re-renderizar this._calculateOffsets(); this.lastScrollTop = -1; this._render(); } /** * Fuerza un re-render completo */ refresh() { for (const el of this.renderedItems.values()) { el.remove(); } this.renderedItems.clear(); this.lastScrollTop = -1; this._render(); } /** * Scroll hasta un índice específico * @param {number} index - Índice del item * @param {string} [position='start'] - 'start', 'center', o 'end' */ scrollToIndex(index, position = 'start') { if (index < 0 || index >= this.items.length) return; let targetOffset = this.itemOffsets[index]; if (position === 'center') { const h = this.getItemHeight(this.items[index], index); targetOffset -= (this.container.clientHeight / 2) - (h / 2); } else if (position === 'end') { const h = this.getItemHeight(this.items[index], index); targetOffset -= this.container.clientHeight - h; } this.container.scrollTop = Math.max(0, targetOffset); } /** * Obtiene el número de items actualmente renderizados en el DOM * @returns {number} */ getRenderedCount() { return this.renderedItems.size; } /** * Destruye el scroller y limpia recursos */ destroy() { this.destroyed = true; if (this.scrollHandler && this.container) { this.container.removeEventListener('scroll', this.scrollHandler); } for (const el of this.renderedItems.values()) { el.remove(); } this.renderedItems.clear(); this.renderingItems.clear(); if (this.spacer) { this.spacer.remove(); this.spacer = null; } } } // MARK: 📤 Import/Export JSON // Exportación/Importación JSON nativo del userscript (preserva videoType de shorts) const exportDataToFile = async () => { try { const exportData = {}; const keys = (await Storage.keys()).filter(k => !isNonVideoStorageKey(k)); for (const k of keys) { const data = await 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; const timestamp = new Date().toISOString().split('T')[0]; a.download = `youtube-playback-plox-backup-${timestamp}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); const count = Object.keys(exportData).length; showFloatingToast(`${SVG_ICONS.upload} ${t('itemsImported', { count })}`); log('exportDataToFile', `Exportados ${count} videos en formato JSON nativo`); } catch (error) { conError('exportDataToFile', 'Error al exportar:', error); showFloatingToast(`${SVG_ICONS.error} ${t('exportError')}`); } }; const importDataFromFile = async () => { let inputFile = document.getElementById('ypp-import-file'); if (!inputFile) { inputFile = createElement('input', { id: 'ypp-import-file', atribute: { type: 'file', accept: '.json' }, style: { display: 'none' } }); document.body.appendChild(inputFile); } inputFile.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); if (typeof data !== 'object' || data === null) { showFloatingToast(`${SVG_ICONS.error} ${t('invalidFormat')}`); return; } let importCount = 0; let skipped = 0; for (const [key, value] of Object.entries(data)) { // Evitar importar configuraciones if (key.startsWith('userSettings') || key.startsWith('userFilters')) { skipped++; continue; } // Validar que el valor tenga estructura mínima de video if (value && typeof value === 'object' && (value.videoId || value.timestamp !== undefined)) { await Storage.set(key, value); importCount++; } else { log('importDataFromFile', `Entrada inválida ignorada: ${key}`); skipped++; } } await updateVideoList(); if (importCount > 0) { showFloatingToast(`${SVG_ICONS.check} ${t('itemsImported', { count: importCount })} ${skipped > 0 ? ` (${skipped} ${t('omitedVideos')})` : ''}`); log('importDataFromFile', `Importados ${importCount} videos, ${skipped} omitidos`); } else { showFloatingToast(`${SVG_ICONS.warning} ${t('noValidVideos')}`); } } catch (error) { conError('importDataFromFile', 'Error al importar:', error); showFloatingToast(`${SVG_ICONS.error} ${t('importError')}`); } finally { inputFile.value = ''; } }; inputFile.click(); }; // MARK: 📤 Import/Export FreeTube // Exportación/Importación FreeTube (no preserva videoType de shorts) const exportToFreeTube = () => { // Usar la función centralizada para exportar en formato FreeTube // para asegurar que todos los campos estén correctamente mapeados (async () => { try { const exportData = await exportToFreeTubeFormat(); // FreeTube imports as JSON Lines / .db where each line is a JSON object. // Generar JSON Lines (NDJSON) - cada línea debe ser un objeto JSON completo // JSON.stringify serializa sin saltos de línea por defecto, pero nos aseguramos const ndjson = exportData .map(obj => { // Asegurar que no haya saltos de línea internos en el JSON serializado const jsonLine = JSON.stringify(obj); // Verificar que sea válido (debugging) if (jsonLine.includes('\n') || jsonLine.includes('\r')) { warn('exportToFreeTube', 'JSON con saltos de línea detectado, limpiando...'); // Esto no debería ocurrir con JSON.stringify, pero por seguridad return jsonLine.replace(/\r?\n/g, '\\n'); } return jsonLine; }) .join('\n'); const blob = new Blob([ndjson], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const timestamp = new Date().toISOString().split('T')[0]; // Usar extensión .db porque FreeTube a veces espera ese sufijo (incluso si es JSONL) a.download = `youtube-playback-plox-backup-${timestamp}-freetube-compatible.db`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); showFloatingToast(`${SVG_ICONS.upload} FreeTube ${t('dataExported')}`); } catch (err) { conError('exportToFreeTube', 'Error exporting to FreeTube format:', err); showFloatingToast(`${SVG_ICONS.error} ${t('exportError')}`); } })(); }; const importFromFreeTube = () => { let inputFile = document.getElementById('ypp-import-freetube-file'); if (!inputFile) { inputFile = createElement('input', { id: 'ypp-import-freetube-file', atribute: { type: 'file', accept: '.json, .db' }, style: { display: 'none' } }); document.body.appendChild(inputFile); } inputFile.onchange = async (e) => { const file = e.target.files[0]; const fileName = file?.name || ''; if (!file) return; if (file.size > 10 * 1024 * 1024) { // 10MB limit const fileSizeMB = `${(file.size / (1024 * 1024)).toFixed(2)}MB`; showFloatingToast(`${SVG_ICONS.error} ${t('fileTooLarge', { size: fileSizeMB })}`); return; } // Si es un archivo .db de FreeTube, puede ser: // - un SQLite binario (real .db) // - un .db renombrado que contiene JSON o JSON Lines (caso observado) if (fileName.endsWith('.db')) { // Intentar leer como texto primero (JSON o JSON Lines) try { showFloatingToast(`${SVG_ICONS.download} ${t('importingFromFreeTube')}`); const text = await file.text(); // Intentar parsear como JSON array let data = null; try { data = JSON.parse(text); } catch (e) { // Intentar JSON Lines try { const lines = text.trim().split('\n').filter(l => l.trim()); data = lines.map(l => JSON.parse(l)); } catch (e2) { data = null; } } if (Array.isArray(data) && data.length > 0) { const result = await importFromFreeTubeFormat(data); await updateVideoList(); if (result.imported > 0) { showFloatingToast(`${SVG_ICONS.check} ${result.imported} ${t('videosImported')}${result.failed > 0 ? ` (${result.failed} ${t('errors')})` : ''}`); } else { showFloatingToast(`${SVG_ICONS.error} ${t('noVideosImported')}${result.failed > 0 ? ` (${result.failed} ${t('errors')})` : ''}`); } return; } // Si llegamos aquí, el archivo .db no era JSON válido -> intentar parsear como SQLite showFloatingToast(`${SVG_ICONS.download} ${t('importingFromFreeTubeAsSQLite')}`); } catch (textErr) { // Si leer como texto falla por cualquier motivo, continuamos intentando parsear como SQLite log('importFromFreeTube', 'No se pudo procesar .db como texto, intentando SQLite', textErr); } // Intentar parsear como SQLite DB (binario) try { const arrayBuffer = await file.arrayBuffer(); const data = await parseFreeTubeDB(arrayBuffer); if (!data || data.length === 0) { showFloatingToast(`${SVG_ICONS.warning} ${t('noVideosFoundInFreeTubeDB')}`); return; } const result = await importFromFreeTubeFormat(data); await updateVideoList(); if (result.imported > 0) { showFloatingToast(`${SVG_ICONS.check} ${result.imported} ${t('videosImportedFromFreeTubeDB')} ${result.failed > 0 ? ` (${result.failed} ${t('errors')})` : ''}`); } else { showFloatingToast(`${SVG_ICONS.error} ${t('noVideosImportedFromFreeTubeDB')} ${result.failed > 0 ? ` (${result.failed} ${t('errors')})` : ''}`); } } catch (error) { conError('importFromFreeTube', 'Error procesando archivo .db:', error); showFloatingToast(`${SVG_ICONS.error} ${t('importError')}`); } return; } try { showFloatingToast(`${SVG_ICONS.download} ${t('processingFile')}`); const text = await file.text(); // Validar que el archivo no esté vacío if (!text.trim()) { showFloatingToast(`${SVG_ICONS.warning} ${t('fileEmpty')}`); return; } let data; // Intentar parsear como JSON array estándar primero try { data = JSON.parse(text); } catch (standardError) { // Si falla, intentar parsear como JSON Lines (formato FreeTube) try { data = []; const lines = text.trim().split('\n').filter(line => line.trim()); for (const line of lines) { if (line.trim()) { const obj = JSON.parse(line); data.push(obj); } } log('importFromFreeTube', `Parseado como JSON Lines: ${data.length} objetos encontrados`); } catch (linesError) { throw new SyntaxError('El archivo no tiene un formato JSON válido ni JSON Lines (formato FreeTube)'); } } // Validar que sea un array if (!Array.isArray(data)) { showFloatingToast(`${SVG_ICONS.warning} ${t('invalidFormat')}`); return; } if (data.length === 0) { showFloatingToast(`${SVG_ICONS.warning} ${t('noValidVideos')}`); return; } const result = await importFromFreeTubeFormat(data); await updateVideoList(); if (result.imported > 0) { showFloatingToast(`${SVG_ICONS.check} ${result.imported} ${t('videosImportedFromFreeTubeDB')} ${result.failed > 0 ? ` (${result.failed} ${t('errors')})` : ''}`); } else { showFloatingToast(`${SVG_ICONS.error} ${t('noVideosImportedFromFreeTubeDB')} ${result.failed > 0 ? ` (${result.failed} ${t('errors')})` : ''}`); } } catch (error) { conError('importFromFreeTube', 'Error importando:', error); if (error instanceof SyntaxError) { showFloatingToast(`${SVG_ICONS.error} ${t('importError')}: ${error.message}`); } else { showFloatingToast(`${SVG_ICONS.error} ${t('importError')}`); } } finally { // Limpiar el input para permitir seleccionar el mismo archivo nuevamente inputFile.value = ''; } }; inputFile.click(); }; /** * Convierte el formato interno de YouTube Playback Plox a formato FreeTube * @param {Object} internalData - Datos en formato interno del script * @returns {Object} Datos en formato FreeTube */ function toFreeTubeFormat(internalData) { // Redondear valores de tiempo para que FreeTube los muestre correctamente const timestamp = internalData.timestamp || 0; const duration = internalData.duration || 0; // Redondear watchProgress a 2 decimales const watchProgress = Math.round(timestamp * 100) / 100; // Redondear lengthSeconds a entero (FreeTube espera segundos completos) const lengthSeconds = Math.round(duration); const result = { videoId: internalData.videoId, title: internalData.title || t('unknown'), author: internalData.author || t('unknown'), authorId: internalData.authorId || '', published: internalData.published || null, description: internalData.description || '', viewCount: typeof internalData.viewsNumber === 'string' ? parseInt(internalData.viewsNumber.replace(/[,\.\s]/g, '')) || 0 : (internalData.viewsNumber || 0), lengthSeconds: lengthSeconds, // Redondeado a entero watchProgress: watchProgress, // Redondeado a 2 decimales timeWatched: internalData.lastUpdated || internalData.savedAt || Date.now(), isLive: internalData.isLive || false, // FreeTube usa 'video' para todos los formatos type: 'video', // Metadatos de playlist (FreeTube los incluye siempre, aunque sean null) lastViewedPlaylistId: internalData.lastViewedPlaylistId || null, lastViewedPlaylistType: internalData.lastViewedPlaylistType || '', lastViewedPlaylistItemId: internalData.lastViewedPlaylistItemId || null }; return result; } /** * Parsea un archivo SQLite de FreeTube para extraer el historial * @param {ArrayBuffer} arrayBuffer - Datos del archivo .db * @returns {Array} Array de videos en formato FreeTube */ async function parseFreeTubeDB(arrayBuffer) { try { // Convertir a string para buscar patrones de texto const uint8Array = new Uint8Array(arrayBuffer); let text = ''; // Extraer texto legible del archivo binario for (let i = 0; i < uint8Array.length; i++) { const byte = uint8Array[i]; if (byte >= 32 && byte <= 126) { // Caracteres imprimibles text += String.fromCharCode(byte); } else { text += ' '; // Reemplazar bytes no imprimibles } } // Buscar patrones JSON en el texto const jsonObjects = []; const jsonPattern = /\{[^}]*"videoId"[^}]*\}/g; let match; while ((match = jsonPattern.exec(text)) !== null) { try { // Limpiar y parsear el objeto JSON let cleanJson = match[0]; // Intentar parsear directamente const obj = JSON.parse(cleanJson); if (obj.videoId) { jsonObjects.push(obj); } } catch (e) { // Si falla, intentar reparar JSON común try { let cleanJson = match[0] .replace(/,\s*}/g, '}') // Remove trailing commas .replace(/,\s*]/g, ']'); // Remove trailing commas in arrays const obj = JSON.parse(cleanJson); if (obj.videoId) { jsonObjects.push(obj); } } catch (e2) { // Ignorar objetos que no se pueden parsear } } } // Si no se encontraron objetos con el patrón anterior, buscar más ampliamente if (jsonObjects.length === 0) { // Buscar cualquier objeto que parezca un video de YouTube const broaderPattern = /\{[^}]*"videoId"\s*:\s*"[^"]*"[^}]*\}/g; let broaderMatch; while ((broaderMatch = broaderPattern.exec(text)) !== null) { try { const obj = JSON.parse(broaderMatch[0]); if (obj.videoId && obj.videoId.length > 5) { jsonObjects.push(obj); } } catch (e) { // Ignorar errores } } } log('parseFreeTubeDB', `Encontrados ${jsonObjects.length} videos en la base de datos`); return jsonObjects; } catch (error) { conError('parseFreeTubeDB', 'Error parseando DB:', error); return []; } } /** * Convierte el formato FreeTube a formato interno * @param {Object} freeTubeData - Datos en formato FreeTube * @returns {Object} Datos en formato interno del script */ function fromFreeTubeFormat(freeTubeData) { // Generar thumbnail URL directamente sin depender del contexto de la página const videoId = freeTubeData.videoId; let thumbnailUrl; if (videoId && typeof videoId === 'string' && videoId.length >= 11) { // Usar URL directa de YouTube como fallback confiable thumbnailUrl = `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`; } else { // Fallback adicional si el videoId es inválido thumbnailUrl = 'https://i.ytimg.com/vi/default/maxresdefault.jpg'; } // Determinar si el video está completado basado en el progreso const watchProgress = freeTubeData.watchProgress || 0; const lengthSeconds = freeTubeData.lengthSeconds || 0; let isCompleted = false; if (lengthSeconds > 0) { // Considerar completado si el progreso es >= 95% o si quedan menos de 30 segundos const progressPercent = (watchProgress / lengthSeconds) * 100; const remainingSeconds = lengthSeconds - watchProgress; isCompleted = progressPercent >= CONFIG.defaultSettings.staticFinishPercent || remainingSeconds <= 30; } return { videoId: freeTubeData.videoId, title: freeTubeData.title, author: freeTubeData.author, authorId: freeTubeData.authorId, thumb: thumbnailUrl, viewsNumber: freeTubeData.viewCount ? freeTubeData.viewCount.toLocaleString() : 'N/D', savedAt: freeTubeData.timeWatched, duration: freeTubeData.lengthSeconds, timestamp: freeTubeData.watchProgress, lastUpdated: freeTubeData.timeWatched, videoType: normalizeVideoType(freeTubeData.type === 'short' ? 'shorts' : 'video'), isCompleted: isCompleted, published: freeTubeData.published, description: freeTubeData.description, isLive: freeTubeData.isLive || false, // Preservar metadatos de playlist lastViewedPlaylistId: freeTubeData.lastViewedPlaylistId || null, lastViewedPlaylistType: freeTubeData.lastViewedPlaylistType || '', lastViewedPlaylistItemId: freeTubeData.lastViewedPlaylistItemId || null }; } /** * Exporta todos los videos guardados en formato FreeTube * @returns {Array} Array de videos en formato FreeTube */ async function exportToFreeTubeFormat() { const videoKeys = (await Storage.keys()).filter(key => !isNonVideoStorageKey(key)); const freeTubeData = []; let videoCount = 0; let shortCount = 0; let iter = 0; for (const key of videoKeys) { const data = await Storage.get(key); if (!data) continue; // Compatibilidad con formato antiguo (playlists anidadas) if (data.videos) { log('exportToFreeTubeFormat', `Exportando playlist antigua ${key} con ${Object.keys(data.videos).length} videos`); Object.entries(data.videos).forEach(([vidKey, videoObj]) => { const internal = Object.assign({}, videoObj, { videoId: videoObj.videoId || vidKey }); const formatted = toFreeTubeFormat(internal); freeTubeData.push(formatted); if (internal.videoType === 'shorts' || internal.videoType === 'preview_shorts') shortCount++; else videoCount++; }); } else { // Formato FreeTube: el video ya está en el formato correcto, solo mapear campos si es necesario const internal = Object.assign({}, data, { videoId: data.videoId || key }); const formatted = toFreeTubeFormat(internal); freeTubeData.push(formatted); if (internal.videoType === 'shorts' || internal.videoType === 'preview_shorts') { shortCount++; log('exportToFreeTubeFormat', `Short detectado: ${formatted.videoId} | videoType: ${internal.videoType}`); } else { videoCount++; } } // Rendición cooperativa para no bloquear el hilo principal if ((++iter % 50) === 0) { await new Promise(r => setTimeout(r, 0)); } } log('exportToFreeTubeFormat', `Exportando ${freeTubeData.length} items: ${videoCount} videos, ${shortCount} shorts`); return freeTubeData; } /** * Importa videos desde formato FreeTube * @param {Array} freeTubeData - Array de videos en formato FreeTube * @returns {Object} Resultado de la importación { imported: number, failed: number } */ async function importFromFreeTubeFormat(freeTubeData) { let imported = 0; let failed = 0; // Validar que los datos sean un array if (!Array.isArray(freeTubeData)) { conError('importFromFreeTubeFormat', 'Los datos no son un array válido'); return { imported: 0, failed: 0, total: 0 }; } for (const video of freeTubeData) { try { // Validar que el video tenga los campos mínimos requeridos if (!video || typeof video !== 'object') { conError('importFromFreeTubeFormat', 'Video inválido: no es un objeto'); failed++; continue; } if (!video.videoId) { conError('importFromFreeTubeFormat', 'Video inválido: no tiene videoId'); failed++; continue; } // Validar que el videoId tenga un formato válido if (typeof video.videoId !== 'string' || video.videoId.length < 5) { conError('importFromFreeTubeFormat', `VideoId inválido: ${video.videoId}`); failed++; continue; } const internalFormat = fromFreeTubeFormat(video); // Validación adicional del formato interno if (!internalFormat || !internalFormat.videoId) { conError('importFromFreeTubeFormat', 'Error al convertir formato interno'); failed++; continue; } // Si ya existe el video en Storage, conservar el mayor viewCount como más reciente try { const existing = await Storage.get(video.videoId); if (existing && (existing.viewsNumber != null || internalFormat.viewsNumber != null)) { const parseViews = (val) => { if (typeof val === 'number') return val; if (typeof val === 'string') { const cleaned = val.replace(/[^0-9]/g, ''); if (!cleaned) return null; const n = parseInt(cleaned, 10); return Number.isNaN(n) ? null : n; } return null; }; const existingViews = parseViews(existing.viewsNumber); const importedViews = parseViews(internalFormat.viewsNumber); const winner = (existingViews || 0) > (importedViews || 0) ? existingViews : importedViews; if (winner != null && winner > 0) { try { internalFormat.viewsNumber = winner.toLocaleString(); } catch (_) { internalFormat.viewsNumber = String(winner); } } } } catch (_) { } await Storage.set(video.videoId, internalFormat); imported++; log('importFromFreeTubeFormat', `✅ Importado: ${video.videoId} - ${video.title || 'Sin título'}`); } catch (error) { conError('importFromFreeTubeFormat', `Error importando ${video?.videoId || 'desconocido'}:`, error); failed++; } } log('importFromFreeTubeFormat', `Importación completada: ${imported} exitosos, ${failed} fallidos, total ${freeTubeData.length}`); return { imported, failed, total: freeTubeData.length }; } // ------------------------------------------ // MARK: 📺 Content Type Detection & Specialized Saving // ------------------------------------------ /** * Módulo para detectar y manejar distintos tipos de contenido de forma especializada. * Detecta el tipo una sola vez al inicio y luego ejecuta la función específica. */ // ------------------------------------------ // MARK: 📺 VideoTypeHandler - Strategy Pattern // ------------------------------------------ /** * Módulo estratégico para manejar operaciones por tipo de video. * Centraliza la validación de configuración y delegación de guardado. * * IMPORTANTE: Mantiene aislamiento de contexto entre miniplayer y shorts * para evitar contaminación de datos cuando ambos coexisten. * * @namespace VideoTypeHandler */ const VideoTypeHandler = (() => { /** * Tipos de contenido soportados * @readonly * @enum {string} */ const ContentTypes = Object.freeze({ VIDEO: 'video', SHORTS: 'shorts', PREVIEW: 'preview', LIVE: 'live' }); /** * Verifica si el guardado está habilitado para un tipo específico. * @param {string} type - Tipo de contenido (video, shorts, preview, live) * @param {Object} [options] - Opciones adicionales * @param {string} [options.previewSubtype] - Subtipo para previews (preview_watch, preview_shorts) * @returns {boolean} true si el tipo está habilitado para guardar */ const isEnabled = (type, options = {}) => { try { const settingsSnapshot = cachedSettings || CONFIG.defaultSettings; const context = options.context || null; switch (type) { case ContentTypes.VIDEO: if (context?.isMiniplayer && settingsSnapshot.saveMiniplayerVideos === false) return false; return settingsSnapshot.saveRegularVideos !== false; case ContentTypes.SHORTS: return settingsSnapshot.saveShorts !== false; case ContentTypes.LIVE: return settingsSnapshot.saveLiveStreams !== false; case ContentTypes.PREVIEW: // Previews requieren configuración explícita if (settingsSnapshot.saveInlinePreviews !== true) return false; // Validar subtipo const subtype = options.previewSubtype || 'preview_watch'; if (subtype === 'preview_shorts') { return settingsSnapshot.saveShorts !== false; } return settingsSnapshot.saveRegularVideos !== false; default: return true; } } catch (_) { return true; // En caso de error, permitir el guardado } }; /** * Obtiene información del contexto actual para evitar contaminación. * Crucial cuando miniplayer y shorts coexisten. * @param {HTMLElement} videoElement - Elemento de video * @returns {Object} Información del contexto */ const getContextInfo = (videoElement) => { try { const container = videoElement?.closest?.('#movie_player, #shorts-player'); const containerId = container?.id || null; return { containerId, isShortContext: containerId === 'shorts-player', isWatchContext: containerId === 'movie_player', isInlinePreview: !!( videoElement?.closest?.('#inline-preview-player') || videoElement?.closest?.('.ytp-inline-preview-ui') || videoElement?.closest?.('ytd-thumbnail-overlay-inline-playback-renderer') ), isMiniplayer: !!( videoElement?.closest?.('#miniplayer') || videoElement?.closest?.('.ytp-miniplayer-ui') || videoElement?.closest?.('ytd-miniplayer') ) }; } catch (_) { return { containerId: null, isShortContext: false, isWatchContext: false, isInlinePreview: false, isMiniplayer: false }; } }; /** * Valida que el tipo detectado coincida con el contexto del elemento. * Previene guardar con tipo incorrecto cuando hay coexistencia. * @param {string} detectedType - Tipo detectado * @param {HTMLElement} videoElement - Elemento de video * @returns {Object} Resultado de validación */ const validateContext = (detectedType, videoElement) => { const context = getContextInfo(videoElement); // Si es shorts-player, el tipo DEBE ser shorts if (context.isShortContext && detectedType !== ContentTypes.SHORTS) { log('VideoTypeHandler', `⚠️ Contexto shorts-player pero tipo=${detectedType}. Corrigiendo a shorts.`); return { valid: true, correctedType: ContentTypes.SHORTS, context }; } // Si es miniplayer en movie_player, el tipo NO debe ser shorts if (context.isMiniplayer && detectedType === ContentTypes.SHORTS) { log('VideoTypeHandler', `⚠️ Contexto miniplayer pero tipo=shorts. Corrigiendo a video.`); return { valid: true, correctedType: ContentTypes.VIDEO, context }; } return { valid: true, correctedType: detectedType, context }; }; /** * Guarda el progreso delegando a la función especializada correspondiente. * @param {string} type - Tipo de contenido * @param {Object} params - Parámetros de guardado * @param {string} params.videoId - ID del video * @param {number} params.currentTime - Tiempo actual en segundos * @param {number} params.duration - Duración total del video * @param {Object} params.videoInfo - Información del video * @param {string} [params.playlistId] - ID de playlist (solo para videos regulares) * @param {string} [params.previewSubtype] - Subtipo para previews * @param {HTMLElement} [params.videoElement] - Elemento de video para validación de contexto * @returns {Object} Resultado de la operación { success, reason?, videoId?, timestamp?, type? } */ const save = (type, params) => { const { videoId, currentTime, duration, videoInfo, playlistId, previewSubtype, videoElement } = params; const context = videoElement ? getContextInfo(videoElement) : null; // Validación de contexto para prevenir contaminación if (videoElement) { const validation = validateContext(type, videoElement); if (validation.correctedType !== type) { log('VideoTypeHandler', `🔄 Tipo corregido: ${type} → ${validation.correctedType}`); type = validation.correctedType; } } // Verificar si está habilitado if (!isEnabled(type, { previewSubtype, context })) { log('VideoTypeHandler', `🛑 Tipo ${type} deshabilitado por configuración`); return { success: false, reason: `${type}_disabled_by_settings` }; } // Delegar al guardado especializado log('VideoTypeHandler', `📼 Delegando guardado: tipo=${type}, videoId=${videoId}`); switch (type) { case ContentTypes.VIDEO: return saveRegularVideo(videoId, currentTime, duration, videoInfo, playlistId); case ContentTypes.SHORTS: return saveShortsVideo(videoId, currentTime, duration, videoInfo); case ContentTypes.PREVIEW: return savePreview(videoId, currentTime, duration, videoInfo, previewSubtype || 'preview_watch'); case ContentTypes.LIVE: return saveLivestream(videoId, currentTime, duration, videoInfo); default: log('VideoTypeHandler', `⚠️ Tipo desconocido: ${type}, usando video por defecto`); return saveRegularVideo(videoId, currentTime, duration, videoInfo, playlistId); } }; /** * Determina el tipo de contenido desde parámetros simples. * Wrapper simplificado de detectContentType para uso interno. * @param {Object} params * @param {string} params.pageType - Tipo de página * @param {string} params.containerType - ID del contenedor * @param {HTMLElement} params.videoElement - Elemento de video * @param {boolean} params.isLive - Si es livestream * @param {string} params.currentType - Tipo actual (preview_*, etc) * @returns {string} Tipo de contenido normalizado */ const detectType = ({ pageType, containerType, videoElement, isLive, currentType }) => { // Delegamos a detectContentType existente para no duplicar lógica return detectContentType(pageType, containerType, videoElement, isLive, currentType); }; // API pública return Object.freeze({ ContentTypes, isEnabled, getContextInfo, validateContext, save, detectType }); })(); /** * Detecta el tipo de contenido basado en el contexto de reproducción * @param {string} pageType - Tipo de página (home, watch, shorts, etc.) * @param {string} containerType - ID del contenedor (movie_player, shorts-player, etc.) * @param {HTMLElement} videoElement - Elemento de video * @param {boolean} isLiveType - Si es contenido en vivo * @param {string} currentType - Tipo actual detectado (puede ser 'preview_watch', 'preview_shorts', etc.) * @returns {string} Tipo de contenido normalizado: 'video', 'shorts', 'preview', 'live' */ function detectContentType(pageType, containerType, videoElement, isLiveType, currentType) { // Prioridad 1: Si es explícitamente un livestream if (isLiveType) { log('detectContentType', `✅ Tipo detectado: LIVE (livestream)`); return 'live'; } // Prioridad 2: Si estamos en la página de Shorts if (pageType === 'shorts' && containerType === 'shorts-player') { log('detectContentType', `✅ Tipo detectado: SHORTS (página de Shorts)`); return 'shorts'; } // Prioridad 3: Si el video está dentro de un contenedor de Shorts (aunque la página no sea Shorts) try { const inShortsContext = videoElement?.closest?.('ytd-reel-video-renderer, ytd-shorts, #shorts-player'); if (inShortsContext) { log('detectContentType', `✅ Tipo detectado: SHORTS (contexto de Shorts detectado)`); return 'shorts'; } } catch (_) { } // Prioridad 4: Si es un miniplayer reproduciendo (ANTES de verificar preview) try { const isMiniplayer = videoElement?.closest?.('#miniplayer, .ytp-miniplayer-ui, ytd-miniplayer'); if (isMiniplayer) { log('detectContentType', `✅ Tipo detectado: VIDEO (miniplayer)`); return 'video'; } } catch (_) { } // Prioridad 5: Si es una preview (inline preview en home/search/channel) // IMPORTANTE: Si currentType indica preview_*, debe ganar incluso si el DOM // fue refloweado (sticky fallback) y el video ya no está dentro del contenedor // de preview en este tick. if (typeof currentType === 'string' && currentType.startsWith('preview_')) { try { const isActualPreview = videoElement?.closest?.('#inline-preview-player, .ytp-inline-preview-ui, ytd-thumbnail-overlay-inline-playback-renderer'); if (isActualPreview) { log('detectContentType', `✅ Tipo detectado: PREVIEW (${currentType})`); } else { log('detectContentType', `ℹ️ currentType indica preview (${currentType}) pero no hay contexto DOM de preview (posible reflow sticky). Forzando PREVIEW.`); } } catch (_) { log('detectContentType', `ℹ️ currentType indica preview (${currentType}) y hubo error de inspección DOM. Forzando PREVIEW.`); } return 'preview'; } // Prioridad 6: Si estamos en watch page, NUNCA debe ser preview if (pageType === 'watch') { log('detectContentType', `✅ Tipo detectado: VIDEO (watch page)`); return 'video'; } // Default: Es un video regular log('detectContentType', `✅ Tipo detectado: VIDEO (default)`); return 'video'; } /** * Guarda progreso para un video regular (watch page) * @param {string} videoId - ID del video * @param {number} currentTime - Tiempo actual en segundos * @param {number} duration - Duración total del video * @param {Object} videoInfo - Información del video (title, author, etc.) * @param {string|null} playlistId - ID de la playlist si aplica * @returns {Object} Resultado de la operación */ async function saveRegularVideo(videoId, currentTime, duration, videoInfo, playlistId) { log('saveRegularVideo', `Guardando video regular ${videoId} en ${currentTime}s`); const sourceData = await getSavedVideoData(videoId, playlistId); const now = Date.now(); const isFinished = duration > 0 && (currentTime / duration) * 100 >= (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent); // Si tiene tiempo fijo, no sobreescribir if (sourceData && sourceData.forceResumeTime > 0) { if (isFinished) { log('saveRegularVideo', `Video ${videoId} completado, manteniendo tiempo fijo`); const base = { ...sourceData, isCompleted: true, lastUpdated: now, timestamp: 0 }; if (!thumbnailHasVideoId(base.thumb, videoId)) { base.thumb = `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`; } await Storage.set(videoId, base); } return { success: false, reason: 'fixed_time_no_overwrite' }; } // Preservar metadata si existe const safeVideoInfo = { ...videoInfo }; if (sourceData) { if (!safeVideoInfo.title && sourceData.title) safeVideoInfo.title = sourceData.title; if (!safeVideoInfo.author && sourceData.author) safeVideoInfo.author = sourceData.author; if (!safeVideoInfo.authorId && sourceData.authorId) safeVideoInfo.authorId = sourceData.authorId; } const videoData = { videoId, timestamp: currentTime, lastUpdated: now, videoType: 'video', isCompleted: isFinished, duration, ...safeVideoInfo, thumb: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, lastViewedPlaylistId: playlistId || (sourceData?.lastViewedPlaylistId || null), lastViewedPlaylistType: playlistId ? 'channel' : (sourceData?.lastViewedPlaylistType || ''), lastViewedPlaylistItemId: null }; const storageResult = await Storage.set(videoId, videoData); log('saveRegularVideo', `✅ Video regular guardado:`, videoData); // Verificar si hubo error de almacenamiento if (storageResult && !storageResult.success) { return { success: false, reason: storageResult.reason, videoId, type: 'video' }; } // Actualizar metadata de playlist si aplica if (playlistId) { const playlistMetaKey = `playlist_meta_${playlistId}`; let playlistMeta = await Storage.get(playlistMetaKey) || { playlistId, title: '', lastWatchedVideoId: videoId, lastUpdated: now }; playlistMeta.lastWatchedVideoId = videoId; playlistMeta.lastUpdated = now; const playlistStorageResult = await Storage.set(playlistMetaKey, playlistMeta); // Verificar si hubo error de almacenamiento en metadata de playlist if (playlistStorageResult && !playlistStorageResult.success) { log('saveRegularVideo', `⚠️ Error guardando metadata de playlist: ${playlistStorageResult.reason}`); } } return { success: true, videoId, timestamp: videoData.timestamp, type: 'video' }; } /** * Guarda progreso para Shorts * @param {string} videoId - ID del short * @param {number} currentTime - Tiempo actual * @param {number} duration - Duración total * @param {Object} videoInfo - Información del short * @returns {Object} Resultado de la operación */ async function saveShortsVideo(videoId, currentTime, duration, videoInfo) { log('saveShortsVideo', `Guardando short ${videoId} en ${currentTime}s`); const sourceData = await getSavedVideoData(videoId); const now = Date.now(); const isFinished = duration > 0 && (currentTime / duration) * 100 >= (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent); const videoData = { videoId, timestamp: currentTime, lastUpdated: now, videoType: 'shorts', isCompleted: isFinished, duration, ...videoInfo, thumb: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, lastViewedPlaylistId: null, lastViewedPlaylistType: '', lastViewedPlaylistItemId: null }; const storageResult = await Storage.set(videoId, videoData); log('saveShortsVideo', `✅ Short guardado:`, videoData); // Verificar si hubo error de almacenamiento if (storageResult && !storageResult.success) { return { success: false, reason: storageResult.reason, videoId, type: 'shorts' }; } return { success: true, videoId, timestamp: videoData.timestamp, type: 'shorts' }; } /** * Guarda progreso para previews (inline playback en home/search) * @param {string} videoId - ID del video en preview * @param {number} currentTime - Tiempo actual * @param {number} duration - Duración total * @param {Object} videoInfo - Información del video * @param {string} previewType - 'preview_watch' o 'preview_shorts' * @returns {Object} Resultado de la operación */ async function savePreview(videoId, currentTime, duration, videoInfo, previewType) { log('savePreview', `Guardando preview ${previewType} para ${videoId} en ${currentTime}s`); const sourceData = await getSavedVideoData(videoId); const now = Date.now(); const isFinished = duration > 0 && (currentTime / duration) * 100 >= (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent); const resolvedVideoType = (() => { const previousType = sourceData?.videoType; if (previousType === 'video' || previousType === 'shorts' || previousType === 'live') return previousType; return previewType; })(); // Preservar datos previos para previews const videoData = { ...(sourceData || {}), videoId, timestamp: currentTime, lastUpdated: now, videoType: resolvedVideoType, isCompleted: isFinished, duration: isFinite(duration) ? duration : (sourceData?.duration || 0), ...videoInfo, thumb: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg` }; const storageResult = await Storage.set(videoId, videoData); log('savePreview', `✅ Preview guardado:`, videoData); // Verificar si hubo error de almacenamiento if (storageResult && !storageResult.success) { return { success: false, reason: storageResult.reason, videoId, type: 'preview' }; } return { success: true, videoId, timestamp: videoData.timestamp, type: 'preview' }; } /** * Guarda progreso para livestreams * @param {string} videoId - ID del livestream * @param {number} currentTime - Tiempo actual * @param {number} duration - Duración (0 para en vivo) * @param {Object} videoInfo - Información del livestream * @returns {Object} Resultado de la operación */ async function saveLivestream(videoId, currentTime, duration, videoInfo) { log('saveLivestream', `Guardando livestream ${videoId} en ${currentTime}s`); const sourceData = await getSavedVideoData(videoId); const now = Date.now(); const videoData = { videoId, timestamp: currentTime, lastUpdated: now, videoType: 'live', isCompleted: false, duration: isFinite(duration) && duration > 0 ? duration : 0, ...videoInfo, thumb: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`, lastViewedPlaylistId: null, lastViewedPlaylistType: '', lastViewedPlaylistItemId: null }; const storageResult = await Storage.set(videoId, videoData); log('saveLivestream', `✅ Livestream guardado:`, videoData); // Verificar si hubo error de almacenamiento if (storageResult && !storageResult.success) { return { success: false, reason: storageResult.reason, videoId, type: 'live' }; } return { success: true, videoId, timestamp: videoData.timestamp, type: 'live' }; } /** * Dispatcher principal: detecta el tipo y delega al VideoTypeHandler. * Esta función simplifica el proceso de guardado usando el patrón Strategy. * * @param {string} videoId - ID del video * @param {number} currentTime - Tiempo actual en segundos * @param {number} duration - Duración total del video * @param {Object} videoInfo - Información del video (title, author, etc.) * @param {string} pageType - Tipo de página (home, watch, shorts, etc.) * @param {string} containerType - ID del contenedor (movie_player, shorts-player) * @param {HTMLElement} videoElement - Elemento de video * @param {boolean} isLiveType - true si es un livestream * @param {string} currentType - Tipo actual (puede ser preview_watch, preview_shorts, etc.) * @param {string|null} [playlistId=null] - ID de playlist si aplica * @returns {Object} Resultado de la operación { success, reason?, videoId?, timestamp?, type? } */ function saveVideoDataByType(videoId, currentTime, duration, videoInfo, pageType, containerType, videoElement, isLiveType, currentType, playlistId = null) { log('saveVideoDataByType', `🎯 Iniciando guardado para video ${videoId} via VideoTypeHandler`); // En páginas dedicadas, nunca debemos tratar el contenido como preview aunque // currentType haya quedado "pegado" desde el homepage (sticky/reflow). // Esto asegura reclasificación inmediata: preview_* -> video/shorts al navegar. const normalizedCurrentType = (() => { if (pageType === 'watch' && typeof currentType === 'string' && currentType.startsWith('preview_')) return 'watch'; if (pageType === 'shorts' && typeof currentType === 'string' && currentType.startsWith('preview_')) return 'shorts'; return currentType; })(); // Detectar el tipo de contenido const contentType = detectContentType(pageType, containerType, videoElement, isLiveType, normalizedCurrentType); log('saveVideoDataByType', `📊 Tipo detectado: ${contentType}`); // Determinar subtipo para previews const previewSubtype = (typeof normalizedCurrentType === 'string' && normalizedCurrentType.startsWith('preview_')) ? normalizedCurrentType : 'preview_watch'; // Delegar todo al VideoTypeHandler return VideoTypeHandler.save(contentType, { videoId, currentTime, duration, videoInfo, playlistId, previewSubtype, videoElement }); } // ------------------------------------------ // MARK: 📺 Helpers // ------------------------------------------ // MARK: 📺 Obtiene datos guardados de un video /** * Obtiene datos guardados de un video, intentando todas las combinaciones posibles. * Soporta tanto videos individuales como en playlist. * * @param {string} videoId - ID del video * @param {string|null} playlistId - ID de la playlist (opcional) * @returns {Object|null} - Datos guardados o null si no se encuentra */ async function getSavedVideoData(videoId, playlistId = null) { log('getSavedVideoData', `Buscando datos guardados para ID: ${videoId} | Playlist ID: ${playlistId}`); if (!videoId) return null; // En el formato FreeTube, todos los videos se guardan con video_id como clave // independientemente de si fueron vistos en una playlist o no const videoData = await Storage.get(videoId); if (videoData) { // Si encontramos el video y se especificó un playlistId, verificar compatibilidad if (playlistId && videoData.lastViewedPlaylistId === playlistId) { log('getSavedVideoData', `✅ Video encontrado con playlist coincidente`); return videoData; } else if (!playlistId) { log('getSavedVideoData', `✅ Video encontrado (sin filtro de playlist)`); return videoData; } else { log('getSavedVideoData', `⚠ Video encontrado pero en contexto diferente (guardado: ${videoData.lastViewedPlaylistId}, buscado: ${playlistId}) - usando fallback base`); return videoData; } } // Compatibilidad con formato antiguo (playlists anidadas) if (playlistId) { const oldPlaylistData = await Storage.get(playlistId); if (oldPlaylistData?.videos?.[videoId]) { log('getSavedVideoData', `✅ Video encontrado en formato antiguo (playlist anidada)`); return oldPlaylistData.videos[videoId]; } } // Búsqueda flexible: por si alguna vez se guardó con prefijos raros const keys = await Storage.keys?.() || []; const altKey = keys.find(k => k.endsWith(videoId) || k.includes(videoId)); if (altKey && altKey !== `playlist_meta_${videoId}`) { log('getSavedVideoData', `✅ Video encontrado con clave alternativa: ${altKey}`); return await Storage.get(altKey); } log('getSavedVideoData', `✗ No se encontraron datos para el video`); return null; } // MARK: 📺 Normaliza las claves de almacenamiento de YouTube /** * @function normalizeYouTubeStorageKeys * @description * Normaliza las claves almacenadas en `Storage` que estén relacionadas con YouTube. * Revisa todas las claves, intenta extraer o corregir los IDs de video, * y migra los datos a claves normalizadas, evitando duplicados. * * @returns {void} No devuelve ningún valor. Realiza operaciones directamente sobre `Storage`. * * @example * // Ejemplo de uso: * normalizeYouTubeStorageKeys(); * * // Este proceso: * // - Lee todas las claves del almacenamiento * // - Detecta las que contienen identificadores de videos no normalizados * // - Crea nuevas claves con el formato correcto y migra los datos */ async function normalizeYouTubeStorageKeys() { const NORMALIZE_VERSION = 2; // Incrementar si cambia la lógica de normalización const NORMALIZE_KEY = 'ypp_normalize_storage_keys_version'; // Evitar re-ejecución si ya se aplicó esta versión try { const last = await GM_getValue(NORMALIZE_KEY, 0); if (last >= NORMALIZE_VERSION) { log('normalizeYouTubeStorageKeys', `✅ Normalización ya aplicada (versión ${last})`); return; } } catch (e) { // Si falla GM_getValue, continuar sin bloquear la normalización warn('normalizeYouTubeStorageKeys', 'No se pudo leer versión de normalización, continuando...', e); } // Verifica si el objeto Storage tiene disponible el método keys() if (typeof Storage?.keys !== 'function') { conError('normalizeYouTubeStorageKeys', 'Storage.keys() no disponible.'); return; } // Obtiene todas las claves almacenadas const allKeys = await Storage.keys(); // Contador para llevar registro de cuántas claves se han migrado let changes = 0; // Recorre todas las claves encontradas en el almacenamiento let iter = 0; for (const key of allKeys) { // Intenta extraer o normalizar el ID del video desde la clave const newKey = extractOrNormalizeVideoId(key)?.id; // Registra la operación en el log log('normalizeYouTubeStorageKeys', `Clave original: ${key} | Clave nueva: ${newKey}`); // Si se obtuvo un ID válido y diferente de la clave original if (newKey && newKey !== key) { // Obtiene los datos asociados a la clave antigua const data = await Storage.get(key); // Solo migra si la nueva clave aún no existe (para evitar sobrescribir datos) if (!(await Storage.get(newKey))) { await Storage.set(newKey, data); // Guarda los datos bajo la nueva clave await Storage.del(key); // Elimina la clave antigua log('normalizeYouTubeStorageKeys', `✅ Migrado: "${key}" -> "${newKey}"`); changes++; } else { // Si la nueva clave ya existe, registra un aviso de duplicado log('normalizeYouTubeStorageKeys', `⚠️ Duplicado detectado: "${key}" ya existe como "${newKey}"`); } } } // Muestra un resumen al final del proceso log('normalizeYouTubeStorageKeys', `🔁 Normalización completa. ${changes} claves migradas.`); // Marcar como aplicada esta versión de normalización para evitar re-ejecuciones try { await GM_setValue(NORMALIZE_KEY, NORMALIZE_VERSION); log('normalizeYouTubeStorageKeys', `📝 Versión de normalización guardada: ${NORMALIZE_VERSION}`); } catch (e) { warn('normalizeYouTubeStorageKeys', 'No se pudo guardar versión de normalización', e); } } // MARK: 📺 Get YouTube Page Type /** * Determina el tipo de página actual en YouTube según la URL. * * Analiza la ruta (`window.location.pathname`) y devuelve un identificador * descriptivo del tipo de página (por ejemplo: "home", "shorts", "watch", "channel", etc.). * * @returns {string} - Tipo de página detectada. Puede ser uno de: * - `'home'` — Página principal de YouTube * - `'shorts'` — Página de YouTube Shorts * - `'watch'` — Página de reproducción de un video * - `'embed'` — Página de video embebido * - `'playlist'` — Página de una lista de reproducción * - `'search'` — Página de resultados de búsqueda * - `'music'` — Página de YouTube Music (canal oficial) * - `'gaming'` — Página de YouTube Gaming * - `'news'` — Página de noticias * - `'sports'` — Página de deportes * - `'learning'` — Página de aprendizaje * - `'you'` — Página "Tu espacio" (feed personal) * - `'history'` — Página del historial de reproducciones * - `'subscriptions'` — Página de suscripciones * - `'live'` — Transmisión en vivo o enlace directo a live * - `'channel'` — Página de canal (personalizada, por ID o nombre) * - `'unknown'` — Si no coincide con ninguno de los anteriores */ let _cachedPageType = null; let _lastPageTypeUrl = null; function getYouTubePageType() { // Retornar caché si la URL no cambió (optimización: evitar evaluaciones repetidas) if (_lastPageTypeUrl === window.location.href && _cachedPageType !== null) { return _cachedPageType; } // Obtener la ruta actual de la URL (sin dominio) const path = window.location.pathname; let pageType = 'unknown'; // Comprobaciones directas de ruta (ordenadas por frecuencia esperada) if (path === '/') { pageType = 'home'; } else if (path.startsWith('/shorts')) { pageType = 'shorts'; } else if (path.startsWith('/watch')) { pageType = 'watch'; } else if (path.startsWith('/embed')) { pageType = 'embed'; } else if (path.startsWith('/playlist')) { pageType = 'playlist'; } else if (path.startsWith('/results')) { pageType = 'search'; } // Detección de videos en vivo o enlaces con "/live" // Ejemplo: https://www.youtube.com/@NASA/live // Para raros casos, ya que Youtube usa "/watch" igual para directos. else if (path.includes('/live')) { pageType = 'live'; } // Sección de Gaming else if (path.startsWith('/gaming')) { pageType = 'gaming'; } // Comprobaciones por canal o categoría especial (IDs fijos de YouTube) else if (path.endsWith('/UC-9-kyTW8ZkZNDHQJ6FgpwQ')) { pageType = 'music'; // Canal oficial de YouTube Music } else if (path.endsWith('/UCYfdidRxbB8Qhf0Nx7ioOYw')) { pageType = 'news'; // Canal de Noticias } else if (path.endsWith('/UCEgdi0XIXXZ-qJOFPf4JSKw')) { pageType = 'sports'; // Canal de Deportes } else if (path.endsWith('/UCtFRv9O2AHqOZjjynzrv-xg')) { pageType = 'learning'; // Canal de Aprendizaje } // Feeds personales del usuario else if (path.endsWith('/feed/you')) { pageType = 'you'; } else if (path.endsWith('/feed/history')) { pageType = 'history'; } else if (path.endsWith('/feed/subscriptions')) { pageType = 'subscriptions'; } // Posibles rutas de canales de usuario // Canal personalizado (nuevo formato: /@nombre) else if (path.startsWith('/@')) { pageType = 'channel'; } // Canal por ID (formato: /channel/UCxxxx) else if (path.startsWith('/channel')) { pageType = 'channel'; } // Canal personalizado (antiguo: /c/nombre) else if (path.startsWith('/c')) { pageType = 'channel'; } // Canal de usuario clásico (muy antiguo) else if (path.startsWith('/user')) { pageType = 'channel'; } // Canal directo por ID (raro) else if (path.startsWith('/UC')) { pageType = 'channel'; } // Guardar en caché _lastPageTypeUrl = window.location.href; _cachedPageType = pageType; return pageType; } // ------------------------------------------ // MARK: 📺 Get Video Element // ------------------------------------------ // Encuentra el elemento