// ==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