// ==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.9-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
// @grant GM_addElement
// @run-at document-end
// @namespace youtube-playback-plox
// @license MIT
// @require https://update.greasyfork.org/scripts/549881/1783571/YouTube%20Helper%20API.js
// ==/UserScript==
// ------------------------------------------
// MARK: 🔍 SISTEMA DE LOGGING
// ------------------------------------------
(function () {
'use strict';
const L = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 };
const level = L.silent; // Cambiar a 'debug' para ver todo, o 'warn'/'error' para menos
const S = {
debug: 'color:#6a9955;',
info: 'color:#4FC1FF;',
warn: 'color:#ce9178;font-weight:bold;',
error: 'color:#f44747;font-weight:bold;'
};
const noop = () => { };
const build = (t, l) =>
(level >= l || t === 'error')
? (c, ...a) => console[t](`%c[${c}]`, S[t], ...a)
: noop;
window.MyScriptLogger = {
_errorLogs: [],
log: build('debug', L.debug),
debug: build('debug', L.debug),
info: build('info', L.info),
warn: (c, ...a) => {
console.warn(`%c[${c}]`, S.warn, ...a);
window.MyScriptLogger._internalPushLog(c, a);
},
error: (c, ...a) => {
console.error(`%c[${c}]`, S.error, ...a);
window.MyScriptLogger._internalPushLog(c, a);
},
_internalPushLog: (c, a) => {
const timestamp = new Date().toISOString();
const errorDetails = a.map(arg => {
if (arg instanceof Error) return arg.stack || arg.message;
if (typeof arg === 'object') {
try { return JSON.stringify(arg, null, 2); }
catch (e) { return '[Object (Unstringifiable)]'; }
}
return String(arg);
}).join(' ');
window.MyScriptLogger._errorLogs.push(`[${timestamp}] [${c}] ${errorDetails}`.trim());
if (window.MyScriptLogger._errorLogs.length > 50) window.MyScriptLogger._errorLogs.shift();
}
};
// Global Error Trackers
window.addEventListener('error', (e) => {
const msg = (e.message || e.error?.message || '').toLowerCase();
if (msg.includes('resizeobserver loop') || msg.includes('undelivered notifications')) {
return;
}
if (e.filename && e.filename.includes('youtube-playback-plox')) {
window.MyScriptLogger.error('Global Error', e.error || e.message);
} else if (!e.filename || e.filename === '') {
window.MyScriptLogger.error('DOM Error', e.error || e.message);
}
});
window.addEventListener('unhandledrejection', (e) => {
if (e.reason && (e.reason instanceof Error) && e.reason.stack && e.reason.stack.includes('youtube-playback-plox')) {
window.MyScriptLogger.error('Unhandled Promise', e.reason);
} else if (e.reason && e.reason.message && e.reason.message.includes('getCascadedVideoInf')) {
window.MyScriptLogger.error('Unhandled Promise', e.reason);
} else if (e.reason && e.reason.stack === undefined) {
window.MyScriptLogger.error('Unhandled Promise', e.reason);
}
});
})();
// Atajo para no tener que escribir window.MyScriptLogger cada vez
const { log: logLog, info: logInfo, warn: logWarn, error: logError } = window.MyScriptLogger;
// --- INICIO CARGA LÓGICA PRINCIPAL DEL USERSCRIPT ---
(() => {
'use strict';
const SCRIPT_VERSION = typeof GM_info !== 'undefined' ? GM_info.script.version : '0.0.9-7';
/**
* 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 = SCRIPT_VERSION;
// 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": {
"youtubePlaybackPlox": "YouTube Playback Plox",
"migrationBackupPrompt": "An update to your saved videos database has been detected. To avoid potential data loss due to a migration error, you will be prompted to save a JSON backup.",
"askDownloadBackupPreMigration": "Do you want to download the backup JSON before the update proceeds?",
"settings": "Settings",
"savedVideos": "View saved videos",
"manageVideos": "Manage videos",
"viewAllHistory": "View all history",
"viewCompletedVideos": "View completed videos",
"completedVideos": "Completed videos",
"close": "Close",
"save": "Save",
"saveAs": "Save as",
"cancel": "Cancel",
"delete": "Delete",
"undo": "Undo",
"clearAll": "Clear all",
"clearAllConfirm": "Are you sure you want to delete ALL saved videos? This action can be undone.",
"deleteEntry": "Delete entry",
"deleteSelected": "Delete selected",
"confirmDeleteSelected": "Are you sure you want to delete {count} videos?",
"retryNow": "Retry now",
"retryCompleted": "Retry completed",
"playlistPrefix": "Playlist",
"loading": "Loading",
"progress": "Progress",
"unknown": "Unknown",
"deleted": "deleted.",
"protect": "Protect",
"unprotect": "Unprotect",
"protected": "Protected",
"unprotected": "Unprotected",
"protectedVideos": "Protected videos",
"protectedVideoWarning": "This video is protected and cannot be deleted.",
"protectedItemsSkipped": "{count} protected items were skipped.",
"notAvailable": "N/A",
"errors": "errors",
"rendered": "Rendered",
"configurationSaved": "Configuration saved",
"noSavedVideos": "No saved videos.",
"progressSaved": "Progress saved",
"errorSaving": "Error saving progress",
"unknownError": "Unknown error",
"language": "Language",
"showFloatingButton": "Show floating button",
"enableProgressBarGradient": "Enable color gradient in progress bar",
"manualSaveMode": "Manual save mode",
"manualSaveModeTooltip": "If enabled, progress will only be saved when clicking the save button.",
"enableAutomaticSavingFor": "Enable automatic saving for",
"regularVideos": "Regular videos",
"miniplayerVideos": "Miniplayer videos",
"shorts": "Shorts",
"liveStreams": "Live streams",
"inlinePreviews": "Inline previews on Home",
"minSecondsBetweenSaves": "Minimum seconds between saves",
"alertStyle": "Alert style in playback bar",
"alertIconText": "Icon + Text",
"alertIconOnly": "Icon Only",
"alertTextOnly": "Text Only",
"alertHidden": "Hidden",
"staticFinishPercent": "Percentage to mark video as completed",
"countOncePerSession": "Log additional completion times only once per session",
"countOncePerSessionTooltip": "If enabled, once the completion threshold is reached, replays or auto-looping will not be counted multiple times within the same session.",
"searchByTitleOrAuthor": "Search by title or author...",
"advancedFilters": "Advanced Filters",
"activeFilters": "{count} active filters",
"custom": "Custom",
"sortBy": "Sort by",
"mostRecent": "Most recent",
"oldest": "Oldest",
"titleAZ": "Title (A-Z)",
"titleZA": "Title (Z-A)",
"authorAZ": "Author (A-Z)",
"authorZA": "Author (Z-A)",
"duration": "Duration",
"durationShort": "Duration (Shortest)",
"durationLong": "Duration (Longest)",
"yourMostWatched": "Your most watched",
"yourLeastWatched": "Your least watched",
"mostViewsYoutube": "Most views on YouTube",
"leastViewsYoutube": "Least views on YouTube",
"progressDESC": "Progress (Most to least)",
"progressASC": "Progress (Least to most)",
"filterByType": "Filter by type",
"all": "All",
"videos": "Videos",
"playlist": "Playlist",
"completed": "Completed",
"completedOnce": "Completed at least once",
"videosWithFixedTime": "Videos with fixed time",
"views": "Views",
"minLimit": "Min",
"maxLimit": "Max",
"minViews": "Min views",
"maxViews": "Max views",
"minPercent": "Min %",
"maxPercent": "Max %",
"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",
"watchedCount": "Watched {count} times",
"watchedHistory": "Watch history",
"openChannel": "Open channel",
"resumedAt": "Resumed at",
"alwaysStartFrom": "Always start from",
"startTimeSet": "Start time set to",
"fixedTimeRemoved": "Fixed time removed.",
"live": "Live",
"previews": "Previews",
"selectAllResults": "Select all current results",
"deselectAllResults": "Deselect all current results",
"allItemsCleared": "All items cleared",
"storageFull": "Storage full - Progress cannot be saved",
"allDataRestored": "All data restored",
"allDataCleared": "All data cleared",
"noDataToRestore": "No data to restore",
"clearAllDataConfirm": "Are you sure you want to delete all data?",
"itemsRestored": "{count} items restored",
"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",
"omitedVideos": "Omitted videos",
"export": "Export",
"import": "Import",
"dataExported": "Data exported",
"exportSelected": "Export selected",
"itemsExported": "Exported {count} items",
"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",
"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",
"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...",
"createPlaylist": "Create playlist",
"openPlaylist": "Open playlist",
"selectVideos": "Select videos",
"selectedVideos": "Selected videos",
"generatePlaylistLink": "Generate playlist link",
"playlistLinkGenerated": "Playlist link generated",
"copyLink": "Copy link",
"linkCopied": "Link copied to clipboard",
"removeFromPlaylist": "Remove from playlist",
"confirmRemoveFromPlaylist": "Are you sure you want to remove this video from the playlist? It will remain as an individual video.",
"playlistAssociationRemoved": "Playlist association removed",
"selectAtLeastOne": "Select at least one video",
"tooManyVideos": "Too many videos selected (max 200)",
"githubBackup": "GitHub Backup",
"githubToken": "Personal Access Token",
"githubGistId": "Gist ID",
"githubAutoBackup": "Enable automatic backup",
"githubInterval": "Backup interval (hours 1-24)",
"githubBackupNow": "Backup Now",
"githubLastSync": "Last sync",
"githubGistView": "View Gist",
"githubBackupSuccess": "Backup successful",
"githubBackupError": "Backup error",
"githubTokenRequired": "GitHub Token required",
"githubInvalidToken": "Invalid GitHub Token",
"githubHelp": "How to configure?",
"githubHelpStep1": "1. Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic).",
"githubHelpStep2Gist": "2. Generate a new token with only the 'gist' scope.",
"githubHelpStep2Repo": "2. Generate a new token with the 'repo' scope (required for private repositories).",
"githubHelpStep3": "3. Paste the token generated below.",
"githubHelpStep4Repo": "4. Create a private repository on your GitHub account and enter the Owner and Name below.",
"githubHelpImportant": "Important: Never share your token or gist ID with anyone outside of this script.",
"githubGistIdPlaceholder": "ID (empty for new)",
"githubGistIdExample": "Example Gist ID: https://gist.github.com/Alplox/123456789 -> ID: 123456789",
"githubSelectRepo": "Gist created/updated successfully",
"githubBackupNowInfo": "This will create a backup of all saved videos in JSON format. The file will be uploaded as a secret Gist on GitHub. Keep in mind that, while it’s not public, anyone with the Gist ID could access its contents. This behavior is inherent to how GitHub Gists work and is outside the control of this userscript.",
"githubRepoBackupNowInfo": "This will create a backup of all saved videos in JSON format. The file will be uploaded to your private repository as 'youtube-playback-plox-backup.json'. Your backup history will be maintained through Git commits.",
"githubBackupType": "Backup storage",
"githubBackupTypeGist": "GitHub Gist (Secret but not entirely private)",
"githubBackupTypeRepo": "GitHub Repository (Private)",
"githubRepoOwner": "Repository Owner",
"githubRepoOwnerPlaceholder": "Your GitHub username",
"githubRepoName": "Repository Name",
"githubRepoNamePlaceholder": "E.g.: ypp-backups",
"githubAutoDeleteToken": "Auto-delete token from script after manual backup",
"githubGistSafe": "Gists only require 'gist' scope (minimal privilege).",
"githubCleanupGuide": "Accidental Backup Cleanup",
"githubCleanupStep1": "To remove data completely, you can delete the Gist or Repository directly on GitHub.",
"githubCleanupStep2": "For repositories, deleting the backup file leaves history in previous commits. Deleting the entire repository is the only way to purge all traces.",
"githubRepoPrivacyError": "Error: The repository must be private to perform the backup.",
"githubRepoCheck": "Verifying repository privacy...",
"supportLogsTitle": "Support & Error Logs",
"copyLogsBtn": "Copy Logs",
"reportIssue": "Report Issue",
"logsCopied": "Logs copied to clipboard!",
"noLogs": "No errors recorded."
},
"es-ES": {
"youtubePlaybackPlox": "YouTube Playback Plox",
"migrationBackupPrompt": "Se ha detectado una actualización en la base de datos de videos guardados. Para evitar la posible pérdida de datos debido a un error de migración, se te pedirá que guardes una copia de seguridad en formato JSON.",
"askDownloadBackupPreMigration": "¿Quieres descargar la copia de seguridad en formato JSON antes de que continúe la actualización?",
"settings": "Configuración",
"savedVideos": "Ver videos guardados",
"manageVideos": "Gestionar vídeos",
"viewAllHistory": "Ver todo el historial",
"viewCompletedVideos": "Ver videos completados",
"completedVideos": "Videos completados",
"close": "Cerrar",
"save": "Guardar",
"saveAs": "Guardar como",
"cancel": "Cancelar",
"delete": "Eliminar",
"undo": "Deshacer",
"clearAll": "Eliminar todo",
"clearAllConfirm": "¿Estás seguro de que quieres eliminar TODOS los videos guardados? Esta acción se puede deshacer.",
"deleteEntry": "Eliminar entrada",
"deleteSelected": "Eliminar seleccionados",
"confirmDeleteSelected": "¿Seguro que quieres eliminar {count} vídeos?",
"retryNow": "Reintentar ahora",
"retryCompleted": "Reintentos completados",
"playlistPrefix": "Playlist",
"loading": "Cargando",
"progress": "Progreso",
"unknown": "Desconocido",
"deleted": "eliminado.",
"protect": "Proteger",
"unprotect": "Quitar protección",
"protected": "Protegido",
"unprotected": "Sin protección",
"protectedVideos": "Videos protegidos",
"protectedVideoWarning": "Este video está protegido y no puede eliminarse.",
"protectedItemsSkipped": "Se omitieron {count} elementos protegidos.",
"notAvailable": "N/A",
"errors": "errores",
"rendered": "Renderizados",
"configurationSaved": "Configuración guardada",
"noSavedVideos": "No hay videos guardados.",
"progressSaved": "Progreso guardado",
"errorSaving": "Error guardando progreso",
"unknownError": "Error desconocido",
"language": "Idioma",
"showFloatingButton": "Mostrar botón flotante",
"enableProgressBarGradient": "Habilitar degradado de colores en barra de progreso",
"manualSaveMode": "Modo de guardado manual",
"manualSaveModeTooltip": "Si está activado, el progreso solo se guardará al pulsar el botón de guardado.",
"enableAutomaticSavingFor": "Habilitar guardado automático para",
"regularVideos": "Videos regulares",
"miniplayerVideos": "Vídeos en minirreproductor",
"shorts": "Shorts",
"liveStreams": "Directos (Livestreams)",
"inlinePreviews": "Previsualizaciones en la página de inicio",
"minSecondsBetweenSaves": "Intervalo segundos mínimos entre guardados",
"alertStyle": "Estilo de alertas en la barra de reproducción",
"alertIconText": "Icono + Texto",
"alertIconOnly": "Solo Icono",
"alertTextOnly": "Solo Texto",
"alertHidden": "Oculto",
"staticFinishPercent": "Porcentaje para marcar video como completado",
"countOncePerSession": "Registrar tiempos de finalización adicionales solo una vez por sesión",
"countOncePerSessionTooltip": "Si está activado, una vez alcanzado el umbral de finalización, las repeticiones o la reproducción automática no se contarán varias veces dentro de la misma sesión.",
"searchByTitleOrAuthor": "Buscar por título o autor...",
"advancedFilters": "Filtros avanzados",
"activeFilters": "{count} filtros activos",
"custom": "Personalizado",
"sortBy": "Ordenar por",
"mostRecent": "Más recientes",
"oldest": "Más antiguos",
"titleAZ": "Título (A-Z)",
"titleZA": "Título (Z-A)",
"authorAZ": "Autor (A-Z)",
"authorZA": "Autor (Z-A)",
"duration": "Duración",
"durationShort": "Duración (Más corta)",
"durationLong": "Duración (Más larga)",
"yourMostWatched": "Tus más vistos",
"yourLeastWatched": "Tus menos vistos",
"mostViewsYoutube": "Más vistas en YouTube",
"leastViewsYoutube": "Menos vistas en YouTube",
"progressDESC": "Progreso (Mayor a menor)",
"progressASC": "Progreso (Menor a mayor)",
"filterByType": "Filtrar por tipo",
"all": "Todos",
"videos": "Videos",
"playlist": "Playlist",
"completed": "Completado",
"completedOnce": "Completado al menos una vez",
"videosWithFixedTime": "Videos con tiempo fijo",
"views": "Vistas",
"minLimit": "Mín",
"maxLimit": "Máx",
"minViews": "Mín vistas",
"maxViews": "Máx vistas",
"minPercent": "Mín %",
"maxPercent": "Máx %",
"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",
"watchedCount": "Visto {count} veces",
"watchedHistory": "Historial de visualización",
"openChannel": "Abrir canal",
"resumedAt": "Reanudado en",
"alwaysStartFrom": "Siempre desde",
"startTimeSet": "Tiempo de inicio establecido en",
"fixedTimeRemoved": "Tiempo fijo eliminado.",
"live": "Directo",
"previews": "Previsualizaciones",
"selectAllResults": "Seleccionar todos los resultados actuales",
"deselectAllResults": "Deseleccionar todos los resultados actuales",
"allItemsCleared": "Todos los elementos eliminados",
"storageFull": "Almacenamiento lleno - No se puede guardar el progreso",
"allDataRestored": "Todos los datos restaurados",
"allDataCleared": "Todos los datos eliminados",
"noDataToRestore": "No hay datos para restaurar",
"clearAllDataConfirm": "¿Estás seguro de que quieres eliminar todos los datos?",
"itemsRestored": "{count} elementos restaurados",
"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",
"omitedVideos": "Videos omitidos",
"export": "Exportar",
"import": "Importar",
"dataExported": "Datos exportados",
"exportSelected": "Exportar seleccionados",
"itemsExported": "{count} elementos 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",
"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",
"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...",
"createPlaylist": "Crear playlist",
"openPlaylist": "Abrir 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",
"removeFromPlaylist": "Quitar de la lista de reproducción",
"confirmRemoveFromPlaylist": "¿Estás seguro de que quieres quitar este vídeo de la lista de reproducción? Se mantendrá como vídeo individual.",
"playlistAssociationRemoved": "Asociación de la lista de reproducción eliminada",
"selectAtLeastOne": "Selecciona al menos un video",
"tooManyVideos": "Demasiados videos seleccionados (máx 200)",
"githubBackup": "Copia de seguridad de GitHub",
"githubToken": "Token de acceso personal",
"githubGistId": "ID del Gist",
"githubAutoBackup": "Activar copia de seguridad automática",
"githubInterval": "Intervalo de copia (horas 1-24)",
"githubBackupNow": "Crear copia ahora",
"githubLastSync": "Última sincronización",
"githubGistView": "Ver Gist",
"githubBackupSuccess": "Copia de seguridad completada",
"githubBackupError": "Error en la copia de seguridad",
"githubTokenRequired": "Se requiere un token de GitHub",
"githubInvalidToken": "Token de GitHub no válido",
"githubHelp": "¿Cómo configurarlo?",
"githubHelpStep1": "1. Ve a Configuración de GitHub > Configuración de desarrollador > Tokens de acceso personal > Tokens (clásicos).",
"githubHelpStep2Gist": "2. Genera un nuevo token con solo el alcance 'gist'.",
"githubHelpStep2Repo": "2. Genera un nuevo token con el alcance 'repo' (necesario para repositorios privados).",
"githubHelpStep3": "3. Pega el token generado abajo.",
"githubHelpStep4Repo": "4. Crea un repositorio privado en tu cuenta de GitHub e introduce el propietario y el nombre abajo.",
"githubHelpImportant": "Importante: Nunca compartas tu token o ID de Gist con nadie fuera de este script.",
"githubGistIdPlaceholder": "ID (vacío para nuevo)",
"githubGistIdExample": "Ejemplo de ID de Gist: https://gist.github.com/Alplox/123456789 -> ID: 123456789",
"githubSelectRepo": "Gist creado/actualizado correctamente",
"githubBackupNowInfo": "Esto creará una copia de seguridad de todos los vídeos guardados en formato JSON. El archivo se subirá como un Gist secreto en GitHub. Ten en cuenta que, aunque no es público, cualquiera con el ID del Gist puede acceder a su contenido. Este comportamiento es propio de GitHub Gists y está fuera del control de este script.",
"githubRepoBackupNowInfo": "Esto creará una copia de seguridad de todos los vídeos guardados en formato JSON. El archivo se subirá a tu repositorio privado como 'youtube-playback-plox-backup.json'. El historial de copias se mantendrá mediante commits de Git.",
"githubBackupType": "Almacenamiento de copia",
"githubBackupTypeGist": "GitHub Gist (secreto pero no completamente privado)",
"githubBackupTypeRepo": "Repositorio de GitHub (privado)",
"githubRepoOwner": "Propietario del repositorio",
"githubRepoOwnerPlaceholder": "Tu usuario de GitHub",
"githubRepoName": "Nombre del repositorio",
"githubRepoNamePlaceholder": "Ej.: ypp-backups",
"githubAutoDeleteToken": "Eliminar automáticamente el token del script tras copia manual",
"githubGistSafe": "Los Gists solo requieren el alcance 'gist' (privilegios mínimos).",
"githubCleanupGuide": "Limpieza de copias accidentales",
"githubCleanupStep1": "Para eliminar los datos completamente, puedes borrar el Gist o el repositorio directamente en GitHub.",
"githubCleanupStep2": "En repositorios, eliminar el archivo deja historial en commits anteriores. Borrar todo el repositorio es la única forma de eliminar todos los rastros.",
"githubRepoPrivacyError": "Error: El repositorio debe ser privado para realizar la copia.",
"githubRepoCheck": "Verificando privacidad del repositorio...",
"supportLogsTitle": "Soporte y registros de errores",
"copyLogsBtn": "Copiar registros",
"reportIssue": "Reportar problema",
"logsCopied": "¡Registros copiados al portapapeles!",
"noLogs": "No hay errores registrados."
},
"fr": {
"youtubePlaybackPlox": "YouTube Playback Plox",
"migrationBackupPrompt": "Une mise à jour de la base de données des vidéos enregistrées a été détectée. Pour éviter toute perte de données due à une erreur de migration, il vous sera demandé de sauvegarder une copie de sauvegarde au format JSON.",
"askDownloadBackupPreMigration": "Voulez-vous télécharger la sauvegarde au format JSON avant que la mise à jour ne continue ?",
"settings": "Paramètres",
"savedVideos": "Voir les vidéos enregistrées",
"manageVideos": "Gérer les vidéos",
"viewAllHistory": "Voir tout l'historique",
"viewCompletedVideos": "Voir les vidéos terminées",
"completedVideos": "Vidéos terminées",
"close": "Fermer",
"save": "Enregistrer",
"saveAs": "Enregistrer sous",
"cancel": "Annuler",
"delete": "Supprimer",
"undo": "Annuler",
"clearAll": "Tout effacer",
"clearAllConfirm": "Êtes-vous sûr de vouloir supprimer TOUTES les vidéos enregistrées ? Cette action peut être annulée.",
"deleteEntry": "Supprimer l'entrée",
"deleteSelected": "Supprimer la sélection",
"confirmDeleteSelected": "Êtes-vous sûr de vouloir supprimer {count} vidéos ?",
"retryNow": "Réessayer maintenant",
"retryCompleted": "Réessais terminés",
"playlistPrefix": "Playlist",
"loading": "Chargement",
"progress": "Progrès",
"unknown": "Inconnu",
"deleted": "supprimé.",
"protect": "Protéger",
"unprotect": "Retirer la protection",
"protected": "Protégé",
"unprotected": "Non protégé",
"protectedVideos": "Vidéos protégées",
"protectedVideoWarning": "Cette vidéo est protégée et ne peut pas être supprimée.",
"protectedItemsSkipped": "{count} éléments protégés ont été ignorés.",
"notAvailable": "N/A",
"errors": "erreurs",
"rendered": "Rendus",
"configurationSaved": "Configuration enregistrée",
"noSavedVideos": "Aucune vidéo enregistrée.",
"progressSaved": "Progrès enregistré",
"errorSaving": "Erreur lors de l'enregistrement de la progression",
"unknownError": "Erreur inconnue",
"language": "Langue",
"showFloatingButton": "Afficher le bouton flottant",
"enableProgressBarGradient": "Activer le dégradé de couleurs dans la barre de progression",
"manualSaveMode": "Mode de sauvegarde manuelle",
"manualSaveModeTooltip": "Si activé, la progression ne sera sauvegardée qu'en cliquant sur le bouton de sauvegarde.",
"enableAutomaticSavingFor": "Activer l’enregistrement automatique pour",
"regularVideos": "Vidéos régulières",
"miniplayerVideos": "Vidéos en mini-lecteur",
"shorts": "Shorts",
"liveStreams": "Diffusions en direct",
"inlinePreviews": "Aperçus intégrés sur l’accueil (Home)",
"minSecondsBetweenSaves": "Secondes minimales entre les sauvegardes",
"alertStyle": "Style d'alerte dans la barre de lecture",
"alertIconText": "Icône + Texte",
"alertIconOnly": "Icône uniquement",
"alertTextOnly": "Texte uniquement",
"alertHidden": "Masqué",
"staticFinishPercent": "Pourcentage pour marquer la vidéo comme terminée",
"countOncePerSession": "Enregistrer les complétions supplémentaires une seule fois par session",
"countOncePerSessionTooltip": "Si activé, une fois le seuil de complétion atteint, les relectures ou la lecture en boucle ne seront pas comptées plusieurs fois au cours de la même session.",
"searchByTitleOrAuthor": "Rechercher par titre ou auteur...",
"advancedFilters": "Filtres avancés",
"activeFilters": "{count} filtres actifs",
"custom": "Personnalisé",
"sortBy": "Trier par",
"mostRecent": "Plus récent",
"oldest": "Plus ancien",
"titleAZ": "Titre (A-Z)",
"titleZA": "Titre (Z-A)",
"authorAZ": "Auteur (A-Z)",
"authorZA": "Auteur (Z-A)",
"duration": "Durée",
"durationShort": "Durée (La plus courte)",
"durationLong": "Durée (La plus longue)",
"yourMostWatched": "Vos plus regardés",
"yourLeastWatched": "Vos moins regardés",
"mostViewsYoutube": "Le plus de vues sur YouTube",
"leastViewsYoutube": "Le moins de vues sur YouTube",
"progressDESC": "Progression (Du plus au moins)",
"progressASC": "Progression (Du moins au plus)",
"filterByType": "Filtrer par type",
"all": "Tous",
"videos": "Vidéos",
"playlist": "Playlist",
"completed": "Terminé",
"completedOnce": "Complété au moins une fois",
"videosWithFixedTime": "Vidéos avec un temps fixe",
"views": "Vues",
"minLimit": "Min",
"maxLimit": "Max",
"minViews": "Vues min",
"maxViews": "Vues max",
"minPercent": "Min %",
"maxPercent": "Max %",
"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",
"watchedCount": "Visionné {count} fois",
"watchedHistory": "Historique de visionnage",
"openChannel": "Ouvrir la chaîne",
"resumedAt": "Repris à",
"alwaysStartFrom": "Toujours commencer à",
"startTimeSet": "Heure de début définie à",
"fixedTimeRemoved": "Heure fixe supprimée.",
"live": "Diffusion en direct",
"previews": "Aperçus",
"selectAllResults": "Sélectionner tous les résultats actuels",
"deselectAllResults": "Désélectionner tous les résultats actuels",
"allItemsCleared": "Tous les éléments effacés",
"storageFull": "Stockage plein - Impossible d’enregistrer la progression",
"allDataRestored": "Toutes les données restaurées",
"allDataCleared": "Toutes les données ont été effacées",
"noDataToRestore": "Aucune donnée à restaurer",
"clearAllDataConfirm": "Êtes-vous sûr de vouloir supprimer toutes les données ?",
"itemsRestored": "{count} éléments restaurés",
"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",
"omitedVideos": "Vidéos omises",
"export": "Exporter",
"import": "Importer",
"dataExported": "Données exportées",
"exportSelected": "Exporter la sélection",
"itemsExported": "{count} éléments exportés",
"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",
"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",
"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...",
"createPlaylist": "Créer une playlist",
"openPlaylist": "Ouvrir la 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",
"removeFromPlaylist": "Retirer de la playlist",
"confirmRemoveFromPlaylist": "Êtes-vous sûr de vouloir retirer cette vidéo de la playlist ? Elle restera comme vidéo individuelle.",
"playlistAssociationRemoved": "Association à la playlist supprimée",
"selectAtLeastOne": "Sélectionnez au moins une vidéo",
"tooManyVideos": "Trop de vidéos sélectionnées (max 200)",
"githubBackup": "Sauvegarde GitHub",
"githubToken": "Jeton d'accès personnel",
"githubGistId": "ID du Gist",
"githubAutoBackup": "Activer la sauvegarde automatique",
"githubInterval": "Intervalle de sauvegarde (heures 1-24)",
"githubBackupNow": "Sauvegarder maintenant",
"githubLastSync": "Dernière synchronisation",
"githubGistView": "Voir le Gist",
"githubBackupSuccess": "Sauvegarde réussie",
"githubBackupError": "Erreur de sauvegarde",
"githubTokenRequired": "Jeton GitHub requis",
"githubInvalidToken": "Jeton GitHub invalide",
"githubHelp": "Comment configurer ?",
"githubHelpStep1": "1. Allez dans Paramètres GitHub > Paramètres développeur > Jetons d'accès personnel > Jetons (classiques).",
"githubHelpStep2Gist": "2. Générez un nouveau jeton avec uniquement le scope 'gist'.",
"githubHelpStep2Repo": "2. Générez un nouveau jeton avec le scope 'repo' (nécessaire pour les dépôts privés).",
"githubHelpStep3": "3. Collez le jeton généré ci-dessous.",
"githubHelpStep4Repo": "4. Créez un dépôt privé sur votre compte GitHub et entrez le propriétaire et le nom ci-dessous.",
"githubHelpImportant": "Important : Ne partagez jamais votre jeton ou l'ID du Gist avec qui que ce soit en dehors de ce script.",
"githubGistIdPlaceholder": "ID (vide pour nouveau)",
"githubGistIdExample": "Exemple d'ID Gist : https://gist.github.com/Alplox/123456789 -> ID : 123456789",
"githubSelectRepo": "Gist créé/mis à jour avec succès",
"githubBackupNowInfo": "Cela créera une sauvegarde de toutes les vidéos enregistrées au format JSON. Le fichier sera téléchargé comme un Gist secret sur GitHub. Notez que, bien qu'il ne soit pas public, toute personne disposant de l'ID du Gist peut accéder à son contenu.",
"githubRepoBackupNowInfo": "Cela créera une sauvegarde de toutes les vidéos enregistrées au format JSON. Le fichier sera téléchargé dans votre dépôt privé sous le nom 'youtube-playback-plox-backup.json'. L'historique sera conservé via les commits Git.",
"githubBackupType": "Stockage de sauvegarde",
"githubBackupTypeGist": "GitHub Gist (secret mais pas totalement privé)",
"githubBackupTypeRepo": "Dépôt GitHub (privé)",
"githubRepoOwner": "Propriétaire du dépôt",
"githubRepoOwnerPlaceholder": "Votre nom d'utilisateur GitHub",
"githubRepoName": "Nom du dépôt",
"githubRepoNamePlaceholder": "Ex. : ypp-backups",
"githubAutoDeleteToken": "Supprimer automatiquement le jeton du script après sauvegarde manuelle",
"githubGistSafe": "Les Gists nécessitent uniquement le scope 'gist' (privilège minimal).",
"githubCleanupGuide": "Nettoyage des sauvegardes accidentelles",
"githubCleanupStep1": "Pour supprimer complètement les données, vous pouvez supprimer le Gist ou le dépôt directement sur GitHub.",
"githubCleanupStep2": "Pour les dépôts, supprimer le fichier laisse un historique dans les commits précédents. Supprimer le dépôt entier est la seule façon d'effacer toutes les traces.",
"githubRepoPrivacyError": "Erreur : Le dépôt doit être privé pour effectuer la sauvegarde.",
"githubRepoCheck": "Vérification de la confidentialité du dépôt...",
"supportLogsTitle": "Support et journaux d’erreurs",
"copyLogsBtn": "Copier les journaux",
"reportIssue": "Signaler un problème",
"logsCopied": "Journaux copiés dans le presse-papiers !",
"noLogs": "Aucune erreur enregistrée."
}
};
// Función para cargar las traducciones desde el archivo JSON externo
async function loadTranslations() {
const CACHE_KEY = CONFIG.STORAGE_KEYS.translations;
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) {
logInfo('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) {
logInfo('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) {
logInfo('loadTranslations', 'Traducciones externas cargadas correctamente desde: ' + url);
data = candidate;
break;
} else {
logWarn('loadTranslations', 'Traducciones inválidas desde: ' + url);
}
} catch (e) {
logWarn('loadTranslations', 'Fallo al cargar traducciones desde ' + url, e);
}
}
if (!data) {
logError('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,
/** Claves de almacenamiento GM_setValue */
STORAGE_KEYS: {
settings: 'YT_PLAYBACK_PLOX_userSettings',
filters: 'YT_PLAYBACK_PLOX_userFilters',
github: 'YT_PLAYBACK_PLOX_githubSettings',
migration: 'YT_PLAYBACK_PLOX_migrationVersion',
translations: 'YT_PLAYBACK_PLOX_translations_cache'
},
/** Valores predeterminados para configuraciones del usuario */
defaultSettings: {
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)
manualSaveMode: false, // Modo de guardado manual (default: desactivado)
countOncePerSession: false, // Contar solo una vez por sesión (default: desactivado)
},
alertStylesSettings: {
icon_only: 'iconOnly',
text_only: 'textOnly',
icon_and_text: 'iconText',
no_icon_no_text: 'hidden'
},
defaultGithubSettings: {
gist: {
token: "",
id: "",
url: "",
autoBackup: false,
interval: 24, // horas
lastSync: 0
},
repo: {
token: "",
owner: "",
name: "",
autoBackup: false,
interval: 24, // horas
lastSync: 0
},
autoDeleteToken: true,
lastViewedType: 'gist'
},
/** Valores predeterminados para filtros */
defaultFilters: {
orderBy: "recent",
filterBy: "all",
searchQuery: "",
minViews: 0,
maxViews: 0,
minPercent: 0,
maxPercent: 100
}
};
// MARK: Selectors
// === VIDEOS /watch ===
// Jerarquía simplificada de YouTube Video en el DOM (acorde a url /watch):
//
// <div#columns.style-scope.ytd-watch-flexy>
// └─ <div#primary.style-scope.ytd-watch-flexy>
// └─ <div#primary-inner.style-scope.ytd-watch-flexy>
// └─ <div#player.style-scope.ytd-watch-flexy>
// └─ <div#player-container-outer.style-scope.ytd-watch-flexy>
// └─ <div#player-container-inner.style-scope.ytd-watch-flexy>
// └─ <div#player-container.style-scope.ytd-watch-flexy>
// └─ <ytd-player#ytd-player.style-scope.ytd-watch-flexy>
// └─ <div#container.style-scope.ytd-player>
// 🟢 └─ <div#movie_player.html5-video-player>
// └─ <div.html5-video-container>
// └─ <video.video-stream.html5-main-video> -> Video activo (Existe 3 veces en DOM; una dentro #movie_player, #shorts-player y #inline-preview-player)
// === SHORTS /shorts ===
// Jerarquía simplificada de YouTube Shorts en el DOM (acorde a url /shorts):
// <ytd-shorts.style-scope ytd-page-manager>
// └─ <div#shorts-container> -> Contenedor interno donde se renderiza el visor de Shorts, incluye botones de control, etc
// └─ <div#shorts-inner-container> -> Irrelevante, serviria solo para primera carga de short luego queda stale (enlazado a ese primer short cargado)
// └─ <div.reel-video-in-sequence-new.style-scope.ytd-shorts> -> Existen multiples de estos divs donde cada short va asignado a un incremental numero en su id (id="1", id="2", etc) Son indistingibles de no ser por sus ids
// └─ <ytd-reel-video-renderer#reel-video-renderer>
// └─ <div#short-video-container> -> Contenedor interno donde se renderiza el video de Shorts Activo
// └─ <div.player-wrapper.style-scope.ytd-reel-video-renderer>
// └─ <div#player-container> -> id="player-container" puede exitir 3 veces en DOM; dentro de #video-preview, #masthead-player y #shorts-container
// └─ <ytd-player#player -> id="player" Existe 2 veces en DOM; en anuncios homepage #masthead-player
// 🟢 └─ <div#shorts-player.html5-video-player> -> Elemento que representa el Short activo (video actual)
// └─ <div.html5-video-container> -> Existe 2 veces en DOM; una dentro de #movie_player (Por miniplayer) y la otra en #shorts-player (puede existir igual en anuncios homepage #masthead-player)
// └─ <video.video-stream html5-main-video> -> Video activo (Existe 2 veces en DOM; una dentro de #movie_player > div.html5-video-container (Por miniplayer) y la otra en #shorts-player > div.html5-video-container) (puede existir igual en anuncios homepage #masthead-player > div.html5-video-container)
// === MINIPLAYER / (homepage) ===
// <ytd-miniplayer.ytdMiniplayerComponentHost.ytdMiniplayerComponentVisible> -> Aqui lo importan es clase .ytdMiniplayerComponentVisible que indica que miniplayer esta activo y visible
// └─ <div.ytdMiniplayerComponentContent>
// └─ <yt-draggable.ytDraggableComponentHost.ytdMiniplayerComponentDraggable>
// └─ <ytd-miniplayer-player-container.ytdMiniplayerPlayerContainerHost>
// └─ <div#player-container.ytdMiniplayerPlayerContainerPlayerContainer>
// └─ <ytd-player#ytd-player.style-scope.ytd-watch-flexy>
// └─ <div#container.style-scope.ytd-player>
// 🟢 └─ <div#movie_player.html5-video-player.ytp-transparent.ytp-exp-bottom-control-flexbox.ytp-modern-caption.ytp-exp-ppp-update.ytp-livebadge-color.ytp-grid-scrollable.ytp-cards-teaser-dismissible.ytp-hide-info-bar.ytp-disable-bottom-gradient.ytp-delhi-modern.ytp-delhi-modern-icons.ytp-delhi-horizontal-volume-controls.ytp-delhi-modern-compact-controls.ad-created.ytp-fit-cover-video.ytp-heat-map.ytp-autonav-endscreen-cancelled-state.ytp-menu-shown.ytp-player-minimized.ytp-xsmall-width-mode.ytp-autohide.playing-mode>
// └─ <div.html5-video-container>
// └─ <video.video-stream.html5-main-video> -> Video activo
// === INLINE PREVIEWS / (homepage) ===
// └─ <div#video-preview.style-scope.ytd-app>
// └─ <ytd-video-preview.style-scope.ytd-app>
// └─ <div#video-preview-container.style-scope.ytd-video-preview>
// └─ <div#media-container.style-scope.ytd-video-preview>
// └─ <a#media-container-link.yt-simple-endpoint.style-scope.ytd-video-preview>
// └─ <div#player-container-wrapper.style-scope.ytd-video-preview>
// └─ <div#player-container.style-scope.ytd-video-preview>
// └─ <ytd-player#inline-player.style-scope.ytd-video-preview>
// └─ <div#container.style-scope.ytd-player>
// 🟢 └─ <div#inline-preview-player.html5-video-player.ytp-hide-controls.ytp-exp-bottom-control-flexbox.ytp-modern-caption.ytp-exp-ppp-update.ytp-livebadge-color.ytp-delhi-modern-compact-controls.ytp-delhi-modern.ytp-delhi-modern-icons.ytp-delhi-horizontal-volume-controls.ytp-fit-cover-video.ytp-cards-teaser-dismissible.ytp-hide-info-bar.ytp-xsmall-width-mode.ytp-autonav-endscreen-cancelled-state.playing-mode.ytp-autohide.ytp-autohide-active>
// └─ <div.html5-video-container>
// └─ <video.video-stream.html5-main-video>
const selector = {
class: c => `.${c}`,
id: id => `#${id}`,
attr: a => `[${a}]`,
element: e => e
};
// ELEMENTS (Elementos simples <element></element>)
const ELEMENTS = {
// === SHORTS ===
YTD_SHORTS: 'ytd-shorts',
REEL_VIDEO_RENDERER: 'ytd-reel-video-renderer', // Elemento que contiene el video de Shorts Activo
// === MINIPLAYER ===
MINIPLAYER_ELEMENT: 'ytd-miniplayer',
// === INLINE PREVIEW ===
INLINE_PREVIEW_ELEMENT: 'ytd-video-preview', // Elemento principal del inline preview
// === RICH GRID RENDERER ===
RICH_GRID_RENDERER: 'ytd-rich-grid-renderer', // Elemento que contiene la grilla de videos
}
// CLASES (Añadir . antes de cada clase con S.CLASSES)
const CLASSES = {
// Se usan en todos los tipos de videos
HTML5_VIDEO_PLAYER: 'html5-video-player', // Clase que acompaña a IDs de elementos comunmente; #movie_player (videos y miniplayer), #shorts-player y #inline-preview-player
HTML5_VIDEO_CONTAINER: 'html5-video-container',
// Clases que acompañan a elemento <video>
HTML5_VIDEO_STREAM: 'video-stream',
HTML5_MAIN_VIDEO: 'html5-main-video',
// === MINIPLAYER ===
MINIPLAYER_COMPONENT_VISIBLE: 'ytdMiniplayerComponentVisible', // Clase que se agrega al documento cuando el miniplayer está visible
// === INLINE PREVIEW ===
INLINE_PREVIEW_UI: 'ytp-inline-preview-ui',
INLINE_PREVIEW_OVERLAY: 'ytd-thumbnail-overlay-inline-playback-renderer',
// Estados del player inline
INLINE_PREVIEW_PLAYING_MODE: 'playing-mode', // Player está reproduciendo
INLINE_PREVIEW_BUFFERING_MODE: 'buffering-mode', // Player está cargando
INLINE_PREVIEW_UNSTARTED_MODE: 'unstarted-mode', // Player no ha iniciado
};
// IDs (Añadir # antes de cada ID con S.IDs)
const IDs = {
// === VIDEOS ===
MOVIE_PLAYER: 'movie_player',
// === SHORTS ===
SHORTS_CONTAINER: 'shorts-container',
SHORTS_VIDEO_CONTAINER: 'short-video-container',
SHORTS_PLAYER: 'shorts-player',
METAPANEL: 'metapanel', // Panel de información del short (nombre canal, boton sub, descripción, etc)
METADATA_CONTAINER: 'metadata-container', // Alternativa moderna para el metapanel
// === INLINE PREVIEW ===
VIDEO_PREVIEW_MAIN_CONTAINER: 'video-preview',
VIDEO_PREVIEW_CONTAINER: 'video-preview-container',
INLINE_PREVIEW_PLAYER: 'inline-preview-player',
};
// ATRIBUTOS (Dependiendo del uso será con corchetes -> `[${ATTRIBUTES.ALGO}]` o sin corchetes -> `${ATTRIBUTES.ALGO}`)
const ATTRIBUTES = {
// === MINIPLAYER ===
MINIPLAYER_ACTIVE_ATTR: 'miniplayer-is-active', // Atributo que se agrega al documento cuando el miniplayer está visible !!document.querySelector('body > ytd-app[miniplayer-is-active]')
// === INLINE PREVIEW ===
INLINE_PREVIEW_ACTIVE: 'active', // Atributo cuando el preview está activo
INLINE_PREVIEW_PLAYING: 'playing', // Atributo cuando el preview está reproduciendo
INLINE_PREVIEW_HIDDEN: 'hidden', // Atributo cuando el preview está oculto/inactivo
};
/*
'ytd-rich-grid-renderer', // Grid de videos en home
'ytd-video-renderer', // Video individual
'ytd-playlist-video-renderer', // Videos en playlist
'ytd-compact-video-renderer', // Videos relacionados
'ytd-reel-video-renderer', // Shorts
'#contents', // Contenedor general
'ytd-watch-next-secondary-results-renderer' // Videos relacionados en watch
*/
// === SELECTORES COMPUESTOS ===
const S = {
ELEMENTS: Object.fromEntries(
Object.entries(ELEMENTS).map(([k, v]) => [k, selector.element(v)])
),
CLASSES: Object.fromEntries(
Object.entries(CLASSES).map(([k, v]) => [k, selector.class(v)])
),
IDS: Object.fromEntries(
Object.entries(IDs).map(([k, v]) => [k, selector.id(v)])
),
ATTR: Object.fromEntries(
Object.entries(ATTRIBUTES).map(([k, v]) => [k, selector.attr(v)])
)
};
/**
* ============================================================
* CENTRALIZED QUERYSELECTOR HELPERS
* ============================================================
* Utilidades para obtener elementos del DOM usando:
* - Selectores centralizados (IDs y ATTRIBUTES)
* - Sistema de caché en memoria con TTL (Time To Live)
*
* Objetivo:
* Reducir llamadas repetidas a document.querySelector y
* mejorar rendimiento en accesos frecuentes.
*/
const DOMHelpers = (() => {
/**
* Caché interna de elementos DOM.
* @type {Map<string, { ts: number, value: any }>}
*
* value → elemento encontrado
* ts → timestamp en milisegundos de cuando fue cacheado
*/
const cache = new Map();
/**
* Tiempo de vida por defecto del caché (ms)
* @type {number}
*/
const DEFAULT_TTL_MS = 125;
/**
* Obtiene un valor cacheado o lo recalcula si expiró.
*
* @template T
* @param {string} key Clave única de caché.
* @param {() => T} getter Función que obtiene el valor si no existe o expiró.
* @param {number} [ttlMs=DEFAULT_TTL_MS] Tiempo de vida en milisegundos.
* @returns {T} Valor cacheado o recién calculado.
*/
const get = (key, getter, ttlMs = DEFAULT_TTL_MS) => {
const now = Date.now();
const entry = cache.get(key);
if (entry && (now - entry.ts) <= ttlMs) {
// Verificar que el nodo siga conectado
if (!(entry.value instanceof Element) || entry.value.isConnected) {
return entry.value;
}
}
const value = getter();
cache.set(key, { ts: now, value });
return value;
};
/** @param {string} [prefix] */
const clear = (prefix) => {
if (!prefix) { cache.clear(); return; }
for (const k of cache.keys()) { if (k.startsWith(prefix)) cache.delete(k); }
};
return {
/**
* Obtiene el contenedor principal del reproductor normal.
* @returns {Element|null} Contenedor #movie_player (o null si no existe).
*/
getWatchPlayer: () =>
get('watchPlayer', () =>
document.querySelector(S.IDS.MOVIE_PLAYER)),
/**
* Obtiene el elemento de video del reproductor principal.
* @returns {HTMLVideoElement|null} Etiqueta <video> principal de YouTube.
*/
getWatchPlayerVideo: () =>
get('watchPlayerVideo', () =>
DOMHelpers.getWatchPlayer()?.querySelector(`video${S.CLASSES.HTML5_VIDEO_STREAM}${S.CLASSES.HTML5_MAIN_VIDEO}`) ?? null
),
/**
* Obtiene el contenedor del reproductor de Shorts.
* @returns {Element|null} Contenedor de Shorts (o null si no existe).
*/
getShortsPlayer: () =>
get('shortsPlayer', () =>
document.querySelector(S.IDS.SHORTS_PLAYER)),
/**
* Obtiene el elemento de video del reproductor de Shorts.
* @returns {HTMLVideoElement|null} Etiqueta <video> del reproductor de Shorts.
*/
getShortsPlayerVideo: () =>
get('shortsPlayerVideo', () =>
DOMHelpers.getShortsPlayer()?.querySelector(`video${S.CLASSES.HTML5_VIDEO_STREAM}${S.CLASSES.HTML5_MAIN_VIDEO}`) ?? null
),
/**
* Comprueba o devuelve la instancia de Miniplayer especificando si esta posee sus componentes visuales activos
* que validan que el Miniplayer está funcionalmente abierto.
* @returns {Element|null} Elemento validado con atributos en activo, o null de no encontrarse.
*/
getMiniplayerElementActive: () =>
get('miniplayerElementActive', () => {
const miniContainer = document.querySelector(S.ELEMENTS.MINIPLAYER_ELEMENT);
if (!miniContainer) return null;
// Usamos .matches() porque S.CLASSES.MINIPLAYER_COMPONENT_VISIBLE y S.ATTR.MINIPLAYER_ACTIVE_ATTR
// contienen selectores CSS (. y []) que no son compatibles con classList.contains() o hasAttribute().
const isVisible = miniContainer.matches(S.CLASSES.MINIPLAYER_COMPONENT_VISIBLE) ||
document.querySelector('ytd-app')?.matches(S.ATTR.MINIPLAYER_ACTIVE_ATTR) ||
isVisiblyDisplayed(miniContainer);
return isVisible ? miniContainer : null;
}),
/**
* Obtiene el elemento reproductor interno alojado en el Miniplayer.
* @returns {Element|null} Player en el miniplayer.
*/
getMiniplayerPlayer: () =>
get('miniplayerPlayer', () =>
DOMHelpers.getMiniplayerElementActive()
?.querySelector(S.IDS.MOVIE_PLAYER) ?? null
),
/**
* Obtiene el elemento de video del reproductor interno alojado en el Miniplayer.
* @returns {HTMLVideoElement|null} Etiqueta <video> del reproductor interno del Miniplayer.
*/
getMiniplayerPlayerVideo: () =>
get('miniplayerPlayerVideo', () =>
DOMHelpers.getMiniplayerPlayer()?.querySelector('video') ?? null
),
/**
* Obtiene el elemento reproductor interno alojado en el Inline Preview.
* @returns {Element|null} Player en el inline preview.
*/
getInlinePreviewPlayer: () =>
get('inlinePreviewPlayer', () =>
document.querySelector(S.IDS.INLINE_PREVIEW_PLAYER)),
/**
* Obtiene el elemento de video del reproductor interno alojado en el Inline Preview.
* @returns {HTMLVideoElement|null} Etiqueta <video> del reproductor interno del Inline Preview.
*/
getInlinePreviewPlayerVideo: () =>
get('inlinePreviewPlayerVideo', () =>
DOMHelpers.getInlinePreviewPlayer()?.querySelector('video') ?? null
),
/**
* Metodo para poder realizar consultas libres desde el exterior aprovechando el cache (Ej: Anuncios).
* Garantiza eficiencia sin volver el objeto gigantesco.
*
* @template T
* @param {string} key Nombre unico para la llave en la memoria Map interna.
* @param {() => T} getter Metodo que devolvera un valor si el TTL se ha rebasado o llave no existe.
* @param {number} ttlMs Cantidad en MS de retencion para este guardado especifico (Sobrescribe defecto: 125ms).
* @returns {T} Resultado evaluado al instante o guardado.
*
* @example
* // Buscar si el botón de "Saltar anuncio" existe en el DOM (caché válido por 300ms)
* const skipButton = DOMHelpers.get('ad:SkipButton', () => document.querySelector('.ytp-ad-skip-button'), 300);
*
* @example
* // Buscar una propiedad de video compleja sin afectar el rendimiento si se llama muchas veces seguidas
* const movieTitle = DOMHelpers.get('movie:title', () => document.querySelector('h1.title')?.textContent?.trim(), 500);
*/
get,
/**
* Elimina manualmente un elemento específico del caché por su clave exacta.
* Utilícese cuando se está seguro de qué nodo actualizar de forma individual (Por defecto: 125ms de vida).
*
* @param {string} key - Clave exacta usada al llamar `.get()`.
*
* @example
* // Fuerza la actualización del registro de la caja de anuncios
* DOMHelpers.removeExact('ad:skipButton');
* // Si en la memoria tienes guardado "ad:skipButton", "ad:banner" y "ad:video",
* // la función solo borrará "ad:skipButton", y dejará el resto intacto.
*/
removeExact: (key) => cache.delete(key),
/**
* Elimina en masa elementos del caché que compartan la misma agrupación (prefijo).
* Ideal cuando ocurre un cambio rotundo de estado, como un cambio de video.
*
* @param {string} prefix - Prefijo en común de los elementos guardados.
*
* @example
* // Cuando pasamos al siguiente video, borramos todos los identificadores viejos relacionados con anuncios
* DOMHelpers.removeByCategory('ad:');
* // Si en la memoria tienes guardado "ad:skipButton", "ad:banner" y "ad:video",
* // al ejecutar este código en masa, como todos empiezan por "ad:", borrará los tres de un solo golpe.
*/
removeByCategory: (prefix) => clear(prefix),
/**
* Formatea por completo el registro en memoria del caché perdiendo toda referencia existente.
*
* @example
* // Al ocurrir la desinstalación en vivo del UserScript, vaciamos la memoria caché
* DOMHelpers.clearAll();
*/
clearAll: () => clear(),
/**
* Encuentra el ancestro más cercano que coincida con el selector, atravesando fronteras de Shadow DOM.
* @param {Element|null|undefined} node Elemento desde el cual empezar la búsqueda.
* @param {string} selector Selector CSS a buscar.
* @returns {Element|null} Ancestros coincidente o null.
*/
closestComposed: (node, selector) => {
if (!node || !selector) return null;
let current = node;
while (current) {
if (current instanceof Element && current.matches(selector)) return current;
const parent = current.parentNode;
if (parent instanceof ShadowRoot) {
current = parent.host;
} else {
current = parent;
}
}
return null;
}
};
})();
// ------------------------------------------
// 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 escapeHTML(replaceParams(TRANSLATIONS[fallbackLang][key], actualParams));
}
// Si no hay ni en el idioma por defecto, devolver el valor por defecto
return escapeHTML(replaceParams(actualDefaultText, actualParams));
}
return escapeHTML(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 }) {
logLog('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) {
logError('setLanguage', 'Error persistiendo idioma', e);
}
}
logLog('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] : []);
logLog('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) {
logLog('detectBrowserLanguage', 'matched by prefix:', matched);
return matched;
}
}
logWarn(`Idioma del navegador '${primaryLang}' no soportado, usando default.`);
return CONFIG.defaultSettings.language;
}
// ------------------------------------------
// MARK: 🎨 Styles
// ------------------------------------------
function injectStyles() {
if (document.querySelector('#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-bg-secondary: #dadada;
--ypp-bg-secondary-hover: #919191ff;
--ypp-bg-tertiary: #f5f5f5;
--ypp-white: #ffffff;
--ypp-black: #000000;
--ypp-muted: #555555;
--ypp-light: #888888;
--ypp-dark: #1b1b1b;
--ypp-danger: #dc2626;
--ypp-danger-dark: #b91c1c;
--ypp-warning: #a96500;
--ypp-warning-dark: #8a5200;
--ypp-success: #16a34a;
--ypp-success-dark: #15803d;
--ypp-info: #0891b2;
--ypp-info-dark: #0e7490;
--ypp-overlay: rgba(0, 0, 0, 0.4);
--ypp-toast: #333333;
--ypp-primary: #2563eb;
--ypp-primary-dark: #1e40af;
--ypp-secondary: #494949;
--ypp-secondary-dark: #272727;
--ypp-border: #cccccc;
--ypp-bg-time-display: rgba(17, 17, 17, 0.45);
/* Tipografía */
--ypp-text: #1b1b1bff;
--ypp-text-secondary: #393939;
--ypp-text-highlight: #014092;
--ypp-font-base: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
/*
* Tokens semánticos para texto
* Garantizan contraste AAA (≥7:1) sobre el fondo del tema correspondiente.
* Usar estos tokens cuando el color sea el del texto, no del fondo del elemento.
*/
--ypp-primary-text: #1a4ab5; /* #2563eb oscurecido, 7.1:1 sobre #fff */
--ypp-success-text: #166534; /* 7.3:1 sobre #fff */
--ypp-warning-text: #7c4a00; /* 7.2:1 sobre #fff */
--ypp-danger-text: #991b1b; /* 7.1:1 sobre #fff */
--ypp-info-text: #0c547a; /* 7.4:1 sobre #fff */
/* Espaciado */
--ypp-spacing-sm: 0.5rem;
--ypp-spacing-md: 1rem;
--ypp-spacing-lg: 1.5rem;
/* 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;
--ypp-warning-dark: #8a5200;
--ypp-info: #0891b2;
--ypp-info-dark: #0e7490;
}
html[dark],
body.dark-theme {
--ypp-bg: #0f0f0f;
--ypp-bg-secondary: #1a1a1a;
--ypp-bg-secondary-hover: #303030;
--ypp-bg-tertiary: #2a2a2a;
--ypp-muted: #aaaaaa;
--ypp-light: #251a1aff;
--ypp-danger: #720000ff;
--ypp-danger-dark: #a81313ff;
--ypp-warning: #e28700;
--ypp-warning-dark: #c47700;
--ypp-success: #15803d;
--ypp-success-dark: #166534;
--ypp-info: #0e7490;
--ypp-info-dark: #155e75;
--ypp-primary: #004683ff;
--ypp-primary-dark: #136fadff;
--ypp-border: #303030;
--ypp-overlay: rgba(0, 0, 0, 0.8);
--ypp-input: #1a1a1a;
--ypp-input-border: #303030;
/* Tipografía */
--ypp-text: #ececec;
--ypp-text-secondary: #c0c0c0;
--ypp-text-highlight: #3ea6ff;
--ypp-input-focus: #065fd4;
/*
* Tokens semánticos para texto en tema oscuro.
* Garantizan contraste AAA (≥7:1) sobre --ypp-bg: #0f0f0f.
*/
--ypp-primary-text: #5b9bff; /* 7.2:1 sobre #0f0f0f */
--ypp-success-text: #4ade80; /* 7.4:1 sobre #0f0f0f */
--ypp-warning-text: #fbbf24; /* 7.8:1 sobre #0f0f0f */
--ypp-danger-text: #f87171; /* 7.1:1 sobre #0f0f0f */
--ypp-info-text: #38bdf8; /* 7.3:1 sobre #0f0f0f */
}
.ypp-shadow-sm {
box-shadow:
0.4px 0.4px 1.3px rgba(0, 0, 0, 0.05),
1px 1px 3.5px rgba(0, 0, 0, 0.07),
2px 2px 7px rgba(0, 0, 0, 0.09),
4px 4px 14px rgba(0, 0, 0, 0.11),
10px 10px 30px rgba(0, 0, 0, 0.15);
-webkit-box-shadow:
0.4px 0.4px 1.3px rgba(0, 0, 0, 0.05),
1px 1px 3.5px rgba(0, 0, 0, 0.07),
2px 2px 7px rgba(0, 0, 0, 0.09),
4px 4px 14px rgba(0, 0, 0, 0.11),
10px 10px 30px rgba(0, 0, 0, 0.15);
}
.ypp-shadow-md {
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-shadow-lg {
box-shadow:
1.2px 1.2px 4px rgba(0, 0, 0, 0.07),
3px 3px 10px rgba(0, 0, 0, 0.1),
6px 6px 20px rgba(0, 0, 0, 0.13),
12px 12px 40px rgba(0, 0, 0, 0.16),
40px 40px 120px rgba(0, 0, 0, 0.25);
-webkit-box-shadow:
1.2px 1.2px 4px rgba(0, 0, 0, 0.07),
3px 3px 10px rgba(0, 0, 0, 0.1),
6px 6px 20px rgba(0, 0, 0, 0.13),
12px 12px 40px rgba(0, 0, 0, 0.16),
40px 40px 120px rgba(0, 0, 0, 0.25);
}
.ypp-m0 {
margin: 0 !important;
}
.ypp-bg-secondary {
background-color: var(--ypp-bg-secondary) !important;
}
.ypp-link {
color: var(--ypp-text-highlight);
text-decoration: none;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--ypp-spacing-sm);
&:hover {
text-decoration: underline;
}
}
.ypp-svgFolderIcon,
.ypp-svgSaveIcon,
.ypp-svgPinIcon,
.ypp-svgTimerIcon,
.ypp-svgPlayOrPauseIcon,
.ypp-svgPlaylistRemove {
vertical-align: middle;
height: 100%;
margin: 0 0px 2px 0px;
}
.ypp-d-flex {
display: -webkit-box !important;
display: -ms-flexbox !important;
display: flex !important;
}
.ypp-d-none {
display: none !important;
}
/* =========================
Contenedores y Overlays
========================= */
.ypp-overlay,
.ypp-modalOverlay {
position: fixed;
top: 0;
left: 0;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-ms-flex-align: 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%;
-webkit-transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
background: var(--ypp-bg);
border: 1px solid var(--ypp-border);
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 85vh;
color: var(--ypp-text);
z-index: var(--ypp-z-modal);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
opacity: 0;
-webkit-transform: translate(-50%, -50%) translateY(20px) scale(0.95);
-ms-transform: translate(-50%, -50%) translateY(20px) scale(0.95);
transform: translate(-50%, -50%) translateY(20px) scale(0.95);
-webkit-animation: videosModalSlideIn 0.3s ease-out forwards;
animation: videosModalSlideIn 0.3s ease-out forwards;
}
@-webkit-keyframes videosModalSlideIn {
to {
opacity: 1;
-webkit-transform: translate(-50%, -50%) translateY(0) scale(1);
transform: translate(-50%, -50%) translateY(0) scale(1);
}
}
@keyframes videosModalSlideIn {
to {
opacity: 1;
-webkit-transform: translate(-50%, -50%) translateY(0) scale(1);
transform: translate(-50%, -50%) translateY(0) scale(1);
}
}
/* =========================
Boton group script en barras reproducción
========================= */
.ypp-time-display,
.ypp-shorts-time-display {
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
/* justify-content: center; */
-webkit-transition: all 0.2s ease;
-o-transition: all 0.2s ease;
transition: all 0.2s ease;
background: var(--ypp-bg-time-display);
border-radius: var(--ypp-spacing-lg);
overflow: hidden;
padding: 0;
gap: 0;
height: 28px;
min-width: -webkit-fit-content;
min-width: -moz-fit-content;
min-width: fit-content;
-webkit-box-ordinal-group: 4 !important;
-ms-flex-order: 3 !important;
order: 3 !important; /* para que se muestre a la derecha en livestreams /watch */
svg {
width: 16px;
height: 16px;
margin: 0;
}
&:hover {
background: var(--ypp-black);
color: var(--ypp-text)
}
}
/* Livestreams */
.ytp-delhi-modern .ytp-time-wrapper:not(.ytp-miniplayer-ui *) {
min-width: 0;
position: relative;
display: -webkit-box !important;
display: -ms-flexbox !important;
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, 0.3));
text-shadow: 0 0 2px #000;
-webkit-box-align: center;
-ms-flex-align: center;
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 */
#movie_player .ytp-delhi-modern .ytp-time-wrapper .ytp-time-current,
#movie_player .ytp-delhi-modern .ytp-time-wrapper .ytp-time-separator,
#movie_player .ytp-delhi-modern .ytp-time-wrapper .ytp-time-duration {
-webkit-box-ordinal-group: 2;
-ms-flex-order: 1;
order: 1;
/* El tiempo debe estar visible para que YouTube calcule bien los offsets de click */
display: inline-block !important;
}
#movie_player .ytp-delhi-modern .ytp-time-wrapper .ytp-live-badge,
#movie_player .ytp-delhi-modern .ytp-time-wrapper .live-badge {
-webkit-box-ordinal-group: 3 !important;
-ms-flex-order: 2 !important;
order: 2 !important;
margin-left: 4px;
/* Asegurar que el badge sea clickeable */
pointer-events: auto !important;
cursor: pointer !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 Shorts */
.ypp-shorts-time-display {
margin: 4px auto 0;
}
/* Fallback flotante para Shorts cuando el metapanel no se encuentra */
.ypp-shorts-time-display.ypp-floating {
position: absolute;
left: 50%;
-webkit-transform: translateX(-50%);
-ms-transform: translateX(-50%);
transform: translateX(-50%);
bottom: 64px;
/* por encima de botones de acción */
z-index: var(--ypp-z-toast, 10001);
pointer-events: auto;
}
/* Estilo específico para Miniplayer */
.ypp-miniplayer-time-display {
pointer-events: auto;
}
.ytdMiniplayerComponentVisible .ytp-time-wrapper.ytp-time-wrapper-delhi {
display: -webkit-box !important;
display: -ms-flexbox !important;
display: flex !important;
/* Para poner botones al lado de tiempo */
-webkit-box-align: center !important;
-ms-flex-align: center !important;
align-items: center !important;
gap: 8px !important;
margin-bottom: 10px !important;
/* Para que no se tape con heatmaps */
}
.ytdMiniplayerComponentVisible .ytp-live-badge {
-webkit-box-ordinal-group: 3 !important;
-ms-flex-order: 2 !important;
order: 2 !important;
margin: 0 17px 0 0;
}
/* Estilo específico para Inline Previews */
.ypp-inline-preview-time-display {
position: absolute;
bottom: 77px;
left: 8px;
z-index: var(--ypp-z-toast, 10001);
}
/* =========================
Botones dentro de ypp-time-display
========================= */
.ypp-btn-history,
.ypp-btn-save {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
background: transparent;
border: none;
color: var(--ypp-white);
cursor: pointer;
-webkit-transition: background 0.2s;
-o-transition: background 0.2s;
transition: background 0.2s;
height: 100%;
-webkit-box-shadow: none;
box-shadow: none;
&:hover {
background: var(--ypp-primary);
}
&:active {
background: var(--ypp-primary-dark);
}
&:focus-visible {
background: var(--ypp-primary-dark);
}
}
.ypp-btn-history {
padding: 0 7px 0 10px;
}
.ypp-btn-save {
padding: 0 7px 0 7px;
}
.ypp-btn-save-hover-color-when-saved {
&:hover {
background: var(--ypp-success);
}
&:active {
background: var(--ypp-success-dark);
}
&:focus-visible {
background: var(--ypp-success-dark);
}
}
.ypp-time-display-message {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--ypp-spacing-sm);
padding: 0 12px 0 7px;
white-space: nowrap;
overflow: hidden;
cursor: default;
text-shadow: none !important;
/* max-width: 180px; */
font-size: var(--ypp-font-size-sm);
font-weight: var(--ypp-font-weight-medium);
color: var(--ypp-white);
border-left: 2px solid var(--ypp-bg);
}
/* =========================
Header, Footer, Layout
========================= */
.ypp-header,
.ypp-modalHeader {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 6px 12px;
border-bottom: 1px solid var(--ypp-border);
-ms-flex-negative: 0;
flex-shrink: 0;
}
.ypp-filters-top-row {
display: flex;
align-items: center;
gap: var(--ypp-spacing-md);
padding: var(--ypp-spacing-md) var(--ypp-spacing-lg);
border-bottom: 1px solid var(--ypp-border);
}
.ypp-search-container {
flex: 1;
min-width: 0;
}
.ypp-filters-toggle-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--ypp-border);
border-radius: 6px;
background: var(--ypp-bg);
color: var(--ypp-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
font-size: 1.3rem;
white-space: nowrap;
&:hover {
background: var(--ypp-primary);
color: var(--ypp-white);
}
&.active {
background: var(--ypp-primary);
color: var(--ypp-white);
border-color: var(--ypp-primary);
}
}
.ypp-active-filter-badge {
position: absolute;
top: -10px;
right: -5px;
display: flex;
align-items: center;
justify-content: center;
background: var(--ypp-danger);
color: white;
font-size: 1rem;
font-weight: bold;
min-width: 18px;
height: 18px;
border-radius: 9px;
padding: 0 5px;
border: 2px solid var(--ypp-bg);
}
.ypp-filters-advanced {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out, padding 0.3s ease;
background: var(--ypp-bg-secondary);
border-bottom: 1px solid var(--ypp-border);
display: flex;
flex-direction: column;
gap: var(--ypp-spacing-md);
padding: 0 var(--ypp-spacing-lg);
&.expanded {
max-height: 200px; /* Suficiente para los filtros */
min-height: 165px;
padding: var(--ypp-spacing-md) var(--ypp-spacing-lg);
overflow: auto;
}
}
.ypp-filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--ypp-spacing-md);
}
.ypp-range-filter-section {
display: flex;
flex-direction: column;
gap: 4px;
border: 1px solid var(--ypp-border);
padding: var(--ypp-spacing-sm);
border-radius: var(--ypp-spacing-sm);
width: 100%;
}
.ypp-range-controls {
display: flex;
gap: 8px;
align-items: center;
flex-direction: column;
}
.ypp-range-inputs-group {
display: flex;
gap: 8px;
align-items: center;
}
.ypp-range-input {
display: flex;
gap: 8px;
align-items: center;
max-width: 100px;
}
.ypp-range-filters-group {
display: flex;
/* flex-wrap: wrap; */
gap: var(--ypp-spacing-md);
padding-top: var(--ypp-spacing-sm);
border-top: 1px solid var(--ypp-border);
}
.ypp-footer {
padding: var(--ypp-spacing-md) var(--ypp-spacing-lg);
border-top: 2px solid var(--ypp-border);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: var(--ypp-spacing-sm);
z-index: 10;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.ypp-footer-row {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--ypp-spacing-sm);
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
.ypp-footer-row-bottom {
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
}
#video-list-container {
-webkit-box-flex: 1;
-ms-flex-positive: 1;
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: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
#ypp-virtual-scroller-container {
-webkit-box-flex: 1;
-ms-flex-positive: 1;
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%;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.ypp-virtual-loading {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
padding: var(--ypp-spacing-lg);
color: var(--ypp-text);
font-size: 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-secondary);
z-index: 10;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.ypp-settingsContent {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: var(--ypp-spacing-lg);
max-height: 60vh;
overflow-y: auto;
}
.ypp-settings-footer {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: end;
-ms-flex-pack: end;
justify-content: flex-end;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: 12px;
padding: 16px 24px;
background: var(--ypp-dark);
color: var(--ypp-light);
border-radius: 0 0 12px 12px;
-ms-flex-negative: 0;
flex-shrink: 0;
margin-top: auto;
}
.ypp-settings-main-section {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: var(--ypp-spacing-md);
background: var(--ypp-border);
border-radius: 6px;
padding: 10px;
}
.ypp-manual-saving-options {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
}
.ypp-automatic-saving-options {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
border-radius: 6px;
}
.ypp-settings-second-level-section {
background: var(--ypp-bg-secondary);
border-radius: 6px;
padding: var(--ypp-spacing-md);
gap: var(--ypp-spacing-md);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
border: 1px solid var(--ypp-bg);
}
.ypp-settings-third-level-section {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: var(--ypp-spacing-sm);
background: var(--ypp-bg-tertiary);
border-radius: 6px;
padding: var(--ypp-spacing-sm);
}
.ypp-github-tab-content {
background: var(--ypp-bg-secondary);
padding: var(--ypp-spacing-md);
gap: var(--ypp-spacing-md);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
border: 1px solid var(--ypp-bg);
border-top: none;
border-radius: 0 0 6px 6px;
}
.ypp-label-save-type {
margin: 0 0 0 10px;
}
/* =========================
Tipografía
========================= */
.ypp-emptyMsg {
text-align: center;
color: var(--ypp-muted);
padding: 40px 24px;
font-size: 1.4rem;
}
.ypp-playlistTitle {
margin: 8px 0 4px;
color: var(--ypp-primary-text);
cursor: pointer;
text-decoration: none;
display: block;
font-size: 1.2rem;
font-weight: 500;
}
.ypp-playlistTitle:hover {
color: var(--ypp-primary-dark);
text-decoration: underline;
}
.ypp-titleLink {
font-weight: 600;
font-size: 1.4rem;
color: var(--ypp-primary-text);
text-decoration: none;
display: block;
max-height: 40px;
overflow: auto;
/* max-width: 90%; */
&:hover {
color: var(--ypp-primary-dark);
text-decoration: underline;
}
svg {
margin: 0 0 -4px 0;
}
}
.ypp-author,
.ypp-views {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--ypp-spacing-sm);
font-size: 1.1rem;
color: var(--ypp-muted);
}
.ypp-watched-count {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.ypp-author-link {
color: var(--ypp-primary-text);
text-decoration: none;
-webkit-transition: color 0.2s;
-o-transition: color 0.2s;
transition: color 0.2s;
&:hover {
color: var(--ypp-primary-dark);
text-decoration: underline;
}
svg {
width: 1.1rem;
height: 1.1rem;
}
}
.ypp-timestamp,
.ypp-progressInfo {
font-size: 1.3rem;
margin-top: 4px;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--ypp-spacing-sm);
}
.ypp-timestamp {
color: var(--ypp-muted);
}
.ypp-timestamp.forced {
color: var(--ypp-primary-text);
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: var(--ypp-success-text);
font-weight: bold;
background: -o-linear-gradient(left,
var(--ypp-primary-dark) 0%,
var(--ypp-success) 100%);
background: -webkit-gradient(linear,
left top, right top,
from(var(--ypp-primary-dark)),
to(var(--ypp-success)));
background: linear-gradient(90deg,
var(--ypp-primary-dark) 0%,
var(--ypp-success) 100%);
-webkit-background-clip: text;
background-clip: text;
}
/* =========================
Video List
========================= */
.ypp-videoWrapper {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
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);
-webkit-box-sizing: border-box;
box-sizing: border-box;
background: var(--ypp-bg);
}
.ypp-videoWrapper.playlist-item {
border-radius: 6px;
-webkit-transition: all 0.2s ease;
-o-transition: all 0.2s ease;
transition: all 0.2s ease;
height: 140px !important;
.ypp-btn-outlined,
.ypp-btn-delete {
color: var(--ypp-black);
}
.ypp-btn-delete:hover {
color: var(--ypp-white);
}
}
.ypp-videoWrapper.regular-item {
/* background-color: var(--ypp-bg-secondary); */
border-left: 2px solid var(--ypp-border);
height: 120px !important; /* Altura estándar */
}
.ypp-playlist-indicator {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: start;
-ms-flex-align: start;
align-items: start;
margin: 4px 0;
font-size: 0.85em;
font-weight: bold;
background: var(--ypp-bg-secondary);
padding: 3px 8px;
border-radius: var(--ypp-spacing-sm);
/* white-space: nowrap; */
overflow: auto;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
max-width: 100%;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
max-height: 20px;
}
.ypp-videoWrapper {
overflow: hidden !important;
}
.ypp-protected-item {
border: 1px solid var(--ypp-warning) !important;
box-shadow: 0 0 8px rgba(242, 187, 65, 0.2);
}
.ypp-protected-item .ypp-thumb,
.ypp-protected-item .ypp-thumb-shorts {
border-color: var(--ypp-warning) !important;
}
.ypp-playlist-link {
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
color: var(--ypp-text);
opacity: 0.7;
-webkit-transition: opacity 0.2s ease;
-o-transition: opacity 0.2s ease;
transition: opacity 0.2s ease;
text-decoration: none;
overflow: hidden;
}
.ypp-playlist-link:hover {
opacity: 1;
}
.ypp-playlist-header {
height: 40px !important;
max-height: 40px !important;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
padding: 0 var(--ypp-spacing-md);
-webkit-box-sizing: border-box;
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: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: 8px;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
min-width: 0;
/* Necesario para que text-overflow funcione en flex child */
white-space: nowrap;
overflow: hidden;
-o-text-overflow: ellipsis;
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 {
-webkit-transition: all 0.2s ease;
-o-transition: all 0.2s ease;
transition: all 0.2s ease;
}
.ypp-video-checkbox {
min-width: 30px;
min-height: 30px;
margin: 0 10px 0 0;
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: -webkit-box;
display: -ms-flexbox;
display: flex;
gap: 10px;
margin-top: 10px;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
}
.ypp-thumb {
max-width: 155px;
max-height: 85px;
-o-object-fit: cover;
object-fit: cover;
border-radius: var(--ypp-spacing-md);
margin-right: var(--ypp-spacing-sm);
-ms-flex-negative: 0;
flex-shrink: 0;
}
.ypp-thumb-shorts {
max-width: 55px;
max-height: 85px;
-o-object-fit: cover;
object-fit: cover;
border-radius: var(--ypp-spacing-md);
margin-right: var(--ypp-spacing-sm);
-ms-flex-negative: 0;
flex-shrink: 0;
}
.ypp-infoDiv {
-webkit-box-flex: 1;
-ms-flex-positive: 1;
flex-grow: 1;
min-width: 0;
/* Permite que el contenedor se encoja correctamente */
}
.ypp-containerButtonsTime {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: 8px;
margin-left: auto;
}
.ypp-sort-select,
.ypp-filter-select {
background: var(--ypp-bg);
border: 1px solid var(--ypp-border);
color: var(--ypp-text-secondary);
padding: 8px 12px;
border-radius: 6px;
font-size: 1.3rem;
cursor: pointer;
-webkit-transition:
border-color 0.2s ease,
background-color 0.2s ease;
-o-transition:
border-color 0.2s ease,
background-color 0.2s ease;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
min-width: 0;
width: auto;
&:hover {
background: var(--ypp-bg);
}
&:active {
background: var(--ypp-bg);
}
}
.ypp-sort-select:focus,
.ypp-filter-select:focus {
outline: none;
border-color: var(--ypp-bg);
background: var(--ypp-bg);
}
.ypp-sort-select option,
.ypp-filter-select option {
background: var(--ypp-bg);
color: var(--ypp-text);
}
.ypp-search-input {
background: var(--ypp-bg);
border: 1px solid var(--ypp-border);
color: var(--ypp-text);
padding: 8px 12px;
border-radius: 6px;
font-size: 1.3rem;
-webkit-transition:
border-color 0.2s ease,
background-color 0.2s ease;
-o-transition:
border-color 0.2s ease,
background-color 0.2s ease;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
min-width: 200px;
&:hover {
background: var(--ypp-primary);
color: var(--ypp-white);
&::placeholder {
color: var(--ypp-white);
}
}
/* &:focus {
outline: none;
border-color: var(--ypp-primary);
background: var(--ypp-bg-secondary);
} */
/* &::-webkit-input-placeholder {
color: var(--ypp-text-secondary);
}
&::-moz-placeholder {
color: var(--ypp-text-secondary);
}
&:-ms-input-placeholder {
color: var(--ypp-text-secondary);
}
&::-ms-input-placeholder {
color: var(--ypp-text-secondary);
}
&::placeholder {
color: var(--ypp-text-secondary);
} */
}
/* =========================
Botones
========================= */
.ypp-btn {
display: -webkit-inline-box;
display: -ms-inline-flexbox;
display: inline-flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
padding: 5px 14px;
font-weight: 500;
font-size: 1.4rem;
border-radius: 8px;
cursor: pointer;
-webkit-transition: all 0.2s ease;
-o-transition: all 0.2s ease;
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-text);
}
.ypp-btn::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.1);
opacity: 0;
-webkit-transition: opacity 0.2s ease;
-o-transition: opacity 0.2s ease;
transition: opacity 0.2s ease;
}
.ypp-btn:hover::before {
opacity: 1;
}
.ypp-btn:active {
-webkit-transform: scale(0.98);
-ms-transform: scale(0.98);
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 var(--ypp-primary);
color: var(--ypp-text);
svg {
width: 16px;
height: 16px;
margin: 0;
}
&:hover {
background: rgba(6, 95, 212, 0.3);
}
&:focus {
background: var(--ypp-bg-secondary-hover);
color: var(--ypp-white);
}
}
.ypp-btn-primary {
background: var(--ypp-primary);
color: var(--ypp-white);
&:hover,
&:active {
background: var(--ypp-primary-dark);
}
}
.ypp-btn-view-saved-videos,
.ypp-save-button {
color: var(--ypp-white);
}
.ypp-save-button {
background: transparent;
border: 1px solid var(--ypp-success);
svg {
width: 16px;
height: 16px;
margin: 0;
}
&:hover {
background: rgba(22, 212, 6, 0.3);
}
&:active {
background: #008855;
}
}
.ypp-btn-secondary {
background: var(--ypp-secondary);
color: var(--ypp-white);
&:hover {
background: var(--ypp-secondary-dark);
}
&:active {
background: var(--ypp-secondary-dark);
}
}
.ypp-btn-delete {
background: transparent;
border: 1px solid var(--ypp-danger);
color: var(--ypp-text-secondary);
&:hover {
background: var(--ypp-danger);
color: var(--ypp-white);
}
&:active {
background: rgba(255, 68, 68, 0.2);
color: var(--ypp-text-secondary);
}
}
.ypp-btn-danger {
background: var(--ypp-danger);
color: var(--ypp-white);
&:hover,
&:active {
background: var(--ypp-danger-dark);
}
}
/* =========================
Variantes de color de botones
Uso: .ypp-btn + .ypp-btn-{variant} o .ypp-btn + .ypp-btn-outline-{variant}
Variantes disponibles: primary | danger | success | warning | info
========================= */
/* Solid - Success */
.ypp-btn-success {
background: var(--ypp-success);
color: var(--ypp-white);
&:hover {
background: var(--ypp-success-dark);
}
&:active {
background: var(--ypp-success-dark);
filter: brightness(0.85);
}
}
/* Solid - Warning */
.ypp-btn-warning {
background: var(--ypp-warning);
color: var(--ypp-white);
&:hover {
background: var(--ypp-warning-dark);
}
&:active {
background: var(--ypp-warning-dark);
filter: brightness(0.85);
}
}
/* Solid - Info */
.ypp-btn-info {
background: var(--ypp-info);
color: var(--ypp-white);
&:hover {
background: var(--ypp-info-dark);
}
&:active {
background: var(--ypp-info-dark);
filter: brightness(0.85);
}
}
/* Outline - Primary */
.ypp-btn-outline-primary {
background: transparent;
border: 1px solid var(--ypp-primary);
color: var(--ypp-primary);
&:hover {
background: var(--ypp-primary);
color: var(--ypp-white);
}
&:active {
background: var(--ypp-primary-dark);
color: var(--ypp-white);
}
}
/* Outline - Secondary */
.ypp-btn-outline-secondary {
background: transparent;
border: 1px solid var(--ypp-secondary);
color: var(--ypp-text);
&:hover {
background: var(--ypp-secondary-dark);
color: var(--ypp-white);
}
&:active {
background: var(--ypp-secondary-dark);
color: var(--ypp-white);
}
}
/* Outline - Danger */
.ypp-btn-outline-danger {
background: transparent;
border: 1px solid var(--ypp-danger);
&:hover {
background: var(--ypp-danger);
color: var(--ypp-white);
}
&:active {
background: var(--ypp-danger-dark);
color: var(--ypp-white);
}
}
/* Outline - Success */
.ypp-btn-outline-success {
background: transparent;
border: 1px solid var(--ypp-success);
color: var(--ypp-success);
&:hover {
background: var(--ypp-success);
color: var(--ypp-white);
}
&:active {
background: var(--ypp-success-dark);
color: var(--ypp-white);
}
}
/* Outline - Warning */
.ypp-btn-outline-warning {
background: transparent;
border: 1px solid var(--ypp-warning);
color: var(--ypp-warning);
&:hover {
background: var(--ypp-warning);
color: var(--ypp-white);
}
&:active {
background: var(--ypp-warning-dark);
color: var(--ypp-white);
}
}
/* Outline - Info */
.ypp-btn-outline-info {
background: transparent;
border: 1px solid var(--ypp-info);
color: var(--ypp-info);
&:hover {
background: var(--ypp-info);
color: var(--ypp-white);
}
&:active {
background: var(--ypp-info-dark);
color: var(--ypp-white);
}
}
/* =========================
Botones invariantes de tema
Siempre negros o siempre blancos,
independientemente del tema activo.
Uso: .ypp-btn + .ypp-btn-dark / .ypp-btn-light
.ypp-btn + .ypp-btn-outline-dark / .ypp-btn-outline-light
========================= */
/* Solid - Siempre oscuro */
.ypp-btn-dark {
background: #111111;
color: #ffffff;
border: 1px solid #111111;
&:hover {
background: #000000;
color: #ffffff;
}
&:active {
background: #333333;
color: #ffffff;
filter: brightness(0.9);
}
}
/* Solid - Siempre claro */
.ypp-btn-light {
background: #f4f4f5;
color: #111111;
border: 1px solid #e4e4e7;
&:hover {
background: #ffffff;
color: #000000;
border-color: #d4d4d8;
}
&:active {
background: #e4e4e7;
color: #111111;
filter: brightness(0.95);
}
}
/* Outline - Siempre oscuro */
.ypp-btn-outline-dark {
background: transparent;
border: 1px solid #111111;
color: #111111;
&:hover {
background: #111111;
color: #ffffff;
}
&:active {
background: #000000;
color: #ffffff;
}
}
/* Outline - Siempre claro */
.ypp-btn-outline-light {
background: transparent;
border: 1px solid #d4d4d8;
color: #f4f4f5;
&:hover {
background: #f4f4f5;
color: #111111;
}
&:active {
background: #ffffff;
color: #000000;
}
}
.ypp-btn-small {
padding: 8px;
width: 36px;
height: 36px;
min-height: 36px;
-ms-flex-negative: 0;
flex-shrink: 0;
border-radius: 18px;
}
.ypp-btn-close {
background: var(--ypp-bg);
border: 1px solid #303030;
color: var(--ypp-text);
&:hover {
background: var(--ypp-danger);
color: var(--ypp-white);
}
}
@-webkit-keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@-webkit-keyframes slideUp {
from {
opacity: 0;
-webkit-transform: translateY(20px) scale(0.95);
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
-webkit-transform: translateY(0) scale(1);
transform: translateY(0) scale(1);
}
}
@keyframes slideUp {
from {
opacity: 0;
-webkit-transform: translateY(20px) scale(0.95);
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
-webkit-transform: translateY(0) scale(1);
transform: translateY(0) scale(1);
}
}
/* =========================
Toasts
========================= */
.ypp-toast-container {
position: fixed;
top: var(--ypp-spacing-md);
right: var(--ypp-spacing-md);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: 0.5rem;
z-index: var(--ypp-z-toast);
pointer-events: none;
}
.ypp-toast {
position: relative;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
gap: 10px;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
background: var(--ypp-bg);
color: var(--ypp-text);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid var(--ypp-border);
font-size: 1.4rem;
max-width: 300px;
-webkit-animation: slideInRight 0.3s ease-out;
animation: slideInRight 0.3s ease-out;
-webkit-transition: opacity 0.2s ease;
-o-transition: opacity 0.2s ease;
transition: opacity 0.2s ease;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
pointer-events: auto;
overflow: hidden;
/* Para que la barra de progreso no se salga de los bordes redondeados */
}
.ypp-toast-progress {
position: absolute;
bottom: 0;
left: 0;
height: 4px;
background: var(--ypp-primary);
width: 100%;
-webkit-transform-origin: left;
-ms-transform-origin: left;
transform-origin: left;
-webkit-transform: scaleX(1);
-ms-transform: scaleX(1);
transform: scaleX(1);
}
.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: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
cursor: pointer;
-webkit-transition: background-color 0.2s ease;
-o-transition: background-color 0.2s ease;
transition: background-color 0.2s ease;
font-size: 12px;
}
.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));
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
z-index: var(--ypp-z-modal);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-animation: fadeIn 0.2s ease-out;
animation: fadeIn 0.2s ease-out;
}
.ypp-modalBox {
background: var(--ypp-bg);
border: 1px solid var(--ypp-border);
border-radius: 12px;
padding: 0;
color: var(--ypp-text);
max-width: 600px;
width: 90%;
max-height: 85vh;
/* overflow: hidden; */
-webkit-animation: slideUp 0.3s ease-out;
animation: slideUp 0.3s ease-out;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
opacity: 0;
-webkit-transform: translateY(20px) scale(0.95);
-ms-transform: translateY(20px) scale(0.95);
transform: translateY(20px) scale(0.95);
-webkit-animation: modalSlideIn 0.3s ease-out forwards;
animation: modalSlideIn 0.3s ease-out forwards;
}
/* Tabs para GitHub Backup */
.ypp-github-tabs {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
gap: var(--ypp-spacing-lg);
border-radius: 8px;
}
.ypp-github-tab {
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
padding: 8px;
text-align: center;
cursor: pointer;
border-radius: 6px 6px 0 0;
font-size: 0.9em;
font-weight: 500;
-webkit-transition: all 0.2s;
-o-transition: all 0.2s;
transition: all 0.2s;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
gap: 6px;
padding: var(--ypp-spacing-lg);
}
.ypp-github-tab.active {
background: var(--ypp-bg-secondary);
}
.ypp-github-tab:not(.active):hover {
background: var(--ypp-bg);
}
@-webkit-keyframes modalSlideIn {
to {
opacity: 1;
-webkit-transform: translateY(0) scale(1);
transform: translateY(0) scale(1);
}
}
@keyframes modalSlideIn {
to {
opacity: 1;
-webkit-transform: translateY(0) scale(1);
transform: translateY(0) scale(1);
}
}
.ypp-header {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: justify;
-ms-flex-pack: justify;
justify-content: space-between;
padding: 6px 12px;
border-bottom: 1px solid var(--ypp-border);
background: var(--ypp-bg);
border-radius: 12px 12px 0 0;
-ms-flex-negative: 0;
flex-shrink: 0;
}
.ypp-header h2 {
margin: 0;
color: var(--ypp-text);
font-size: 1.8rem;
font-weight: 500;
}
.ypp-modalTitle {
font-weight: 500;
color: var(--ypp-text);
font-size: 1.6rem;
margin: 0;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-ms-flex-pack: start;
justify-content: flex-start;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
gap: 5px;
svg {
width: 20px;
height: 20px;
}
}
.ypp-modalTitle-version {
color: var(--ypp-bg-secondary-hover);
margin-left: 8px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
font-size: 1.2rem;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
}
.ypp-log-textarea {
width: 90%;
height: 120px;
background: var(--ypp-bg-secondary);
color: var(--ypp-text-secondary);
border: 1px solid var(--ypp-border);
border-radius: 4px;
padding: 8px;
font-family: monospace;
font-size: 1.2rem;
resize: vertical;
outline: none;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.ypp-modalBody {
font-size: 1.4rem;
padding: 10px 24px;
-webkit-box-flex: 1;
-ms-flex: 1;
flex: 1;
background: var(--ypp-bg);
min-height: 0;
}
/* =========================
Inputs y Forms
========================= */
.ypp-label {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
color: var(--ypp-text);
font-size: 1.4rem;
-webkit-transition: color 0.2s ease;
-o-transition: color 0.2s ease;
transition: color 0.2s ease;
white-space: nowrap;
/* margin: 8px 0; */
gap: var(--ypp-spacing-sm);
span {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: 5px;
}
}
.ypp-label-language {
gap: 12px;
}
.ypp-label-filters {
margin: 0 8px 0 0;
}
.ypp-input {
width: 100%;
padding: 6px;
background: var(--ypp-input);
border: 1px solid var(--ypp-input-border);
border-radius: 8px;
color: var(--ypp-text);
font-size: 1.4rem;
-webkit-transition:
border-color 0.2s ease,
background-color 0.2s ease;
-o-transition:
border-color 0.2s ease,
background-color 0.2s ease;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:hover {
background: var(--ypp-primary);
&::-webkit-input-placeholder {
color: var(--ypp-white);
}
&::-moz-placeholder {
color: var(--ypp-white);
}
&:-ms-input-placeholder {
color: var(--ypp-white);
}
&::-ms-input-placeholder {
color: var(--ypp-white);
}
&::placeholder {
color: var(--ypp-white);
}
}
&:focus {
outline: none;
border-color: var(--ypp-primary);
background: var(--ypp-bg-secondary);
color: var(--ypp-text);
&::-webkit-input-placeholder {
color: var(--ypp-text);
}
&::-moz-placeholder {
color: var(--ypp-text);
}
&:-ms-input-placeholder {
color: var(--ypp-text);
}
&::-ms-input-placeholder {
color: var(--ypp-text);
}
&::placeholder {
color: var(--ypp-text);
}
}
&::-webkit-input-placeholder {
color: var(--ypp-text-secondary);
}
&::-moz-placeholder {
color: var(--ypp-text-secondary);
}
&:-ms-input-placeholder {
color: var(--ypp-text-secondary);
}
&::-ms-input-placeholder {
color: var(--ypp-text-secondary);
}
&::placeholder {
color: var(--ypp-text-secondary);
}
}
.ypp-percent-symbol {
margin-left: 6px;
}
.ypp-select {
padding: 5px 12px;
background: var(--ypp-input);
border: 1px solid var(--ypp-input-border);
border-radius: 8px;
color: var(--ypp-text);
font-size: 1.4rem;
cursor: pointer;
-webkit-transition:
border-color 0.2s ease,
background-color 0.2s ease;
-o-transition:
border-color 0.2s ease,
background-color 0.2s ease;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
border-color: var(--ypp-primary);
background: var(--ypp-bg-secondary);
}
}
.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: -webkit-box;
display: -ms-flexbox;
display: flex;
gap: 10px;
}
/* =========================
Selector de Idioma con Banderas
========================= */
.ypp-language-selector {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: 8px;
}
.ypp-language-flag {
font-size: 1.2em;
margin-right: 5px;
}
/* =========================
GitHub Backup
========================= */
.ypp-github-settings-header {
margin-bottom: 10px;
}
.ypp-github-help-toggle {
cursor: pointer;
color: var(--ypp-text-highlight);
font-size: 0.85em;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
gap: var(--ypp-spacing-sm);
margin-top: 5px;
opacity: 0.8;
&:hover {
opacity: 1;
}
}
.ypp-github-help-content {
font-size: 0.8em;
color: var(--ypp-text-secondary);
background: var(--ypp-bg-secondary);
padding: 8px;
border-radius: 4px;
margin-top: 5px;
display: none;
}
.ypp-github-help-toggle.active+.ypp-github-help-content {
display: block;
}
.ypp-github-help-important {
margin: 0;
color: var(--ypp-warning);
background: var(--ypp-bg);
border-radius: var(--ypp-spacing-sm);
padding: 5px;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
font-weight: bold;
text-transform: uppercase;
}
.ypp-support-options {
border-top: 1px solid var(--ypp-border);
font-size: 1.4rem;
color: var(--ypp-text);
background: var(--ypp-bg-secondary);
/* display: flex;
flex-direction: column;
gap: var(--ypp-spacing-md); */
background: var(--ypp-border);
border-radius: 6px;
padding: 10px;
}
.ypp-management-footer-container {
display: flex;
flex-direction: column;
gap: var(--ypp-spacing-md);
}
.ypp-management-footer-item-group {
display: grid;
gap: var(--ypp-spacing-md);
/* grid-template-rows: 1fr 1fr; */
grid-template-columns: minmax(40px, 1fr) minmax(40px, 1fr) minmax(40px, 1fr);
}
`;
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 = {
// https://www.svgrepo.com/svg/453347/folder
folder: '<svg class="ypp-svgFolderIcon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2h-8l-2-2z"/></svg>',
// https://www.svgrepo.com/svg/502680/folder
folderOutline: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 17V7a2 2 0 0 1 2-2h4.586a1 1 0 0 1 .707.293L12 7h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2"/></svg>',
// https://www.svgrepo.com/svg/511175/timer - CC Attribution License
timer: '<svg class="ypp-svgTimerIcon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 13V9m9-3-2-2m-9-2h4m-2 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16"/></svg>',
// https://fontawesome.com/icons/clock-rotate-left?f=classic&s=solid - CC BY 4.0 license https://fontawesome.com/license/free
// clockRotateLeft: '<svg class="ypp-svgFolderIcon" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free v7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M320 128c106 0 192 86 192 192s-86 192-192 192c-65.2 0-122.9-32.5-157.6-82.3-10.1-14.5-30.1-18-44.6-7.9s-18 30.1-7.9 44.6C156.1 532.6 233 576 320 576c141.4 0 256-114.6 256-256S461.4 64 320 64c-85.7 0-161.5 42.1-208 106.7V144c0-17.7-14.3-32-32-32s-32 14.3-32 32v112c0 17.7 14.3 32 32 32h112.1c17.7 0 32-14.3 32-32s-14.3-32-32-32h-38.3c33.1-57.4 95.2-96 166.2-96m24 88c0-13.3-10.7-24-24-24s-24 10.7-24 24v104c0 6.4 2.5 12.5 7 17l72 72c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-65-65V216z"/></svg>',
// https://www.svgrepo.com/svg/502700/history
clockRotateLeft: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.528 16.702a8 8 0 1 0-1.512-4.2m0 0-1.5-1.5m1.5 1.5 1.5-1.5"/><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3"/></svg>',
// https://www.svgrepo.com/svg/309446/cloud-backup
// https://www.svgrepo.com/svg/309451/code
check: '<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--ypp-success)"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>',
checkCircular: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#4bd37b"/><path fill="#fff" d="M46 14 25 35.6l-7-7.2-7 7.2L25 50l28-28.8z"/></svg>',
save: '<svg class="ypp-svgSaveIcon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>',
saveWithCheckCircular: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="#fff" d="M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V7zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3m3-10H5V5h10z"/><circle cx="17.5" cy="17.5" r="4.5" fill="#4bd37b"/><path fill="#fff" d="m19.5 15.5-2.3 2.6-1.2-1.2-.9.9 2.1 2.1 3.2-3.6z"/></svg>',
bookmarkOutline: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M4 4a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v16.943c0 1.668-1.923 2.603-3.236 1.572L12 18.772l-4.764 3.743C5.923 23.546 4 22.611 4 20.942zm3-1a1 1 0 0 0-1 1v16.943l6-4.715 6 4.714V4a1 1 0 0 0-1-1z" clip-rule="evenodd"/></svg>',
bookmarkFill: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24"><path fill="var(--ypp-success)" d="M5 6c0-1.4 0-2.1.272-2.635a2.5 2.5 0 0 1 1.093-1.093C6.9 2 7.6 2 9 2h6c1.4 0 2.1 0 2.635.272a2.5 2.5 0 0 1 1.092 1.093C19 3.9 19 4.6 19 6v13.208c0 1.056 0 1.583-.217 1.856a1 1 0 0 1-.778.378c-.349.002-.764-.324-1.593-.976L12 17l-4.411 3.466c-.83.652-1.245.978-1.594.976a1 1 0 0 1-.778-.378C5 20.791 5 20.264 5 19.208z"/></svg>',
chart: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/></svg>',
settings: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>',
close: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: text-bottom;"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>',
// play: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>',
trash: '<svg id="svgTrashIcon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>',
download: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>',
upload: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>',
externalLink: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg>',
playlist: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"/></svg>',
playlistRemove: '<svg class="ypp-svgPlaylistRemove" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M15.964 4.634h-12v2h12zM15.964 8.634h-12v2h12zM3.964 12.634h8v2h-8zM12.965 13.71l1.414-1.415 2.121 2.121 2.121-2.12 1.415 1.413-2.122 2.122 2.122 2.12-1.415 1.415-2.121-2.121-2.121 2.121-1.415-1.414 2.122-2.122z"/></svg>',
copy: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>',
// calendar: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>',
// sort: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>',
locked: '<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.1" viewBox="0 0 30 30"><path d="M9 16V8c0-3.3 2.7-6 6-6h0c3.3 0 6 2.7 6 6v8" style="fill:none;stroke:#6a83ba;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M22 29H8c-1.1 0-2-.9-2-2V16c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2v11c0 1.1-.9 2-2 2z" style="fill:#f2bb41;stroke:#f2bb41;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/><path d="M15 24h0c-.6 0-1-.4-1-1v-3c0-.6.4-1 1-1h0c.6 0 1 .4 1 1v3c0 .6-.4 1-1 1z" style="fill:#354c75;stroke:#354c75;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/></svg>',
unlocked: '<svg idth="16" height="16" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path fill="#c9d0d7" d="M484.17 244.87H417.4a16.7 16.7 0 0 1-16.7-16.7v-77.9c0-27.63-22.46-50.1-50.08-50.1s-50.09 22.47-50.09 50.1v111.3a16.7 16.7 0 0 1-16.7 16.7h-66.78a16.7 16.7 0 0 1-16.7-16.7v-111.3C200.35 67.4 267.76 0 350.62 0s150.26 67.4 150.26 150.26v77.91a16.7 16.7 0 0 1-16.7 16.7z"/><path fill="#b8c2c9" d="M400.7 150.26v77.91a16.7 16.7 0 0 0 16.7 16.7h66.78a16.7 16.7 0 0 0 16.7-16.7v-77.9C500.86 67.4 433.46 0 350.6 0v100.17a50.14 50.14 0 0 1 50.09 50.1z"/><path fill="#e79d2e" d="M328.35 512H61.22a50.14 50.14 0 0 1-50.09-50.09V294.96a50.14 50.14 0 0 1 50.09-50.09h267.13a50.14 50.14 0 0 1 50.08 50.09V461.9A50.14 50.14 0 0 1 328.35 512z"/><path fill="#d8842a" d="M378.44 461.91V294.96a50.14 50.14 0 0 0-50.1-50.09H194.79V512h133.57a50.14 50.14 0 0 0 50.08-50.09z"/><g fill="#6e6057"><path d="M194.78 445.22a16.7 16.7 0 0 1-16.7-16.7v-66.78a16.7 16.7 0 0 1 33.4 0v66.78a16.7 16.7 0 0 1-16.7 16.7z"/><path d="M194.78 378.44c-18.41 0-33.39-14.98-33.39-33.4s14.98-33.39 33.4-33.39 33.38 14.98 33.38 33.4-14.97 33.38-33.39 33.38zm0-33.4h.11-.1zm0 0h.11-.1zm0 0h.11-.1zm0 0zm0 0h.11-.1zm0-.01h.11-.1zm0 0h.11-.1z"/></g><g fill="#615349"><path d="M211.48 428.52v-66.78a16.7 16.7 0 0 0-16.7-16.7v100.18a16.7 16.7 0 0 0 16.7-16.7z"/><path d="M194.78 378.44c18.42 0 33.4-14.98 33.4-33.4s-14.98-33.39-33.4-33.39v66.79z"/></g></svg>',
// https://www.svgrepo.com/svg/187087/push-pin - CC0 License
pin: '<svg class="ypp-svgPinIcon" width="16" height="16" viewBox="0 0 508.901 508.901" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"><defs><path id="a" fill="#a31c09" d="m342.08 279.177 58.606-58.606c-24.594-6.727-48.746-21.389-69.853-42.496-21.116-21.116-35.257-45.789-41.366-70.991l-59.727 59.719-48.719 48.719c6.118 25.212 22.581 47.554 43.697 68.661 21.107 21.116 44.067 36.97 68.661 43.697l48.701-48.703z"/></defs><path fill="#a31c09" d="M505.605 190.556c-13.789 13.789-66.887-16.949-118.599-68.661s-82.45-104.81-68.661-118.599 66.887 16.949 118.599 68.661 82.45 104.811 68.661 118.599"/><path fill="#d9dbe8" d="m0 508.9 112.358-162.295 49.937 49.938z"/><path fill="#ce3929" d="M387.007 121.894c-51.712-51.712-82.45-104.81-68.661-118.599-49.991 49.991-39.23 123.065 12.482 174.777s121.671 65.589 171.652 15.607l-.786-.821c-18.069 6.577-66.93-23.207-114.687-70.964"/><use xlink:href="#a"/><path fill="#ce3929" d="M311.324 389.978c2.348-21.486-1.607-44.226-11.829-68.22l-6.118 6.126c-24.594-6.735-47.554-22.59-68.661-43.697-21.116-21.107-37.579-43.458-43.697-68.661l6.241-6.241-.274-.282c-24.143-10.346-47.016-14.345-68.626-11.979-40.157 4.378-64.071 45.877-47.634 82.785 12.509 28.072 35.566 60.734 66.322 91.489 30.746 30.747 63.417 53.813 91.489 66.313 36.901 16.437 78.4-7.477 82.787-47.633"/></svg>',
playOrPause: '<svg class="ypp-svgPlayOrPauseIcon" width="16"height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><path fill="#3B88C3" d="M36 32a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h28a4 4 0 0 1 4 4v28z"></path><path fill="#FFF" d="m6 7 13 11L6 29zm20 0h4v22h-4zm-7 0h4v22h-4z"></path></svg>',
warning: '<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M2.65 35C.81 35 0 33.66.85 32.03l15.6-30.06c.86-1.63 2.24-1.63 3.1 0l15.6 30.06c.85 1.63.04 2.97-1.8 2.97H2.65z"/><path fill="#231F20" d="M15.58 28.95A2.42 2.42 0 0 1 18 26.53a2.42 2.42 0 0 1 2.42 2.42A2.42 2.42 0 0 1 18 31.37a2.42 2.42 0 0 1-2.42-2.42zm.19-18.29c0-1.3.96-2.1 2.23-2.1 1.24 0 2.23.83 2.23 2.1V22.6c0 1.27-.99 2.1-2.23 2.1-1.27 0-2.23-.8-2.23-2.1V10.66z"/></svg>',
import: '<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke="#1C274C" stroke-linecap="round" stroke-width="1.5" d="M4 12a8 8 0 1 0 16 0" opacity=".5"/><path stroke="#1C274C" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4v10m0 0 3-3m-3 3-3-3"/></svg>',
export: '<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke="#1C274C" stroke-linecap="round" stroke-width="1.5" d="M4 12a8 8 0 1 0 16 0" opacity=".5"/><path stroke="#1C274C" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 14V4m0 0 3 3m-3-3L9 7"/></svg>',
error: '<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14"><path fill="red" d="M13 10.66q0 .4-.28.68l-1.38 1.38q-.28.28-.68.28t-.69-.28L7 9.75l-2.97 2.97q-.28.28-.69.28-.4 0-.68-.28l-1.38-1.38Q1 11.06 1 10.66t.28-.69L4.25 7 1.28 4.03Q1 3.75 1 3.34q0-.4.28-.68l1.38-1.38Q2.94 1 3.34 1t.69.28L7 4.25l2.97-2.97q.28-.28.69-.28.4 0 .68.28l1.38 1.38q.28.28.28.68t-.28.69L9.75 7l2.97 2.97q.28.28.28.69z"/></svg>',
github: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 4.418 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.603-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z"/></svg>',
// https://www.svgrepo.com/svg/361206/issue-draft - MIT
issueDraft: '<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="16" height="16" viewBox="0 0 16 16"><circle cx="7.5" cy="7.5" r="1"/><path fill-rule="evenodd" d="m13.684 9.51-.952-.31A5.5 5.5 0 0 0 13 7.5c0-.595-.094-1.166-.268-1.7l.952-.31C13.889 6.124 14 6.8 14 7.5s-.111 1.377-.316 2.01m-.391-4.962-.89.455a5.53 5.53 0 0 0-2.406-2.405l.455-.89a6.53 6.53 0 0 1 2.84 2.84M9.509 1.317l-.309.95A5.5 5.5 0 0 0 7.5 2c-.595 0-1.166.094-1.7.268l-.31-.951A6.5 6.5 0 0 1 7.5 1c.701 0 1.377.111 2.01.317m-4.96.39.454.89a5.53 5.53 0 0 0-2.405 2.406l-.89-.455a6.53 6.53 0 0 1 2.84-2.84M1.316 5.491A6.5 6.5 0 0 0 1 7.5c0 .701.111 1.377.317 2.01l.95-.31A5.5 5.5 0 0 1 2 7.5c0-.595.094-1.166.268-1.7zm.39 4.96.89-.454a5.53 5.53 0 0 0 2.406 2.405l-.455.89a6.53 6.53 0 0 1-2.84-2.84m3.784 3.233.309-.952A5.5 5.5 0 0 0 7.5 13c.595 0 1.166-.094 1.7-.268l.31.952A6.5 6.5 0 0 1 7.5 14a6.5 6.5 0 0 1-2.01-.316m4.96-.391-.454-.89a5.53 5.53 0 0 0 2.405-2.406l.89.455a6.53 6.53 0 0 1-2.84 2.84" clip-rule="evenodd"/></svg>',
info: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>',
eye: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24"><path fill="var(--ypp-text)" fill-rule="evenodd" d="M12 8.25a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5M9.75 12a2.25 2.25 0 1 1 4.5 0 2.25 2.25 0 0 1-4.5 0" clip-rule="evenodd"/><path fill="var(--ypp-text)" fill-rule="evenodd" d="M12 3.25c-4.514 0-7.555 2.704-9.32 4.997l-.031.041c-.4.519-.767.996-1.016 1.56-.267.605-.383 1.264-.383 2.152s.116 1.547.383 2.152c.25.564.617 1.042 1.016 1.56l.032.041C4.445 18.046 7.486 20.75 12 20.75s7.555-2.704 9.32-4.997l.031-.041c.4-.518.767-.996 1.016-1.56.267-.605.383-1.264.383-2.152s-.116-1.547-.383-2.152c-.25-.564-.617-1.041-1.016-1.56l-.032-.041C19.555 5.954 16.514 3.25 12 3.25M3.87 9.162C5.498 7.045 8.15 4.75 12 4.75s6.501 2.295 8.13 4.412c.44.57.696.91.865 1.292.158.358.255.795.255 1.546s-.097 1.188-.255 1.546c-.169.382-.426.722-.864 1.292C18.5 16.955 15.85 19.25 12 19.25s-6.501-2.295-8.13-4.412c-.44-.57-.696-.91-.865-1.292-.158-.358-.255-.795-.255-1.546s.097-1.188.255-1.546c.169-.382.426-.722.864-1.292" clip-rule="evenodd"/></svg>'
};
// ------------------------------------------
// 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);
if (type === 'shorts') {
const shortsProgressHost = DOMHelpers.get('shorts:progressHost', () => document.querySelector('.desktopShortsPlayerControlsHost .ytPlayerProgressBarHost, .ytPlayerProgressBarHost'), 50);
const shortsPlayedBar = DOMHelpers.get('shorts:playedBar', () => document.querySelector('.ytProgressBarLineProgressBarPlayed'), 50);
const shortsHoveredBar = DOMHelpers.get('shorts:hoveredBar', () => document.querySelector('.ytProgressBarLineProgressBarHovered'), 50);
const shortsPlayheadDot = DOMHelpers.get('shorts:playheadDot', () => document.querySelector('.ytProgressBarPlayheadProgressBarPlayheadDot'), 50);
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 {
const progressContainer = DOMHelpers.get('video:progressContainer', () => document.querySelector('.ytp-progress-bar'), 50);
const playProgress = DOMHelpers.get('video:playProgress', () => document.querySelector('.ytp-play-progress'), 50);
const hoverProgress = DOMHelpers.get('video:hoverProgress', () => document.querySelector('.ytp-hover-progress'), 50);
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) {
logLog('injectProgressBarCSS', 'Degradado de barra de progreso deshabilitado en configuración');
return;
}
// Verificar si ya existe el estilo para evitar duplicados
if (document.querySelector('#ypp-progress-bar-styles')) {
logLog('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;
-webkit-transition: background 0.3s ease !important;
-o-transition: background 0.3s ease !important;
transition: background 0.3s ease !important;
}
.ytp-hover-progress {
background: var(--ytp-progress-color) !important;
-webkit-transition: background 0.3s ease !important;
-o-transition: background 0.3s ease !important;
transition: background 0.3s ease !important;
}
.ytp-progress-bar-container {
background: -webkit-gradient(linear,
left top, right top,
from(var(--ytp-progress-color)),
color-stop(var(--ytp-progress-color)),
color-stop(rgba(255, 255, 255, 0.2)),
to(rgba(255, 255, 255, 0.2))) !important;
background: -o-linear-gradient(left,
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: 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;
-webkit-transition: background 0.3s ease !important;
-o-transition: background 0.3s ease !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;
-webkit-transition: background 0.3s ease !important;
-o-transition: background 0.3s ease !important;
transition: background 0.3s ease !important;
}
/* Barra de hover en shorts */
.ytProgressBarLineProgressBarHovered {
background: var(--ytp-progress-color) !important;
-webkit-transition: background 0.3s ease !important;
-o-transition: background 0.3s ease !important;
transition: background 0.3s ease !important;
}
/* Contenedor principal de la barra de shorts */
.ytProgressBarLineProgressBarLine {
background: -webkit-gradient(linear,
left top, right top,
from(var(--ytp-progress-color)),
color-stop(var(--ytp-progress-color)),
color-stop(rgba(255, 255, 255, 0.2)),
to(rgba(255, 255, 255, 0.2))) !important;
background: -o-linear-gradient(left,
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: 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;
-webkit-transition: background 0.3s ease !important;
-o-transition: background 0.3s ease !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;
-webkit-transition: background 0.3s ease !important;
-o-transition: background 0.3s ease !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;
}
`;
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);
logLog('injectProgressBarCSS', 'CSS inyectado para barra de progreso (regular y shorts)');
} catch (error) {
logError('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
*/
// Cache pre-computado de colores para optimizar rendimiento
const COLOR_CACHE = {
light: {
// Pre-computed key points and factors
keyPoints: [
{ percent: 0, r: 204, g: 0, b: 0 },
{ percent: 33, r: 221, g: 102, b: 0 },
{ percent: 66, r: 204, g: 153, b: 0 },
{ percent: 95, r: 0, g: 204, b: 0 }
],
factors: {
// Pre-computed differences for linear interpolation
'0-33': { dr: 17, dg: 102, db: 0 },
'33-66': { dr: -17, dg: 51, db: 0 },
'66-95': { dr: -204, dg: -17, db: 0 }
}
},
dark: {
keyPoints: [
{ percent: 0, r: 221, g: 68, b: 68 },
{ percent: 33, r: 255, g: 136, b: 68 },
{ percent: 66, r: 255, g: 204, b: 68 },
{ percent: 95, r: 0, g: 204, b: 68 }
],
factors: {
'0-33': { dr: 34, dg: 68, db: 0 },
'33-66': { dr: 0, dg: 68, db: 0 },
'66-95': { dr: -255, dg: 0, db: 0 }
}
}
};
function getProgressColor(percent) {
if (percent <= 0) return COLOR_CACHE.dark.keyPoints[0];
if (percent >= 100) return '#008800';
const theme = isYouTubeDarkTheme() ? COLOR_CACHE.dark : COLOR_CACHE.light;
// Fast path for exact key points
for (const point of theme.keyPoints) {
if (Math.abs(percent - point.percent) < 0.5) {
return `rgb(${point.r}, ${point.g}, ${point.b})`;
}
}
// Optimized interpolation
let range;
if (percent <= 33) range = '0-33';
else if (percent <= 66) range = '33-66';
else range = '66-95';
const factor = theme.factors[range];
const startPoint = range === '0-33' ? theme.keyPoints[0] :
range === '33-66' ? theme.keyPoints[1] : theme.keyPoints[2];
const rangeStart = range === '0-33' ? 0 : range === '33-66' ? 33 : 66;
const ratio = (percent - rangeStart) / (range === '0-33' ? 33 : range === '33-66' ? 33 : 29);
const r = Math.round(startPoint.r + factor.dr * ratio);
const g = Math.round(startPoint.g + factor.dg * ratio);
const b = startPoint.b + factor.db * ratio;
return `rgb(${r}, ${g}, ${b})`;
}
// ------------------------------------------
// 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.
*/
const storageCache = new Map();
// Nueva capa asíncrona de almacenamiento (IndexedDB primario + caché en memoria + fallback)
const StorageAsync = (() => {
// 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é.
*/
/**
* 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 {
logInfo('Iniciando StorageAsync...');
const result = await IndexedDBAdapter.bootstrap([]);
// Poblar caché en memoria desde IndexedDB
for (const entry of result.entries) {
storageCache.set(entry.key, entry.value);
}
isReady = true;
logInfo('StorageAsync listo. Backend:', IndexedDBAdapter.isSupported ? 'IndexedDB' : 'fallback');
} catch (err) {
initError = err;
logError('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) {
logWarn(`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) {
logWarn(`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) {
logWarn(`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);
} catch (err) {
logWarn('Error al listar claves desde IndexedDB, usando caché:', err);
}
}
// Fallback a caché en memoria
return Array.from(storageCache.keys());
}
/**
* Devuelve el estado actual del backend.
*/
function getBackendInfo() {
return {
ready: isReady,
error: initError,
indexedDBSupported: IndexedDBAdapter.isSupported,
cacheSize: storageCache.size
};
}
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();
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 = () => logWarn('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<any>} 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) => {
logError('Operación fallida en cola IndexedDB', error);
// Re-lanzar para que el error llegue al llamador (ej: detección de cuota)
throw error;
}))
.catch((error) => {
// Solo loggear si no venía ya de la operación fallida
if (!error.__idbEnqueueLogged) {
Object.defineProperty(error, '__idbEnqueueLogged', { value: true });
logError('Error en cola IndexedDB', error);
}
throw 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) {
logInfo(`Recuperando ${existingEntries.length} entradas desde IndexedDB`);
return { entries: existingEntries, source: 'idb' };
}
if (legacySnapshot.length > 0) {
logInfo(`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
};
})();
/**
* Identifica si una clave de almacenamiento corresponde a una configuración o metadato
* que NO debe guardarse en IndexedDB, sino en GM_setValue (o ser purgado).
* @param {string} key
* @returns {boolean}
*/
const isNonVideoStorageKey = (key) => {
if (typeof key !== 'string') return true;
// 1. Verificar contra claves oficiales en CONFIG.STORAGE_KEYS
// Soporta tanto la clave con prefijo como sin prefijo (para purga de IDB)
const isOfficialKey = Object.values(CONFIG.STORAGE_KEYS).some(v =>
key === v || key === v.replace('YT_PLAYBACK_PLOX_', '')
);
if (isOfficialKey) return true;
// 2. Playlists legadas (obsoletas, se purgarán de IDB)
if (key.startsWith('playlist_meta_')) return true;
// 3. Otros prefijos/claves legadas o ambiguas (retrocompatibilidad del filtro)
if (key.startsWith('userSettings') || key.startsWith('userFilters') || key.startsWith('ypp_')) return true;
if (key === 'translations_cache_v1' || key === 'idb_migrated' || key === 'idb_migrated_v1') return true;
return false;
};
const Storage = {
/**
* Guarda un valor en el backend disponible (ahora delega a StorageAsync).
*/
async set(key, value) {
// Interceptar claves que no son de video
if (isNonVideoStorageKey(key)) {
try {
const gmKey = key.startsWith('YT_PLAYBACK_PLOX_') ? key : 'YT_PLAYBACK_PLOX_' + key;
// Saltamos guardado si es una playlist_meta_ legacy (purga silenciosa al intentar escribir)
if (key.startsWith('playlist_meta_')) return { success: true };
await GM_setValue(gmKey, JSON.stringify(value));
return { success: true };
} catch (err) {
logError('Storage', `Storage.set (GM): Error en clave "${key}"`, err);
return { success: false, reason: 'storage_error', error: err };
}
}
// 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) {
logError('Storage', `Storage.set: Error al guardar la clave "${key}"`, err);
// Detectar errores de cuota: por nombre (estándar) o por código numérico 22 (QUOTA_EXCEEDED_ERR)
// Algunos navegadores/IDB reportan el error solo por código, no por nombre
const isQuotaError =
err.name === 'QuotaExceededError' ||
err.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
err.code === 22 || // DOMException.QUOTA_EXCEEDED_ERR
(err.message ?? '').toLowerCase().includes('quota');
if (isQuotaError) {
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) {
if (isNonVideoStorageKey(key)) {
try {
const gmKey = key.startsWith('YT_PLAYBACK_PLOX_') ? key : 'YT_PLAYBACK_PLOX_' + key;
const raw = await GM_getValue(gmKey, null);
if (raw) {
try { return JSON.parse(raw); } catch (_) { return raw; }
}
return null;
} catch (err) {
logError('Storage', `Storage.get (GM): Error en clave "${key}"`, err);
return null;
}
}
try {
return await StorageAsync.get(key);
} catch (err) {
logError('Storage', `Storage.get: Error al obtener la clave "${key}"`, err);
return null;
}
},
/**
* Elimina un valor (ahora delega a StorageAsync).
*/
async del(key) {
if (isNonVideoStorageKey(key)) {
try {
const gmKey = key.startsWith('YT_PLAYBACK_PLOX_') ? key : 'YT_PLAYBACK_PLOX_' + key;
if (typeof GM_deleteValue === 'function') {
await GM_deleteValue(gmKey);
} else {
await GM_setValue(gmKey, null);
}
} catch (_) { }
// Si la clave estaba en IDB (legacy), borrarla también para limpiar
try { await StorageAsync.del(key); } catch (_) { }
return;
}
try {
await StorageAsync.del(key);
} catch (err) {
logError('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) {
logError('Storage', 'Storage.keys: Error al listar claves', err);
return [];
}
}
};
/**
* 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<Object>} 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.STORAGE_KEYS.settings, null);
// const parsed = raw ? JSON.parse(raw) : {};
/** @type {Record<string, any>} **/
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) {
logError('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.STORAGE_KEYS.settings, null);
/** @type {Record<string, any>} */
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) {
logError('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<void>} Una promesa que resuelve cuando la configuración es guardada.
*/
async set(settings) {
try {
const serialized = JSON.stringify(settings);
await GM_setValue(CONFIG.STORAGE_KEYS.settings, serialized);
} catch (error) {
logError('Settings', 'Error al guardar configuración del usuario:', error);
}
}
};
/**
* Objeto Filters para gestionar la persistencia del estado del modal de videos guardados.
*/
const Filters = {
/**
* Obtiene los filtros guardados.
* @returns {Promise<Object>} Filtros combinados con los valores por defecto.
*/
async get() {
try {
const raw = await GM_getValue(CONFIG.STORAGE_KEYS.filters, null);
let parsed = {};
if (raw && typeof raw === 'object') {
parsed = raw;
} else if (typeof raw === 'string' && raw.trim()) {
parsed = JSON.parse(raw);
}
return { ...CONFIG.defaultFilters, ...parsed };
} catch (error) {
logError('Filters', 'Error al cargar filtros del usuario:', error);
return { ...CONFIG.defaultFilters };
}
},
/**
* Guarda o actualiza los filtros.
* @param {Object} newValues - Nuevos valores de filtros a fusionar.
* @returns {Promise<void>}
*/
async set(newValues) {
try {
const current = await this.get();
const updated = { ...current, ...newValues };
await GM_setValue(CONFIG.STORAGE_KEYS.filters, JSON.stringify(updated));
} catch (error) {
logError('Filters', 'Error al guardar filtros del usuario:', error);
}
}
};
// ------------------------------------------
// MARK: 📊 Variables Globales
// ------------------------------------------
// Variables para controlar el estado de inicialización
let YTHelper = null; // YouTube Helper API, obtenida de waitForHelper
let currentPageType = null; // Tipo de página actual (home, watch, shorts, playlist, etc.)
let cachedSettings = null; // Configuración del usuario (obtenida de GM_getValue)
// ------------------------------------------
// MARK: 📢 Ad Selectors
// ------------------------------------------
const AdSelectors = Object.freeze({
playerAdClasses: ['ad-showing', 'ad-interrupting'], // Clases confiables del player cuando hay anuncio reproduciéndose (Watch / Miniplayer)
previewAdClasses: ['ad-showing', 'ad-interrupting', 'ad-created'], // Clases para Previews / Grid
shortsAdClasses: ['ad-created', 'ad-showing', 'ad-interrupting'], // Señal de Shorts
// --- Contenedores y Layouts ---
inPlayerAdContainers: [
'.ytp-ad-module', // Módulo de anuncio dentro del player
'.ytp-ad-player-overlay', // Overlay del anuncio (video/imagen) sobre el player
'.video-ads', // Contenedor de ads del reproductor (estructura legacy / stale en miniplayer)
'#player-ads', // Contenedor externo de ads del player (YouTube layout)
'.ytp-ad-player-overlay-layout', // Layout moderno del overlay del anuncio (se elimina al terminar)
'.ytp-ad-player-overlay-layout__player-card-container', // Contenedor de tarjeta en layout moderno
'.ytp-ad-player-overlay-layout__ad-info-container', // Contenedor de info en layout moderno
'.ytp-ad-player-overlay-layout__skip-or-preview-container', // Contenedor de skip/preview en layout moderno
'.ytp-ad-player-overlay-layout__ad-disclosure-banner-container', // Banner de divulgación en layout moderno
],
inFeedAdContainers: [
'#masthead-ad', // Contenedor de anuncio masthead (homepage)
'#masthead-player', // Player de anuncio masthead (homepage)
'[id*="masthead"]', // Selectores comodín para masthead
'[class*="masthead"]', // Clases comodín para masthead
'ytd-video-masthead-ad-primary-video-overlay-renderer', // Masthead ad (homepage autoplay)
'ytd-video-masthead-ad-primary-video-renderer', // Masthead ad (homepage autoplay)
'ytd-video-masthead-ad-advertiser-info-renderer', // Masthead ad (homepage autoplay)
'ytd-in-feed-ad-layout-renderer', // Ads dentro del feed (Home/Search)
'ytd-ad-slot-renderer', // Slot genérico de anuncio (Home/feed/sidebar)
'ytd-display-ad-renderer', // Display ad (paneles laterales / feed)
'ytd-promoted-sparkles-web-renderer', // Promoted / "sparkles" (cards patrocinadas)
'ytd-video-masthead-ad-v3-renderer', // Masthead ad (homepage autoplay)
'ytd-page-top-ad-layout-renderer', // Ad superior de página (homepage/top)
],
// --- Elementos Específicos del Anuncio ---
advertiserCard: [
'.ytp-ad-avatar-lockup-card', // Tarjeta del anunciante (avatar + texto)
'.ytp-ad-avatar', // Avatar del anunciante
'.ytp-ad-avatar-lockup-card__avatar_and_text_container', // Contenedor flexible de avatar/texto
'.ytp-ad-avatar-lockup-card__headline', // Titular del anuncio
'.ytp-ad-avatar-lockup-card__description', // Descripción del anuncio
// --- Elementos #masthead-ad ---
'.yt-badge-shape--ad',
'.yt-badge-shape--ads-include-dot',
'.ytp-paid-content-overlay',
'.ytp-paid-content-overlay-link',
'.ytp-suggested-action', // Overlay con acción sugerida (puede ser promoción o patrocinio)
'.ytp-suggested-action-badge', // Badge del overlay de acción sugerida
'.ytp-featured-product', // Producto promocionado mostrado sobre el video
'.ytp-featured-product-container', // Contenedor del bloque de producto promocionado
'.ytp-featured-product-title', // Título del producto promocionado
'.ytp-featured-product-price-container' // Contenedor del precio del producto promocionado
],
adButtons: [
'.ytp-ad-button-vm', // Botón principal (ej: "Más info" / "Comprar")
'.ytp-ad-button-vm__text', // Texto del botón principal
'.ytp-ad-hover-text-button', // Botón con texto al pasar el ratón (centro de anuncios)
'.ytp-ad-info-hover-text-button', // Botón de info (Centro de anuncios)
'.ytp-ad-button-link', // Botón de anuncio tipo link
'#ad-badge',
'#ad-button',
'#show-ad',
],
adBadges: [
'.ytp-ad-badge--clean-player', // Badge "Patrocinado" en player limpio
'.ytp-ad-badge__text--clean-player', // Texto del badge "Patrocinado"
'.ytp-ad-badge--stark-clean-player', // Badge "Patrocinado" (variante stark)
'.ad-simple-attributed-string', // Texto atribuido simple (usado en headline, badge, pod index)
],
adProgressIndicator: [
'.ytp-ad-pod-index', // Indicador de posición en tanda (ej: "1 de 2")
'.ytp-ad-pod-index--autohide', // Indicador de tanda (variante autohide)
],
advertiserLinks: [
'.ytp-visit-advertiser-link', // Link para visitar web del anunciante
'.ytp-visit-advertiser-link__text', // Texto del link del anunciante
],
skipButtons: [
'.ytp-skip-ad', // Clase legacy de skip ad
'.ytp-skip-ad-button', // Botón principal moderno para omitir anuncio
'.ytp-skip-ad-button__text', // Texto del botón omitir
'.ytp-skip-ad-button__icon', // Icono del botón omitir
'#skip-button\\:2', // ID específico escapado para botón omitir
],
// --- IDs y Atributos (Menos estables pero útiles) ---
adSpecificIds: [
'#ad-avatar-lockup-card\\:3', // ID de tarjeta anunciante (necesita escape \\:)
'#ad-button\\:7', // ID de botón de anuncio
'#visit-advertiser-link\\:e', // ID de link anunciante
],
clickableAdBadgesWithinRichItem: [
'.yt-badge-shape--ad', // Badge "Ad" moderno
'[aria-label*="Ad"]', // Badge accesible (inglés)
'[aria-label*="Patrocinado"]', // Badge accesible (español)
'[aria-label*="Sponsored"]', // Badge accesible (inglés alternativo)
'[aria-label="Patrocinado"]', // Coincidencia exacta para badge
'[aria-label="Mi centro de anuncios"]', // Atributo para botón de info
'[role="link"][class*="ad"]', // Links con clase ad
],
// --- UI Activa (Detección rápida) ---
activeAdUi: [
'.ytp-ad-player-overlay:not([hidden]):not([style*="display: none"])', // Overlay visible de anuncio
'.ytp-ad-module:not([hidden]):not([style*="display: none"])', // Módulo visible de anuncio
'.ytp-ad-player-overlay-layout:not([hidden]):not([style*="display: none"])', // Layout moderno visible
'.ytp-ad-text:not([hidden]):not([style*="display: none"])', // Texto "Ad" visible
'.ytp-ad-preview:not([hidden]):not([style*="display: none"])', // Preview de anuncio visible
'.ytp-ad-skip-button-container:not([hidden]):not([style*="display: none"])', // Botón "Skip" contenedor visible
'.ytp-skip-ad-button:not([hidden]):not([style*="display: none"])', // Botón "Skip" visible
'.ytp-ad-preview-container:not([hidden]):not([style*="display: none"])', // Contenedor de preview visible
'.ytp-ad-image-overlay:not([hidden]):not([style*="display: none"])', // Overlay de imagen visible
'.ytp-ad-overlay-container:not([hidden]):not([style*="display: none"])', // Contenedor overlay visible
'.video-ads:not([hidden]):not([style*="display: none"])', // Contenedor legacy visible
'ytd-in-feed-ad-layout-renderer', // Feed ad (no depende del player)
],
shortDurationAdUi: [
'.ytp-ad-text', // Señales rápidas: texto/badge de ad
'.ytp-ad-skip-button-container:not([hidden]):not([style*="display: none"])', // Botón "Skip" contenedor visible
'.ytp-skip-ad-button:not([hidden]):not([style*="display: none"])', // Botón "Skip" visible
'.ytp-ad-preview-container:not([hidden]):not([style*="display: none"])', // Contenedor de preview visible
'.ytp-ad-image-overlay:not([hidden]):not([style*="display: none"])', // Overlay de imagen visible
'.ytp-ad-overlay-container:not([hidden]):not([style*="display: none"])', // Contenedor overlay visible
'.video-ads:not([hidden]):not([style*="display: none"])', // Contenedor legacy visible
'.ytd-in-feed-ad-layout-renderer', // Feed ad (no depende del player)
'.ytp-ad-badge__text--clean-player', // Badge "Patrocinado" detectable rápido
]
});
const AdSelectorText = Object.freeze({
inPlayerAdContainers: AdSelectors.inPlayerAdContainers.join(', '),
inFeedAdContainers: AdSelectors.inFeedAdContainers.join(', '),
inAnyAdContainers: [...AdSelectors.inPlayerAdContainers, ...AdSelectors.inFeedAdContainers].join(', '),
activeAdUi: AdSelectors.activeAdUi.join(','),
shortDurationAdUi: AdSelectors.shortDurationAdUi.join(', '),
clickableAdBadgesWithinRichItem: AdSelectors.clickableAdBadgesWithinRichItem.join(', '),
});
// MARK: 📢 Ad Detector
const AdDetector = Object.freeze({
/**
* @param {Element|null|undefined} node
* @returns {boolean}
*/
isNodeWithinAdContainer(node) {
try {
if (!node || typeof node.closest !== 'function') return false;
// --- 1. Contenedores de Anuncios (In-Feed / Layouts / Homes) ---
// Prioridad superior para capturar grid ads que imitan renderers legítimos
if (AdSelectorText.inAnyAdContainers) {
if (DOMHelpers.closestComposed(node, AdSelectorText.inAnyAdContainers)) return true;
}
// --- 2. Jerarquía de Reproductores (Videos Regulares / Shorts / Mini) ---
// Prioridad máxima: detectar si el reproductor contenedor tiene clases de anuncio activas.
// Reproductor Principal (Watch / Miniplayer)
const moviePlayer = DOMHelpers.closestComposed(node, `#${IDs.MOVIE_PLAYER}`);
if (moviePlayer?.classList) {
if (AdSelectors.playerAdClasses.some(c => moviePlayer.classList.contains(c))) return true;
}
// Reproductor de Shorts (ID e identificación explícita)
const shortsPlayer = DOMHelpers.closestComposed(node, `#${IDs.SHORTS_PLAYER}`) || DOMHelpers.closestComposed(node, IDs.SHORTS_PLAYER);
if (shortsPlayer?.classList) {
if (AdSelectors.shortsAdClasses.some(c => shortsPlayer.classList.contains(c))) return true;
}
// Reproductor de Previews / Grid (Detección de anuncios en thumbnails que se autoreproducen)
const inlinePlayer = DOMHelpers.closestComposed(node, `#${IDs.INLINE_PREVIEW_PLAYER}`);
if (inlinePlayer?.classList) {
if (AdSelectors.previewAdClasses.some(c => inlinePlayer.classList.contains(c))) return true;
}
// --- 3. Protección de Previews e Inline (Contenido Legítimo) ---
// Si está en un inline preview verificado Y NO fue capturado arriba por clases de anuncio
if (DOMHelpers.closestComposed(node, S.IDS.INLINE_PREVIEW_PLAYER)) return false;
// --- 4. Validación de Contenido Legítimo en Feed ---
// Si está dentro de un renderizador de video estándar sin badges de anuncio
try {
const videoItem = DOMHelpers.closestComposed(node, 'ytd-video-renderer, ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer, .video-renderer');
if (videoItem && !DOMHelpers.closestComposed(videoItem, AdSelectorText.inFeedAdContainers)) {
const hasAdBadge = !!videoItem.querySelector?.(AdSelectorText.clickableAdBadgesWithinRichItem);
if (hasAdBadge) return true; // Es un anuncio con badge
return false; // Es contenido legítimo
}
} catch (_) { }
return false;
} catch (_) {
return false;
}
},
/**
* @param {Element|null|undefined} root
* @returns {Element|null}
*/
findVisibleAdUi(root) {
try {
if (!root?.querySelector) return null;
return DOMHelpers.get('ad:activeUi', () => root.querySelector(AdSelectorText.activeAdUi), 100);
} catch (_) {
return null;
}
},
/**
* @param {Element|null|undefined} rootCont
* @returns {boolean}
*/
hasShortDurationAdUi(rootCont = null) {
try {
const root = rootCont || document;
// Add unique context to cache key if root is provided
const cacheKey = rootCont ? `ad:shortUi_${rootCont.id || 'custom'}` : 'ad:shortUi';
return !!DOMHelpers.get(cacheKey, () => root.querySelector(AdSelectorText.shortDurationAdUi), 125);
} catch (_) {
return false;
}
},
/**
* @param {Element|null|undefined} linkEl
* @returns {{adContainer: Element|null, hasAdBadge: boolean}}
*/
classifyClickedLink(linkEl) {
try {
if (!linkEl) return { adContainer: null, hasAdBadge: false };
const adContainer = linkEl.closest(AdSelectorText.inFeedAdContainers);
let hasAdBadge = false;
try {
const richItem = linkEl.closest('ytd-rich-item-renderer');
hasAdBadge = !!richItem?.querySelector?.(AdSelectorText.clickableAdBadgesWithinRichItem);
} catch (_) { hasAdBadge = false; }
return { adContainer: adContainer || null, hasAdBadge };
} catch (_) {
return { adContainer: null, hasAdBadge: false };
}
},
});
// ------------------------------------------
// MARK: 🔧 Utils
// ------------------------------------------
/**
* Sanitiza strings para insertarlos de forma segura en el HTML
* @param {string|number|undefined|null} str
* @returns {string}
*/
const escapeHTML = (str) => {
if (!str && str !== 0) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
// 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 {
logError('Formato de tiempo no válido:', input);
return '00:00';
}
}
// Intenta convertir directamente a número
else {
seconds = parseFloat(input);
}
}
// Caso por defecto
else {
logError('Valor de entrada no válido:', input);
return '00:00';
}
// Validación final
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) {
logError('Valor de segundos no válido:', input);
return '00:00';
}
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const hours = h.toString().padStart(2, '0');
const minutes = m.toString().padStart(2, '0');
const secs = s.toString().padStart(2, '0');
return h > 0
? `${hours}:${minutes}:${secs}`
: `${minutes}:${secs}`;
};
/**
* 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;
};
/**
* 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, se captura en el catch.
_ttPolicy = window.trustedTypes.createPolicy('youtube-playback-plox', {
createHTML: (string) => string
});
logInfo('getTrustedTypesPolicy', '✅ Trusted Types policy "youtube-playback-plox" creada.');
} catch (e) {
logWarn('getTrustedTypesPolicy', 'Falló creación de política (posiblemente ya existe).');
// En algunos navegadores no se puede recuperar una política ya creada,
// pero si existe 'default', el navegador la usará automáticamente.
}
}
return _ttPolicy;
}
function setInnerHTML(element, html) {
if (!element) return;
// 1. Caso base: Si es solo texto, usar textContent (el sink más seguro y rápido)
if (typeof html === 'string' && !html.includes('<')) {
element.textContent = html;
return;
}
// 2. Intentar usar Trusted Types (recomendado por YouTube)
// Esto permite cumplir con la política de seguridad oficial si el navegador/entorno la soporta.
const policy = getTrustedTypesPolicy();
if (policy) {
try {
element.innerHTML = policy.createHTML(html);
return;
} catch (e) {
// Si la política falla (ej: bloqueada por CSP), caemos al siguiente fallback
}
}
// 3. Fallback Privilegiado (GM_addElement): Bypassa CSP y Trusted Types de la página.
// GM_addElement es una función de Tampermonkey/Greasemonkey que permite insertar elementos
// y HTML de forma segura desde un contexto privilegiado.
if (typeof GM_addElement === 'function') {
try {
// Limpiar el contenido actual
while (element.firstChild) {
element.removeChild(element.firstChild);
}
// Insertar un contenedor transparente que porte el HTML
GM_addElement(element, 'span', {
innerHTML: html,
style: 'display: contents;' // Evita alterar el layout del elemento original
});
return;
} catch (e) {
logWarn('setInnerHTML', 'GM_addElement falló, intentando último recurso.');
}
}
// 4. Último recurso: DOMParser (si falla el privilegio o no existe GM_addElement)
try {
const parser = new DOMParser();
// Nota: En documentos extremadamente estrictos (como YouTube), parseFromString puede fallar.
const doc = parser.parseFromString(html, 'text/html');
while (element.firstChild) {
element.removeChild(element.firstChild);
}
const fragment = document.createDocumentFragment();
while (doc.body.firstChild) {
fragment.appendChild(doc.body.firstChild);
}
element.appendChild(fragment);
} catch (e) {
logError('setInnerHTML', '❌ Falló bypass total de Trusted Types:', e);
// Fallback final: mostrar como texto plano para no perder la información totalmente
element.textContent = typeof html === 'string' ? html : String(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.<string, Function>} [options.events] - Eventos a añadir, e.g., { click: fn, mouseover: fn }.
* @param {Object.<string, string>} [options.atribute] - Atributos HTML a añadir, e.g., { src: 'img.png' }.
* @param {Object.<string, any>} [options.props] - Propiedades del elemento, e.g., { value: '123' }.
* @param {Object.<string, string>} [options.styles] - Estilos CSS a aplicar, e.g., { color: 'red', fontSize: '14px' }.
* @param {Array<string|Node>} [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 FALLBACK_URL = 'https://raw.githubusercontent.com/Alplox/Youtube-Playback-Plox/refs/heads/main/YouTube-Helper-API.js';
let helper = null;
if (typeof youtubeHelperApi !== 'undefined') {
helper = youtubeHelperApi;
try { logInfo('waitForHelper', '✅ Referencia a YouTube Helper API obtenida youtubeHelperApi'); } catch (_) { }
} else if (typeof window.youtubeHelperApi !== 'undefined') {
helper = window.youtubeHelperApi;
try { logInfo('waitForHelper', '✅ Referencia a YouTube Helper API obtenida window.youtubeHelperApi'); } catch (_) { }
} else if (typeof unsafeWindow !== 'undefined' && unsafeWindow.youtubeHelperApi) {
helper = unsafeWindow.youtubeHelperApi;
try { logInfo('waitForHelper', '✅ Referencia a YouTube Helper API obtenida unsafeWindow.youtubeHelperApi'); } catch (_) { }
}
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) {
logWarn('waitForHelper', `[YTHelper] No disponible, reintentando... (${retries + 1}/${MAX_RETRIES})`);
// Intentar cargar desde el fallback en el reintento 3 para darle oportunidad a GreasyFork primero
if (retries === 3 && typeof GM_xmlhttpRequest !== 'undefined') {
logWarn('waitForHelper', '[YTHelper] Intentando cargar Helper API desde Fallback (GitHub)...');
GM_xmlhttpRequest({
method: 'GET',
url: FALLBACK_URL,
onload: (response) => {
if (response.status === 200) {
try {
// Reemplazar const por definición en window para acceso global
const code = response.responseText.replace(/const\s+youtubeHelperApi\s*=/, 'window.youtubeHelperApi = ');
// Opción A: GM_addElement (Tampermonkey/Violentmonkey) - Método más seguro para saltar CSP
if (typeof GM_addElement !== 'undefined') {
GM_addElement(document.head, 'script', { textContent: code });
logInfo('waitForHelper', '[YTHelper] Helper API cargado mediante GM_addElement.');
return;
}
// Opción B: Trusted Types aware injection
let trustedCode = code;
if (window.trustedTypes?.createPolicy) {
try {
const policy = window.trustedTypes.createPolicy('ypp-helper-fallback', {
createScript: (s) => s
});
trustedCode = policy.createScript(code);
} catch (_) { /* Política ya existe o creación bloqueada */ }
}
try {
// Intento 1: Sandbox (new Function)
new Function(code)();
logInfo('waitForHelper', '[YTHelper] Helper API cargado desde Fallback (Sandbox).');
} catch (err) {
// Intento 2: Inyección DOM con soporte TT
const script = document.createElement('script');
const nonce = document.querySelector('script[nonce]')?.nonce;
if (nonce) script.setAttribute('nonce', nonce);
script.textContent = trustedCode;
document.head.appendChild(script);
logInfo('waitForHelper', '[YTHelper] Helper API inyectado en el DOM con soporte TrustedTypes.');
}
} catch (err) {
logError('waitForHelper', '[YTHelper] Falló la carga desde Fallback:', err);
}
} else {
logWarn('waitForHelper', `[YTHelper] Fallback retornó código de estado: ${response.status}`);
}
},
onerror: (err) => logError('waitForHelper', '[YTHelper] Error de red al intentar cargar Fallback:', err)
});
}
setTimeout(() => resolve(waitForHelper(retries + 1)), RETRY_INTERVAL);
} else {
logWarn('waitForHelper', '⚠️ YouTube Helper API no disponible tras varios intentos. El script funcionará en modo limitado (Fallback).');
resolve(null);
}
});
}
// 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),
* 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) {
logWarn('[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) {
const currentVersion = this.renderVersion;
if (this.destroyed || index >= this.items.length) {
this.renderingItems.delete(index);
return;
}
try {
const item = this.items[index];
let el = await this.renderItem(item, index);
if (this.destroyed || currentVersion !== this.renderVersion) return;
if (typeof el === 'string') {
const temp = document.createElement('div');
setInnerHTML(temp, el.trim());
el = temp.firstElementChild || temp;
}
// 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) {
logError('[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 || [];
this.renderVersion = (this.renderVersion || 0) + 1;
// Limpieza agresiva del spacer para asegurar que no queden huérfanos de renders asíncronos previos
if (this.spacer) {
const orphans = this.spacer.querySelectorAll('.ypp-virtual-item');
orphans.forEach(el => 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);
}
/**
* 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
// ------------------------------------------
/**
* Recopila todos los datos de videos almacenados para sincronización o exportación.
* @returns {Promise<Object|null>} Objeto con todos los datos o null si no hay datos.
*/
const getSyncData = async (method = 'export', keysToExport = null) => {
try {
const exportData = {};
let keys = (await Storage.keys()).filter(k => !isNonVideoStorageKey(k));
if (keysToExport && Array.isArray(keysToExport)) {
keys = keys.filter(k => keysToExport.includes(k));
}
if (keys.length === 0) return null;
// Añadir metadatos al inicio
exportData['__metadata__'] = {
version: SCRIPT_VERSION,
date: new Date().toISOString(),
totalEntries: keys.length,
backupMethod: method
};
for (const k of keys) {
const data = await Storage.get(k);
if (data) exportData[k] = data;
}
return exportData;
} catch (error) {
logError('getSyncData', 'Error al recopilar datos:', error);
return null;
}
};
// Exportación/Importación JSON nativo del userscript (preserva videoTypes)
const exportDataToFile = async (keysToExport = null, filenameSuffix = 'backup') => {
try {
const exportData = await getSyncData('export', keysToExport);
// Early exit si no hay datos que exportar
if (!exportData) {
logLog('exportDataToFile', 'No hay datos para exportar');
showFloatingToast(`${SVG_ICONS.warning} ${t('noSavedVideos')}`);
return;
}
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-v${SCRIPT_VERSION}-${filenameSuffix}-${timestamp}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
const count = Object.keys(exportData).filter(k => k !== '__metadata__').length;
showFloatingToast(`${SVG_ICONS.upload} ${t('itemsExported', { count })}`);
logLog('exportDataToFile', `Exportados ${count} videos en formato JSON nativo`);
} catch (error) {
logError('exportDataToFile', 'Error al exportar:', error);
showFloatingToast(`${SVG_ICONS.error} ${t('exportError')}`);
}
};
const importDataFromFile = async () => {
let inputFile = document.querySelector('#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;
// Early filtering para evitar procesar claves inválidas
const validKeys = Object.keys(data).filter(key =>
!key.startsWith('userSettings') &&
!key.startsWith('userFilters') &&
key !== '__metadata__'
);
if (validKeys.length === 0) {
logLog('importDataFromFile', 'No hay datos válidos para importar');
showFloatingToast(`${SVG_ICONS.warning} ${t('noValidVideos')}`);
return;
}
for (const key of validKeys) {
// Normalizar los datos antes de guardarlos para asegurar Format A
const normalized = normalizeVideoData(data[key]);
// Validar que el valor tenga estructura mínima de video después de normalizar
if (normalized && typeof normalized === 'object' && normalized.videoId) {
await Storage.set(key, normalized);
importCount++;
} else {
logLog('importDataFromFile', `Entrada inválida ignorada (o sin videoId): ${key}`);
skipped++;
}
}
await updateVideoList();
if (importCount > 0) {
showFloatingToast(`${SVG_ICONS.check} ${t('itemsImported', { count: importCount })} ${skipped > 0 ? ` (${skipped} ${t('omitedVideos')})` : ''}`);
logLog('importDataFromFile', `Importados ${importCount} videos, ${skipped} omitidos`);
} else {
showFloatingToast(`${SVG_ICONS.warning} ${t('noValidVideos')}`);
}
} catch (error) {
logError('importDataFromFile', 'Error al importar:', error);
showFloatingToast(`${SVG_ICONS.error} ${t('importError')}`);
} finally {
inputFile.value = '';
}
};
inputFile.click();
};
// ------------------------------------------
// MARK: ☁️ GitHub Backup
// ------------------------------------------
/**
* Realiza un respaldo de los datos en un Gist de GitHub.
*/
const backupToGitHub = (data, githubSettings, isManual) => {
return new Promise((resolve) => {
const fileName = 'youtube-playback-plox-backup.json';
const gistData = {
description: `YouTube Playback Plox Backup v${SCRIPT_VERSION} - ${new Date().toLocaleString()}`,
public: false,
files: {
[fileName]: {
content: JSON.stringify(data, null, 2)
}
}
};
const gistId = githubSettings.gistId;
const url = gistId ? `https://api.github.com/gists/${gistId}` : 'https://api.github.com/gists';
const method = gistId ? 'PATCH' : 'POST';
GM_xmlhttpRequest({
method: method,
url: url,
headers: {
'Authorization': `token ${githubSettings.token}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
},
data: JSON.stringify(gistData),
onload: async (response) => {
if (response.status >= 200 && response.status < 300) {
const result = JSON.parse(response.responseText);
// Actualizar el objeto actual para feedback en la UI si el modal está abierto
githubSettings.gistId = result.id;
githubSettings.gistUrl = result.html_url;
githubSettings.lastSync = Date.now();
// Persistir metadatos de sincronización
let storedSettings = await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings);
storedSettings.gist.id = result.id;
storedSettings.gist.url = result.html_url;
storedSettings.gist.lastSync = Date.now();
// Auto-eliminar token si aplica
if (githubSettings.autoDeleteToken) {
storedSettings.gist.token = '';
}
await GM_setValue(CONFIG.STORAGE_KEYS.github, storedSettings);
logInfo('backupToGitHub', 'Respaldo en GitHub exitoso:', result.id.slice(0, 10) + '...');
resolve(true);
} else {
logError('backupToGitHub', 'Error en respaldo GitHub:', response.status, response.responseText);
if (isManual) {
const errorMsg = response.status === 401 ? t('githubInvalidToken') : t('githubBackupError');
showFloatingToast(`${SVG_ICONS.error} ${errorMsg} (${response.status})`);
}
resolve(false);
}
},
onerror: (err) => {
logError('backupToGitHub', 'Error de red en respaldo GitHub:', err);
resolve(false);
}
});
});
};
/**
* Realiza un respaldo de los datos en un repositorio privado de GitHub.
*/
const backupToRepository = (data, githubSettings, isManual) => {
return new Promise((resolve) => {
const { repoOwner, repoName, token } = githubSettings;
if (!repoOwner || !repoName) {
if (isManual) showFloatingToast(`${SVG_ICONS.warning} ${t('githubBackupError')}`);
return resolve(false);
}
const fileName = 'youtube-playback-plox-backup.json';
const baseUrl = `https://api.github.com/repos/${repoOwner}/${repoName}`;
const headers = {
'Authorization': `token ${token}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json'
};
if (isManual) showFloatingToast(`⏳ ${t('githubRepoCheck')}...`);
// 1. Verificar privacidad del repositorio
GM_xmlhttpRequest({
method: 'GET',
url: baseUrl,
headers: headers,
onload: (repoResponse) => {
if (repoResponse.status !== 200) {
logError('backupToRepository', 'No se pudo acceder al repositorio:', repoResponse.status);
if (isManual) showFloatingToast(`${SVG_ICONS.error} ${t('githubBackupError')} (${repoResponse.status})`);
return resolve(false);
}
const repoInfo = JSON.parse(repoResponse.responseText);
if (!repoInfo.private) {
logError('backupToRepository', 'El repositorio NO es privado.');
if (isManual) showFloatingToast(`${SVG_ICONS.error} ${t('githubRepoPrivacyError')}`);
return resolve(false);
}
// 2. Obtener el SHA del archivo si ya existe
GM_xmlhttpRequest({
method: 'GET',
url: `${baseUrl}/contents/${fileName}`,
headers: headers,
onload: (fileResponse) => {
let sha = null;
if (fileResponse.status === 200) {
sha = JSON.parse(fileResponse.responseText).sha;
}
// 3. Subir/Actualizar el archivo
const commitMessage = `YouTube Playback Plox Backup v${SCRIPT_VERSION} - ${new Date().toLocaleDateString()}`;
const contentBase64 = btoa(unescape(encodeURIComponent(JSON.stringify(data, null, 2))));
GM_xmlhttpRequest({
method: 'PUT',
url: `${baseUrl}/contents/${fileName}`,
headers: headers,
data: JSON.stringify({
message: commitMessage,
content: contentBase64,
sha: sha
}),
onload: async (putResponse) => {
if (putResponse.status >= 200 && putResponse.status < 300) {
// Persistir metadatos
let storedSettings = await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings);
storedSettings.repo.lastSync = Date.now();
// Auto-eliminar token si aplica
if (githubSettings.autoDeleteToken) {
storedSettings.repo.token = '';
}
await GM_setValue(CONFIG.STORAGE_KEYS.github, storedSettings);
logInfo('backupToRepository', 'Respaldo en repositorio exitoso');
resolve(true);
} else {
logError('backupToRepository', 'Error al subir archivo:', putResponse.status);
if (isManual) showFloatingToast(`${SVG_ICONS.error} ${t('githubBackupError')} (${putResponse.status})`);
resolve(false);
}
},
onerror: (err) => {
logError('backupToRepository', 'Error de red:', err);
resolve(false);
}
});
},
onerror: (err) => {
logError('backupToRepository', 'Error obteniendo SHA:', err);
resolve(false);
}
});
},
onerror: (err) => {
logError('backupToRepository', 'Error de red verificando repo:', err);
resolve(false);
}
});
});
};
/**
* Punto de entrada unificado para respaldos remotos.
*/
const performRemoteBackup = async (type = 'gist', isManual = false, settingsOverride = null) => {
let githubSettings = await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings);
const modeSettings = settingsOverride
? { autoDeleteToken: githubSettings.autoDeleteToken, ...settingsOverride }
: (githubSettings[type] || CONFIG.defaultGithubSettings[type] || {});
if (!modeSettings.token) {
if (isManual) showFloatingToast(`${SVG_ICONS.warning} ${t('githubTokenRequired')}`);
return false;
}
const data = await getSyncData(isManual ? 'manual' : 'auto');
if (!data) {
if (isManual) showFloatingToast(`${SVG_ICONS.warning} ${t('noSavedVideos')}`);
return false;
}
if (isManual) showFloatingToast(`⏳ ${t('githubBackupNow')} (${type})...`);
let success = false;
if (type === 'repo') {
success = await backupToRepository(data, modeSettings, isManual);
} else {
success = await backupToGitHub(data, modeSettings, isManual);
}
if (success) {
if (isManual) showFloatingToast(`${SVG_ICONS.check} ${t('githubBackupSuccess')}`);
// Re-leer settings desde GM para obtener valores actualizados (lastSync, gistId, etc.)
const updatedSettings = await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings);
// Actualizar UI en tiempo real si el modal está abierto
const lastSyncEl = document.getElementById(`ypp-github-last-sync-${type}`);
if (lastSyncEl) {
const syncTime = updatedSettings[type]?.lastSync;
setInnerHTML(lastSyncEl, `<strong>${t('githubLastSync')}:</strong> ${syncTime ? new Date(syncTime).toLocaleString() : t('unknown')}`);
}
if (type === 'gist') {
const gistContainer = document.getElementById('ypp-github-gist-link-container-gist');
if (gistContainer && updatedSettings.gist?.url) {
setInnerHTML(gistContainer, `
<a href="${updatedSettings.gist.url}" class="ypp-link" target="_blank" rel="noopener noreferrer">
${SVG_ICONS.externalLink} ${t('githubGistView')}
</a>
`);
}
const gistIdInput = document.querySelector('input[name="gist_id"]');
if (gistIdInput && !gistIdInput.value && updatedSettings.gist?.id) {
gistIdInput.value = updatedSettings.gist.id;
}
}
// Limpiar input del token de la UI si fue eliminado
if (isManual && modeSettings.autoDeleteToken) {
const tokenInput = document.querySelector(`input[name="${type}_token"]`);
if (tokenInput) tokenInput.value = '';
logInfo('performRemoteBackup', `Token (${type}) auto-eliminado de UI tras respaldo manual.`);
}
}
return success;
};
/**
* Verifica si es necesario realizar un respaldo automático.
*/
const checkGitHubBackup = async () => {
let githubSettings = await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings);
const now = Date.now();
const types = ['gist', 'repo'];
for (const type of types) {
const s = githubSettings[type] || CONFIG.defaultGithubSettings[type] || {};
if (!s.autoBackup || !s.token) continue;
const intervalMs = (s.interval || 24) * 60 * 60 * 1000;
const timeSinceLastSync = now - s.lastSync;
if (timeSinceLastSync >= intervalMs) {
logInfo('checkGitHubBackup', `Iniciando respaldo automático (${type})...`);
await performRemoteBackup(type, false);
} else {
const minutesRemaining = Math.ceil((intervalMs - timeSinceLastSync) / (60 * 1000));
logLog('checkGitHubBackup', `Respaldo automático (${type}) omitido. Faltan ${minutesRemaining} min.`);
}
}
};
// MARK: 📤 Import/Export FreeTube options
const exportToFreeTube = (specificKeys = null) => {
// 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(specificKeys);
if (!exportData || exportData.length === 0) {
showFloatingToast(`${SVG_ICONS.warning} ${t('noSavedVideos')}`);
return;
}
// 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')) {
logWarn('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-v${SCRIPT_VERSION}-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) {
logError('exportToFreeTube', 'Error exporting to FreeTube format:', err);
showFloatingToast(`${SVG_ICONS.error} ${t('exportError')}`);
}
})();
};
const importFromFreeTube = () => {
let inputFile = document.querySelector('#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
logLog('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) {
logError('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);
}
}
logLog('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) {
logError('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();
};
// MARK: 🔄 Normalize Video Data
/**
* Normaliza los datos de un video al formato interno.
* Convierte campos legacy (Format B) a modernos (Format A) y asegura consistencia.
* @param {Object} data - Datos a normalizar
* @returns {Object} Datos normalizados con esquema moderno y sin campos obsoletos
*/
function normalizeVideoData(data, fallbackId = '') {
if (!data || typeof data !== 'object') return data;
const resolvedTimeWatched = data.timeWatched;
const resolvedIsCompleted = data.isCompleted || false;
// Retrocompatibilidad con entradas sin historial de completados.
// Si el video ya estaba marcado como `isCompleted = true` pero
// no tiene historial (dato creado antes de que existiera `completionHistory`), se
// siembra una única entrada usando `timeWatched` como fecha de la última compleción
// registrada. Es idempotente: solo actúa en el primer ciclo lectura/escritura
// post-actualización, ya que tras guardar el array deja de estar vacío.
let resolvedCompletionHistory = Array.isArray(data.completionHistory)
? data.completionHistory
: [];
if (resolvedIsCompleted && resolvedCompletionHistory.length === 0) {
resolvedCompletionHistory = [resolvedTimeWatched];
}
const result = {
videoId: data.videoId || fallbackId,
title: data.title || '',
author: data.author || '',
authorId: data.authorId || '',
published: data.published || 0,
description: data.description || '',
watchProgress: data.watchProgress ?? data.timestamp ?? 0,
lengthSeconds: data.lengthSeconds ?? data.duration ?? 0,
timeWatched: resolvedTimeWatched,
type: normalizeVideoType(data.type ?? data.videoType),
viewCount: data.viewCount ?? (parseInt(data.viewsNumber?.toString().replace(/[,\.\s]/g, '')) || 0),
isLive: data.isLive || false,
isCompleted: resolvedIsCompleted,
// Conservar campos de navegación/playlist si existen
lastViewedPlaylistId: data.lastViewedPlaylistId ?? null,
lastViewedPlaylistType: '',
lastViewedPlaylistItemId: data.lastViewedPlaylistItemId ?? null,
// Formato Interno
playlistTitle: data.playlistTitle ?? null,
completionHistory: resolvedCompletionHistory,
isProtected: data.isProtected || false,
...(data.forceResumeTime ? { forceResumeTime: data.forceResumeTime } : {}),
// Conservar el _id de FreeTube si existe (importante para compatibilidad)
...(data._id ? { _id: data._id } : {})
};
return result;
}
// MARK: 🔄 Convert To FreeTube
/**
* 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) {
// Asegurar que los datos internos estén normalizados antes de la conversión
const normalized = normalizeVideoData(internalData);
// Redondear valores de tiempo para que FreeTube los muestre correctamente
const progress = normalized.watchProgress;
const duration = normalized.lengthSeconds;
// Redondear watchProgress a 2 decimales
const watchProgress = Math.round(progress * 100) / 100;
// Redondear lengthSeconds a entero (FreeTube espera segundos completos)
const lengthSeconds = Math.round(duration);
const result = {
videoId: normalized.videoId,
title: normalized.title || t('unknown'),
author: normalized.author || t('unknown'),
authorId: normalized.authorId || '',
published: normalized.published || null,
description: normalized.description || '',
viewCount: normalized.viewCount,
lengthSeconds: lengthSeconds,
watchProgress: watchProgress,
timeWatched: normalized.timeWatched,
isLive: normalized.isLive || false,
// FreeTube usa 'video' para todo en su tipo en exportación historial.db, última revisión: v0.23.15 Beta
type: 'video',
// Durante la importación desde FreeTube, estos campos se consideran opcionales
// https://github.com/FreeTubeApp/FreeTube/blob/fa842985/src/renderer/components/DataSettings/DataSettings.vue#L832-L839
/*
const optionalKeys = [
// `_id` absent if marked as watched manually
'_id',
'lastViewedPlaylistId',
'lastViewedPlaylistItemId',
'lastViewedPlaylistType',
'viewCount',
]
*/
// Metadatos de playlist (FreeTube los incluye siempre, aunque sean null)
lastViewedPlaylistId: normalized.lastViewedPlaylistId || null,
/*
Valores posibles para `lastViewedPlaylistType`
https://github.com/FreeTubeApp/FreeTube/blob/fa842985/src/renderer/views/Watch/Watch.js#L1188-L1200
- **`"user"`**: Cuando el video se reproduce desde una playlist creada por el usuario
- **`""`** (cadena vacía): Cuando no hay playlist asociada o es una playlist remota
- **`null`**: Cuando no hay información de playlist
*/
lastViewedPlaylistType: '',
/*
Valores posibles para `lastViewedPlaylistItemId`
https://github.com/FreeTubeApp/FreeTube/blob/fa842985/src/renderer/views/Watch/Watch.js#L1230-1273
- **String**: Identificador único del item dentro de la playlist (para playlists de usuario)
- **`null`**: Cuando no hay item de playlist específico (como en playlists remotas)
*/
lastViewedPlaylistItemId: normalized.lastViewedPlaylistItemId || null,
// Preservar _id si existe
...(normalized._id ? { _id: normalized._id } : {})
};
return result;
}
// freetube v0.23.15 Beta guarda asi un livestream...
/*
{
"videoId":"YnSRyzVme58",
"title":"🔴LIVE | CS2 | OPENING CASES | HUTCH x CLOAKZY x DRAC x NICKMERCS | #BUNGULATE",
"author":"TheBurntPeanut",
"authorId":"UCMNEVbszv8ZyvSXoTn3yhpQ",
"published":1774483849000,
"description":"",
"viewCount":93265,
"lengthSeconds":0,
"watchProgress":0,
"timeWatched":1774487172973,
"isLive":false, <- bruh
"type":"video"
}
*/
// MARK: Parse FreeTube DB
/**
* 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 = '';
// Intentar decodificar como UTF-8 primero por si es un archivo de texto (JSON-L)
// que llegó aquí como arrayBuffer
try {
const decoder = new TextDecoder('utf-8');
text = decoder.decode(uint8Array);
} catch (e) {
// Fallback a extracción manual de caracteres imprimibles si falla la decodificación
for (let i = 0; i < uint8Array.length; i++) {
const byte = uint8Array[i];
text += (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : ' ';
}
}
const jsonObjects = [];
// 1. Intentar parsear como JSON directo o JSON-L
const trimmed = text.trim();
if (trimmed.startsWith('[') && (trimmed.endsWith(']') || trimmed.includes(']'))) {
try {
// Extraer solo la parte del array si hay basura alrededor (común en binarios)
const arrayMatch = trimmed.match(/\[.*\]/s);
if (arrayMatch) jsonObjects.push(...JSON.parse(arrayMatch[0]));
} catch (e) { /* continuar */ }
}
// 2. Si falló o no es array, buscar objetos individuales (NDJSON o escaneo de binario)
if (jsonObjects.length === 0) {
const jsonPattern = /\{[^}]*"videoId"[^}]*\}/g;
let match;
while ((match = jsonPattern.exec(text)) !== null) {
try {
const obj = JSON.parse(match[0].replace(/,\s*}/g, '}'));
if (obj.videoId) jsonObjects.push(obj);
} catch (e) { /* ignorar línea/segmento corrupto */ }
}
}
logLog('parseFreeTubeDB', `Procesados ${jsonObjects.length} posibles videos`);
return jsonObjects;
} catch (error) {
logError('parseFreeTubeDB', 'Error fatal parseando DB:', error);
return [];
}
}
// MARK: 🔄 Convert From FreeTube
/**
* 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) {
if (!freeTubeData || !freeTubeData.videoId) return null;
// Normalizar para asegurar Format A y limpiar campos legacy
const normalized = normalizeVideoData(freeTubeData);
// Determinar si el video está completado basado en el progreso
let isCompleted = normalized.isCompleted || false;
const watchProgress = normalized.watchProgress;
const lengthSeconds = normalized.lengthSeconds;
if (!isCompleted && 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 >= (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent) || remainingSeconds <= 30;
}
return {
...normalized,
type: normalizeVideoType(normalized.type === 'short' ? 'shorts' : normalized.type),
isCompleted: isCompleted
};
}
// MARK: ⬆ Export To FreeTube
/**
* Exporta todos los videos guardados en formato FreeTube
* @returns {Array} Array de videos en formato FreeTube
*/
async function exportToFreeTubeFormat(specificKeys = null) {
const videoKeys = specificKeys || (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) {
logLog('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++;
logLog('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)); }
}
logLog('exportToFreeTubeFormat', `Exportando ${freeTubeData.length} items: ${videoCount} videos, ${shortCount} shorts`);
return freeTubeData;
}
// MARK: ⬇ Import From FreeTube
/**
* 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 importedCount = 0;
let failedCount = 0;
if (!Array.isArray(freeTubeData)) {
logError('importFromFreeTubeFormat', 'Los datos no son un array válido');
return { imported: 0, failed: 0, total: 0 };
}
// 1. Deduplicar por videoId: quedarse con la entrada de tiempo más reciente
// Esto maneja exports de FreeTube Beta que permiten duplicados via _id
const uniqueVideos = new Map();
for (const entry of freeTubeData) {
if (!entry || !entry.videoId) continue;
const internal = fromFreeTubeFormat(entry);
if (!internal) continue;
const existing = uniqueVideos.get(internal.videoId);
if (!existing || internal.timeWatched > existing.timeWatched) {
uniqueVideos.set(internal.videoId, internal);
}
}
// 2. Procesar e importar de forma secuencial
for (const [vId, internalFormat] of uniqueVideos) {
try {
// Mezclar con datos existentes si los hay (ej. preservar mayor viewCount)
const existingInStorage = await Storage.get(vId);
let finalData = internalFormat;
if (existingInStorage) {
finalData = normalizeVideoData({
...existingInStorage,
...internalFormat,
viewCount: Math.max(existingInStorage.viewCount || 0, internalFormat.viewCount || 0)
});
}
await Storage.set(vId, finalData);
importedCount++;
if (importedCount % 20 === 0) logLog('importFromFreeTubeFormat', `Progreso: ${importedCount} importados...`);
} catch (error) {
logError('importFromFreeTubeFormat', `Error al importar ${vId}:`, error);
failedCount++;
}
}
logLog('importFromFreeTubeFormat', `Importación completa: ${importedCount} éxitos, ${failedCount} fallos de ${uniqueVideos.size} únicos.`);
return { imported: importedCount, failed: failedCount, total: freeTubeData.length };
}
// MARK: 💾 Internal Save Regular Video
/**
* Lógica interna compartida para guardar videos que no son Shorts ni Directos (Watch o Miniplayer).
* @private
*/
async function internalSaveRegularVideo(currentTime, videoInfo, videoEl, logContext = 'saveRegularVideo', options = {}) {
const { videoId, lengthSeconds: duration, lastViewedPlaylistId: playlistId } = videoInfo;
// Ignorar si el modo de guardado manual está activo y no es un guardado manual
if (cachedSettings.manualSaveMode && !options.isManual) {
logLog(logContext, `No se guardó el video ${videoId} en ${currentTime}s porque el modo de guardado manual está activo`);
return { success: false, reason: 'manual_save_mode_active' };
}
logLog(logContext, `Guardando video ${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) {
logLog(logContext, `Video ${videoId} completado, manteniendo tiempo fijo`);
// Normalizar para asegurar Format A y marcar como completado
const base = normalizeVideoData({ ...sourceData, isCompleted: true, watchProgress: 0 });
await Storage.set(videoId, base);
}
return { success: false, reason: 'fixed_time_no_overwrite' };
}
const videoData = {
...sourceData,
...Object.fromEntries(Object.entries(videoInfo).filter(([k, v]) => v != null || k.startsWith('lastViewed') || k === 'playlistTitle')),
watchProgress: currentTime,
timeWatched: now,
type: 'video',
isCompleted: isFinished,
completionHistory: sourceData?.completionHistory || []
};
const session = activeProcessingSessions.get(videoEl);
if (isFinished && session && !session.hasLoggedCompletion) {
videoData.completionHistory.push(now);
session.hasLoggedCompletion = true;
} else if (session && currentTime < (duration * 0.15) && session.hasLoggedCompletion && !cachedSettings.countOncePerSession) {
session.hasLoggedCompletion = false;
}
// Normalizar antes de guardar para asegurar Format A y limpieza de legacy
const normalizedData = normalizeVideoData(videoData);
const storageResult = await Storage.set(videoId, normalizedData);
logLog(logContext, `✅ Video guardado (${logContext}):`, {
...videoData,
description: videoData.description
? videoData.description.slice(0, 12) +
(videoData.description.length > 12 ? ' (truncada para log)' : '')
: ''
});
if (storageResult && !storageResult.success) {
return { success: false, reason: storageResult.reason, videoId, type: 'video' };
}
return { success: true, videoId, watchProgress: videoData.watchProgress, type: 'video', savedData: videoData };
}
// MARK: 💾 Save Regular Video
/**
* Guarda progreso para videos regulares (Página de Watch)
* @param {number} currentTime - Tiempo actual
* @param {Object} videoInfo - Información del video
* @returns {Object} Resultado
*/
async function saveRegularVideo(currentTime, videoInfo, videoEl, options = {}) {
return await internalSaveRegularVideo(currentTime, videoInfo, videoEl, 'saveRegularVideo', options);
}
// MARK: 💾 Save Miniplayer
/**
* Guarda progreso para videos en el Miniplayer
* @param {number} currentTime - Tiempo actual
* @param {Object} videoInfo - Información del video
* @returns {Object} Resultado
*/
async function saveMiniplayer(currentTime, videoInfo, videoEl, options = {}) {
return await internalSaveRegularVideo(currentTime, videoInfo, videoEl, 'saveMiniplayer', options);
}
// MARK: 💾 Save Shorts
/**
* Guarda progreso para Shorts
* @param {number} currentTime - Tiempo actual
* @param {Object} videoInfo - Información del short (videoId, title, author, duration, etc.)
* @returns {Object} Resultado de la operación
*/
async function saveShortsVideo(currentTime, videoInfo, videoEl, options = {}) {
const { videoId, lengthSeconds: duration, lastViewedPlaylistId: playlistId } = videoInfo;
logLog('saveShortsVideo', `Guardando short ${videoId} en ${currentTime}s`);
// Ignorar si el modo de guardado manual está activo y no es un guardado manual
if (cachedSettings.manualSaveMode && !options.isManual) {
return { success: false, reason: 'manual_save_mode_active' };
}
const sourceData = await getSavedVideoData(videoId, playlistId);
const now = Date.now();
const isFinished = duration > 0 && (currentTime / duration) * 100 >= (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent);
const videoData = {
...sourceData,
...Object.fromEntries(Object.entries(videoInfo).filter(([k, v]) => v != null || k.startsWith('lastViewed') || k === 'playlistTitle')),
watchProgress: currentTime,
timeWatched: now,
type: 'shorts',
isCompleted: isFinished,
completionHistory: sourceData?.completionHistory || []
};
const session = activeProcessingSessions.get(videoEl);
if (isFinished && session && !session.hasLoggedCompletion) {
videoData.completionHistory.push(now);
session.hasLoggedCompletion = true;
} else if (session && currentTime < (duration * 0.15) && session.hasLoggedCompletion && !cachedSettings.countOncePerSession) {
session.hasLoggedCompletion = false;
}
// nota: (duration * 0.15) 15% para asegurar que las finalizaciones se registren de manera confiable incluso para videos de corta duración donde el intervalo de guardado de 1s podría perderse durante reinicio.
const normalizedData = normalizeVideoData(videoData);
const storageResult = await Storage.set(videoId, normalizedData);
// logLog('saveShortsVideo', `✅ Short guardado:`, videoData);
logLog('saveShortsVideo', '✅ Short guardado:', {
...videoData,
description: videoData.description
? videoData.description.slice(0, 12) +
(videoData.description.length > 12 ? ' (truncada para log)' : '')
: ''
});
// Verificar si hubo error de almacenamiento
if (storageResult && !storageResult.success) {
return { success: false, reason: storageResult.reason, videoId, type: 'shorts' };
}
return { success: true, videoId, watchProgress: videoData.watchProgress, type: 'shorts', savedData: videoData };
}
// MARK: 💾 Save Preview
/**
* Guarda progreso para previews (inline playback en home/search)
* @param {number} currentTime - Tiempo actual
* @param {Object} videoInfo - Información del video (videoId, title, author, duration, etc.)
* @param {string} previewType - 'preview_watch' o 'preview_shorts'
* @returns {Object} Resultado de la operación
*/
async function savePreview(currentTime, videoInfo, videoEl, previewType, options = {}) {
const { videoId, lengthSeconds: duration, lastViewedPlaylistId: playlistId } = videoInfo;
logLog('savePreview', `Guardando preview ${previewType} para ${videoId} en ${currentTime}s`);
// Previews suelen ser auto-saves, pero respetamos el modo manual
if (cachedSettings.manualSaveMode && !options.isManual) {
return { success: false, reason: 'manual_save_mode_active' };
}
const sourceData = await getSavedVideoData(videoId, playlistId);
const now = Date.now();
// Debug detallado para investigar el estado "completed" falso
const isFinished = duration > 0 && (currentTime / duration) * 100 >= (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent);
// Log de debug intensivo para detectar flickering y completions prematuros
logLog('savePreview', `Saving Preview: videoId=${videoId}, cur=${currentTime.toFixed(2)}, dur=${duration.toFixed(2)}, isFinished=${isFinished}`);
// Verificación de seguridad adicional: no marcar como completado si el tiempo es sospechosamente bajo
// o si la duración parece ser un placeholder (YouTube a veces pone 0.1 o similar al inicio)
const safeIsFinished = isFinished && currentTime > 0.8 && duration > 1;
const resolvedVideoType = (() => {
const previousType = sourceData?.type;
if (previousType === 'video' || previousType === 'shorts' || previousType === 'live') return previousType;
return previewType;
})();
// Preservar datos previos para previews
const videoData = {
...sourceData,
...Object.fromEntries(Object.entries(videoInfo).filter(([k, v]) => v != null || k.startsWith('lastViewed') || k === 'playlistTitle')),
watchProgress: currentTime,
timeWatched: now,
type: resolvedVideoType,
isCompleted: safeIsFinished,
completionHistory: sourceData?.completionHistory || []
};
if (safeIsFinished) {
const session = activeProcessingSessions.get(videoEl);
if (session && !session.hasLoggedCompletion) {
videoData.completionHistory.push(now);
session.hasLoggedCompletion = true;
}
} else {
const session = activeProcessingSessions.get(videoEl);
if (session && currentTime < (duration * 0.15) && session.hasLoggedCompletion && !cachedSettings.countOncePerSession) {
session.hasLoggedCompletion = false;
}
}
const normalizedData = normalizeVideoData(videoData);
const storageResult = await Storage.set(videoId, normalizedData);
// logLog('savePreview', `✅ Preview guardado:`, videoData);
logLog('savePreview', '✅ Preview guardado:', {
...videoData,
description: videoData.description
? videoData.description.slice(0, 12) +
(videoData.description.length > 12 ? ' (truncada para log)' : '')
: ''
});
// Verificar si hubo error de almacenamiento
if (storageResult && !storageResult.success) {
return { success: false, reason: storageResult.reason, videoId, type: 'preview' };
}
return { success: true, videoId, watchProgress: videoData.watchProgress, type: 'preview' };
}
// MARK: 💾 Save Livestream
/**
* Guarda progreso para livestreams
* @param {number} currentTime - Tiempo actual
* @param {Object} videoInfo - Información del livestream
* @param {HTMLVideoElement} videoEl - Elemento de video
* @returns {Object} Resultado de la operación
*/
async function saveLivestream(currentTime, videoInfo, videoEl, options = {}) {
const { videoId, lengthSeconds: duration } = videoInfo;
logLog('saveLivestream', `Guardando livestream ${videoId} en ${currentTime}s`);
// Ignorar si el modo de guardado manual está activo y no es un guardado manual
if (cachedSettings.manualSaveMode && !options.isManual) {
return { success: false, reason: 'manual_save_mode_active' };
}
const sourceData = await getSavedVideoData(videoId);
const now = Date.now();
const videoData = {
...sourceData,
...Object.fromEntries(Object.entries(videoInfo).filter(([k, v]) => v != null || k.startsWith('lastViewed') || k === 'playlistTitle')),
watchProgress: currentTime,
timeWatched: now,
type: 'live',
isLive: true,
isCompleted: false
};
const normalizedData = normalizeVideoData(videoData);
const storageResult = await Storage.set(videoId, normalizedData);
// logLog('saveLivestream', `✅ Livestream guardado:`, videoData);
logLog('saveLivestream', '✅ Livestream guardado:', {
...videoData,
description: videoData.description
? videoData.description.slice(0, 12) +
(videoData.description.length > 12 ? ' (truncada para log)' : '')
: ''
});
// Verificar si hubo error de almacenamiento
if (storageResult && !storageResult.success) {
return { success: false, reason: storageResult.reason, videoId, type: 'live' };
}
return { success: true, videoId, watchProgress: videoData.watchProgress, type: 'live' };
}
// ------------------------------------------
// 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) {
logLog('getSavedVideoData', `Buscando datos guardados para ID: ${videoId} | Playlist ID: ${playlistId}`);
if (!videoId) return null;
let videoData = await Storage.get(videoId);
// Fallback context: si no se encuentra por videoId, probar en la playlist directamente
if (!videoData && playlistId) {
const oldPlaylistData = await Storage.get(playlistId);
if (oldPlaylistData?.videos?.[videoId]) {
logLog('getSavedVideoData', `✅ Video encontrado en formato antiguo (playlist anidada)`);
videoData = oldPlaylistData.videos[videoId];
}
}
// Búsqueda flexible adicional
if (!videoData) {
const keys = (await Storage.keys?.()) || [];
const altKey = keys.find(k => (k.endsWith(videoId) || k.includes(videoId)) && !isNonVideoStorageKey(k));
if (altKey) {
logLog('getSavedVideoData', `✅ Video encontrado con clave alternativa: ${altKey}`);
videoData = await Storage.get(altKey);
}
}
if (videoData) {
// NORMALIZACIÓN: Asegurar que siempre se devuelven los campos estándar
// para que los consumidores (como initPlaybackBar) no tengan que lidiar con fallbacks
return {
...videoData,
watchProgress: videoData.watchProgress ?? 0,
lengthSeconds: videoData.lengthSeconds ?? 0,
timeWatched: videoData.timeWatched ?? Date.now(),
type: videoData.type ?? 'video'
};
}
logLog('getSavedVideoData', `✗ No se encontraron datos para el video`);
return null;
}
// MARK: 📺 Get Player Video ID
/**
* Obtiene el ID del video desde el reproductor de YouTube.
* @param {HTMLDivElement} player - Elemento del reproductor de YouTube.
* @returns {string|null} - ID del video o null si no se pudo obtener.
*/
const getPlayerVideoId = (player) => {
if (typeof player?.getPlayerResponse === 'function') {
const resp = player.getPlayerResponse()
const videoDetailsVideoId = resp?.videoDetails?.videoId
const microformatVideoId = resp?.microformat?.playerMicroformatRenderer?.externalVideoId
logInfo('getPlayerVideoId', `getPlayerResponse DETECTADO
videoDetailsVideoId: ${videoDetailsVideoId}
microformatVideoId: ${microformatVideoId}`)
const id = videoDetailsVideoId || microformatVideoId
if (id) return id
}
if (typeof player?.getVideoData === 'function') {
const id = player.getVideoData()?.video_id
logInfo('getPlayerVideoId', 'getVideoData DETECTADO', id)
if (id) return id
}
if (currentPageType === 'watch' || currentPageType === 'shorts') {
const { id } = extractOrNormalizeVideoId(location.href)
logInfo('getPlayerVideoId', 'Usando fallback desde URL', id)
if (id) return id
}
logWarn('getPlayerVideoId', '❌ no se pudo obtener videoId')
return null
}
// MARK: 📺 Get YouTube Page Type
let _cachedPageType = null;
let _lastPath = null;
const CHANNEL_SPECIAL = {
'UC-9-kyTW8ZkZNDHQJ6FgpwQ': 'music',
'UCYfdidRxbB8Qhf0Nx7ioOYw': 'news',
'UCEgdi0XIXXZ-qJOFPf4JSKw': 'sports',
'UCtFRv9O2AHqOZjjynzrv-xg': 'learning'
};
function getTypeFromPageManager() {
const manager = document.querySelector('ytd-page-manager');
if (!manager) return null;
const page = manager.getAttribute('page-subtype') || manager.getAttribute('page-type');
switch (page) {
case 'watch':
return 'watch';
case 'shorts':
return 'shorts';
case 'channel':
return 'channel';
case 'search':
return 'search';
}
return null;
}
function getTypeFromYtApp() {
const app = document.querySelector('ytd-app');
if (!app) return null;
try {
const page = app.__data?.page;
switch (page) {
case 'watch':
return 'watch';
case 'browse':
return 'channel';
case 'search':
return 'search';
}
} catch { }
return null;
}
function detectFromURL(path) {
if (path === '/') return 'home';
const c1 = path.charCodeAt(1);
switch (c1) {
case 115: // s
if (path.startsWith('/shorts')) return 'shorts';
if (path.startsWith('/sports')) return 'sports';
if (path.startsWith('/subscriptions')) return 'subscriptions';
if (path.startsWith('/search') || path.startsWith('/results')) return 'search';
break;
case 119: // w
if (path.startsWith('/watch')) return 'watch';
break;
case 101: // e
if (path.startsWith('/embed')) return 'embed';
break;
case 112: // p
if (path.startsWith('/playlist')) return 'playlist';
break;
case 103: // g
if (path.startsWith('/gaming')) return 'gaming';
break;
case 102: // f
if (path.startsWith('/feed/you')) return 'you';
if (path.startsWith('/feed/history')) return 'history';
if (path.startsWith('/feed/subscriptions')) return 'subscriptions';
break;
case 64: // @
return 'channel';
case 99: // c
if (path.startsWith('/channel') || path.startsWith('/c/')) return 'channel';
break;
case 117: // u
if (path.startsWith('/user')) return 'channel';
break;
case 85: // U
if (path.startsWith('/UC')) {
const id = path.slice(1);
return CHANNEL_SPECIAL[id] || 'channel';
}
}
// 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.
if (path.includes('/live')) return 'live';
return 'unknown';
}
function cachePageType(path, type) {
_lastPath = path;
_cachedPageType = type;
return type;
}
function getYouTubePageType() {
const path = location.pathname;
// Retornar caché si la URL no cambió
if (path === _lastPath && _cachedPageType !== null) return _cachedPageType;
// intentar desde page-manager (más fiable)
const managerType = getTypeFromPageManager();
if (managerType) return cachePageType(path, managerType);
// intentar desde datos internos de ytd-app
const appType = getTypeFromYtApp();
if (appType) return cachePageType(path, appType);
// fallback: detección por URL
return cachePageType(path, detectFromURL(path));
}
// ------------------------------------------
// MARK: 📺 Extraer o Normalizar Video ID
// ------------------------------------------
/**
* Extrae o normaliza un video ID de YouTube desde URL, embed o ID directo.
* Soporta:
* - URLs normales: watch?v=ID
* - Shorts: /shorts/ID
* - Short URLs: youtu.be/ID
* - Embeds: /embed/ID
* - IDs directos
* @param {string} input - URL completa o ID de video.
* @returns {Object|null} - { type: "video" | "playlist" | "channel" | "live" | "unknown", id: string, list?: string }
* @example
* extractOrNormalizeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ") // { type: "video", id: "dQw4w9WgXcQ" }
* extractOrNormalizeVideoId("https://www.youtube.com/shorts/dQw4w9WgXcQ") // { type: "shorts", id: "dQw4w9WgXcQ" }
* extractOrNormalizeVideoId("https://www.youtube.com/playlist?list=PLdQw4w9WgXcQ") // { type: "playlist", id: "PLdQw4w9WgXcQ" }
* extractOrNormalizeVideoId("https://www.youtube.com/channel/UCdQw4w9WgXcQ") // { type: "channel", id: "UCdQw4w9WgXcQ" }
* extractOrNormalizeVideoId("https://www.youtube.com/live/dQw4w9WgXcQ") // { type: "live", id: "dQw4w9WgXcQ" }
* extractOrNormalizeVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ") // { type: "video", id: "dQw4w9WgXcQ" }
* extractOrNormalizeVideoId("dQw4w9WgXcQ") // { type: "video", id: "dQw4w9WgXcQ" }
*/
function extractOrNormalizeVideoId(input) {
if (!input || typeof input !== 'string') return null;
const trimmed = input.trim();
// Si es solo un ID directo (no URL)
if (/^[A-Za-z0-9_-]{6,}$/.test(trimmed)) {
return { type: "video", id: trimmed };
}
try {
const url = new URL(trimmed);
// --- LISTAS ---
const listParam = url.searchParams.get("list");
if (url.pathname.includes("/playlist") && listParam) {
return { type: "playlist", id: listParam };
}
// --- VIDEOS ---
// watch?v=ID
const vParam = url.searchParams.get("v");
if (vParam) {
const result = { type: "video", id: vParam };
if (listParam) result.list = listParam; // video dentro de lista
return result;
}
// shorts/ID
const shortsMatch = url.pathname.match(/\/shorts\/([A-Za-z0-9_-]{6,})/);
if (shortsMatch) return { type: "video", id: shortsMatch[1] };
// embed/ID
const embedMatch = url.pathname.match(/\/embed\/([A-Za-z0-9_-]{6,})/);
if (embedMatch) return { type: "video", id: embedMatch[1] };
// short URL (youtu.be/ID)
if (url.hostname.includes("youtu.be")) {
const shortId = url.pathname.slice(1);
if (/^[A-Za-z0-9_-]{6,}$/.test(shortId)) {
const result = { type: "video", id: shortId };
if (listParam) result.list = listParam;
return result;
}
}
} catch {
// Si no es URL válida, continuar
}
// Si todo falla, decidir severidad según contexto de URL
try {
const url = new URL(trimmed);
const host = url.hostname || '';
const path = url.pathname || '';
const isYouTube = /(^|\.)youtube\.com$/i.test(host) || /youtu\.be$/i.test(host);
const isNonVideoPath = (
path === '/' ||
path.startsWith('/@') ||
path.startsWith('/channel/') ||
path.startsWith('/user/') ||
path.startsWith('/c/') ||
path.startsWith('/feed') ||
path.startsWith('/results') ||
path.startsWith('/account') ||
path.startsWith('/playlist') && !url.searchParams.get('list')
);
if (isYouTube && isNonVideoPath) {
logLog('extractOrNormalizeVideoId', 'No es página de video, omitiendo:', input);
return null;
}
} catch { }
// Caso general: advertencia (solo si no es homepage/feed ya manejado)
if (trimmed && !trimmed.includes('youtube.com/')) {
logWarn('extractOrNormalizeVideoId: no se pudo determinar video_id para', input);
}
return null;
}
// MARK: 📺 Get Playlist Name
const playlistNameCache = new Map();
const pendingPlaylistRequests = new Map(); // Track pending HTTP requests
const playlistNameFetchCooldowns = new Map();
const PLAYLIST_NAME_FETCH_COOLDOWN_MS = 15 * 60 * 1000;
/**
* Determina si se debe evitar una nueva solicitud HTTP del título de playlist.
* @param {string} playlistId - ID de la playlist.
* @returns {boolean} True si la petición debe ser aplazada.
*/
const shouldThrottlePlaylistNameFetch = (playlistId) => {
const lastAttempt = playlistNameFetchCooldowns.get(playlistId);
if (!lastAttempt) return false;
return (Date.now() - lastAttempt) < PLAYLIST_NAME_FETCH_COOLDOWN_MS;
};
/**
* Obtiene el nombre de una playlist desde YouTube API o DOM o la URL.
* @param {string} playlistId - ID de la playlist.
* @returns {string|null} Nombre de la playlist o null si no se encuentra.
*
* Ejemplo de URL:
* https://www.youtube.com/watch?v=VIDEO_ID&list=PLAYLIST_ID
*/
async function getPlaylistName(playlistId) {
if (!playlistId) return null;
// 1. Verificar cache en memoria
if (playlistNameCache.has(playlistId)) {
const cachedTitle = playlistNameCache.get(playlistId);
// Si el cache tiene un título válido (no genérico), usarlo directamente
if (cachedTitle && cachedTitle !== playlistId) {
logLog('getPlaylistName', `✅ Usando título cacheado válido para ${playlistId}: "${cachedTitle}"`);
return cachedTitle;
}
}
// 2. Verificar si hay una petición en curso
if (pendingPlaylistRequests.has(playlistId)) {
logLog('getPlaylistName', `⏳ Ya existe una petición en curso para ${playlistId}, reutilizando promesa...`);
return pendingPlaylistRequests.get(playlistId);
}
const requestPromise = (async () => {
const url = new URL(location.href);
const currentPlaylistId = url.searchParams.get('list');
logLog('getPlaylistName', `currentPlaylistId: ${currentPlaylistId}`);
if (currentPlaylistId === playlistId) {
if (currentPageType !== 'watch' && currentPageType !== 'playlist') {
logLog('getPlaylistName', `No estamos en watch o playlist, saltando busqueda en DOM`);
return null;
}
let playlistName = null;
if (currentPageType === 'watch') {
// Intentar múltiples selectores para el panel de playlist solo en watch
playlistName = DOMHelpers.get(`playlist:name:${playlistId}`, () => (
// Playlist panel en el reproductor (Watch Page Sidebar)
document.querySelector('ytd-playlist-panel-renderer #header-description h3 a') ||
document.querySelector('ytd-playlist-panel-renderer #header-description h3') ||
// YouTube Mix y estructuras antiguas
document.querySelector('ytd-playlist-panel-renderer yt-formatted-string.title') ||
document.querySelector('#header-description yt-formatted-string.title') ||
// Alternativas adicionales
document.querySelector('#container #header-description yt-formatted-string') ||
document.querySelector('yt-formatted-string.title:nth-child(1)') ||
// Overlay del reproductor
document.querySelector('.byline-title')
), 250);
}
if (currentPageType === 'playlist') {
// si estamos en la página de la playlist, escenario miniplayer
playlistName = DOMHelpers.get(`playlist:browseName:${playlistId}`, () => (
document.querySelector('.yt-page-header-view-model__page-header-title h1') ||
document.querySelector('yt-page-header-view-model h1.dynamicTextViewModelH1')
), 250);
}
const finalDomName = playlistName?.textContent?.trim();
logLog('getPlaylistName', `finalDomName: ${finalDomName}`);
if (finalDomName && finalDomName !== playlistId) {
playlistNameCache.set(playlistId, finalDomName);
return finalDomName;
}
}
// Solo hacer HTTP request si no hay cache válido o si el cache es genérico
if (shouldThrottlePlaylistNameFetch(playlistId)) {
logLog('getPlaylistName', `⏳ Cooldown activo para ${playlistId}, evitando nueva solicitud`);
return playlistNameCache.get(playlistId) || playlistId;
}
return new Promise((resolve) => {
logLog('getPlaylistName', `🌐 Making HTTP request for playlist ${playlistId}`);
playlistNameFetchCooldowns.set(playlistId, Date.now());
GM_xmlhttpRequest({
method: 'GET',
url: `https://www.youtube.com/playlist?list=${playlistId}`,
onload: function (response) {
try {
const htmlText = response.responseText;
let ytInitialDataMatch = htmlText.match(/var ytInitialData = ({.+?});/) || htmlText.match(/window\["ytInitialData"\] = ({.+?});/);
let title = null;
if (ytInitialDataMatch) {
try {
const data = JSON.parse(ytInitialDataMatch[1]);
title = data?.contents?.twoColumnWatchNextResults?.playlist?.playlist?.title ||
data?.contents?.twoColumnWatchNextResults?.playlist?.playlist?.header?.title ||
data?.header?.playlistHeaderRenderer?.title?.simpleText ||
data?.header?.playlistHeaderRenderer?.title?.runs?.[0]?.text ||
data?.metadata?.playlistMetadataRenderer?.title ||
data?.microformat?.microformatDataRenderer?.title
} catch (_) { }
}
if (!title || title === 'null') {
const titleMatch = htmlText.match(/<title>(.*?) - YouTube<\/title>/) || htmlText.match(/<title>(.*?)<\/title>/);
if (titleMatch) title = titleMatch[1].trim();
}
title = (title && title !== 'null' && title !== 'undefined') ? title : playlistId;
playlistNameCache.set(playlistId, title);
resolve(title);
} catch (e) {
resolve(playlistId);
}
},
onerror: () => resolve(playlistId)
});
});
})();
pendingPlaylistRequests.set(playlistId, requestPromise);
return requestPromise.finally(() => {
pendingPlaylistRequests.delete(playlistId);
});
}
// MARK: 🕒 Time Display
let watchTimeDisplay;
let shortsTimeDisplay;
let shortsPanelObserver = null;
let shortsRetryTimers = [];
let miniplayerTimeDisplay;
let inlinePreviewTimeDisplay;
/**
* Map de timeouts de limpieza de mensajes por contexto de display.
* Reemplaza las 4 variables individuales clearXxxTimeout.
* @type {Map<'watch'|'shorts'|'mini'|'preview', ReturnType<typeof setTimeout>>}
*/
const displayClearTimeouts = new Map();
/**
* Programa la limpieza automática del mensaje de un display.
* Cancela cualquier timeout previo para el mismo contexto antes de crear uno nuevo.
* @param {'watch'|'shorts'|'mini'|'preview'} context - Contexto del display.
* @param {() => void} clearFn - Función que limpia el display.
* @param {number} delayMs - Milisegundos hasta la limpieza.
*/
const scheduleDisplayClear = (context, clearFn, delayMs) => {
const prev = displayClearTimeouts.get(context);
if (prev) clearTimeout(prev);
displayClearTimeouts.set(context, setTimeout(() => {
try { clearFn(); } catch (_) { }
displayClearTimeouts.delete(context);
}, delayMs));
};
// ------------------------------------------
// MARK: 🖼️ Display Button Helpers
// ------------------------------------------
/**
* Muestra un mensaje dentro del button-group de un display,
* manteniendo los botones de acción visibles y mostrando el span de mensaje al final.
* @param {HTMLElement} displayEl - Elemento raíz del display (.ypp-time-display).
* @param {string} message - HTML/texto a mostrar en el span de mensaje.
*/
function showDisplayMessage(displayEl, message) {
if (!displayEl) return;
const messageEl = displayEl.querySelector('.ypp-time-display-message');
if (messageEl) {
setInnerHTML(messageEl, message);
messageEl.classList.remove('ypp-d-none');
}
displayEl.classList.remove('ypp-d-none');
}
/**
* Restaura el estado de botones del button-group:
* asegura que los botones sean visibles y oculta el span de mensaje.
* @param {HTMLElement} displayEl - Elemento raíz del display (.ypp-time-display).
*/
function restoreDisplayButtons(displayEl) {
if (!displayEl) return;
const btnManualSave = displayEl.querySelector('.ypp-btn-save');
const messageEl = displayEl.querySelector('.ypp-time-display-message');
if (btnManualSave) {
const isFixed = displayEl.dataset.isFixedTime === 'true';
if (cachedSettings?.manualSaveMode !== false && !isFixed) {
btnManualSave.classList.remove('ypp-d-none');
} else {
btnManualSave.classList.add('ypp-d-none');
}
}
if (messageEl) {
messageEl.classList.add('ypp-d-none');
}
displayEl.classList.remove('ypp-d-none');
}
/**
* Sincroniza el estado de tiempo fijo en todos los displays activos si corresponden al videoId.
* @param {string} videoId - ID del video actualizado.
* @param {boolean} isFixedTime - Nuevo estado de tiempo fijo.
*/
function syncFixedTimeUI(videoId, isFixedTime, timeValue = 0) {
if (!videoId) return;
const syncDisplay = (display, player) => {
if (!display?.isConnected) return;
const currentPlayerId = (typeof player === 'string') ? player : getPlayerVideoId(player);
if (currentPlayerId === videoId) {
if (isFixedTime) {
display.dataset.isFixedTime = 'true';
// Generar y mostrar mensaje persistente
const timeStr = formatTime(normalizeSeconds(timeValue));
const icon = `${SVG_ICONS.timer}${SVG_ICONS.pin}`;
const text = `${t('alwaysStartFrom')}: ${timeStr}`;
const message = (alertStyles[cachedSettings.alertStyle])(icon, text, timeStr);
showDisplayMessage(display, message);
} else {
delete display.dataset.isFixedTime;
restoreDisplayButtons(display);
}
// Sincronizar visibilidad del botón de guardado manual
syncManualSaveUI(videoId, true, isFixedTime);
logLog('syncFixedTimeUI', `Sincronizada UI (${display.id || 'shorts'}) para ${videoId}: forced=${isFixedTime}`);
}
};
syncDisplay(watchTimeDisplay, DOMHelpers.getWatchPlayer());
syncDisplay(shortsTimeDisplay, DOMHelpers.getShortsPlayer());
syncDisplay(miniplayerTimeDisplay, DOMHelpers.getMiniplayerPlayer());
syncDisplay(inlinePreviewTimeDisplay, DOMHelpers.getInlinePreviewPlayer());
}
/**
* Sincroniza el ícono de guardado manual en todos los displays activos si corresponden al videoId.
* @param {string} videoId - ID del video actualizado.
* @param {boolean} isSaved - Si el video se encuentra guardado en la DB.
*/
function syncManualSaveUI(videoId, isSaved, forceFixed) {
if (!videoId) return;
const syncDisplay = (display, player) => {
if (!display?.isConnected) return;
const currentPlayerId = (typeof player === 'string') ? player : getPlayerVideoId(player);
if (currentPlayerId === videoId) {
const saveBtn = display.querySelector('.ypp-btn-save');
if (saveBtn) {
// Si se pasa forceFixed, lo usamos. Si no, respetamos el estado actual del dataset.
const isActuallyFixed = forceFixed !== undefined ? forceFixed : (display.dataset.isFixedTime === 'true');
if (isActuallyFixed) {
display.dataset.isFixedTime = 'true';
saveBtn.classList.add('ypp-d-none');
} else {
delete display.dataset.isFixedTime;
if (cachedSettings?.manualSaveMode !== false) {
saveBtn.classList.remove('ypp-d-none');
}
const targetVal = isSaved ? 'true' : 'false';
if (saveBtn.dataset.isSaved !== targetVal) {
saveBtn.dataset.isSaved = targetVal;
// Si video esta guardado, el boton cambia su color hover a verde usando clase .ypp-btn-save-hover-color-when-saved
// https://github.com/Alplox/Youtube-Playback-Plox/issues/23#issuecomment-4226733745
saveBtn.classList.toggle('ypp-btn-save-hover-color-when-saved', isSaved);
// Si video esta guardado, el boton cambia icono a bookmarkFill, si no mantiene bookmarkOutline
// setInnerHTML(saveBtn, isSaved ? SVG_ICONS.bookmarkFill : SVG_ICONS.bookmarkOutline);
}
}
}
}
};
syncDisplay(watchTimeDisplay, DOMHelpers.getWatchPlayer());
syncDisplay(shortsTimeDisplay, DOMHelpers.getShortsPlayer());
syncDisplay(miniplayerTimeDisplay, DOMHelpers.getMiniplayerPlayer());
syncDisplay(inlinePreviewTimeDisplay, DOMHelpers.getInlinePreviewPlayer());
}
/**
* Crea la estructura interna (DOM) base de un split-button-group para los displays de tiempo.
* Reutilizable en Watch, Miniplayer, Shorts e Inline Preview.
*
* Estructura generada:
* [listBtn (.ypp-btn-history)] | [message (.ypp-time-display-message)]
*
* @returns {{ listBtn: HTMLButtonElement, messageEl: HTMLSpanElement }} Nodos creados.
*/
function createSplitButtonGroup() {
const listBtn = createElement('button', {
className: 'ypp-btn-history',
html: SVG_ICONS.clockRotateLeft,
atribute: { title: t('savedVideos'), type: 'button' },
onClickEvent: (e) => {
e.stopPropagation();
e.preventDefault();
showSavedVideosList();
}
});
const messageEl = createElement('span', { className: 'ypp-time-display-message ypp-d-none' });
return { listBtn, messageEl };
}
/**
* Configura y añade el botón de guardado manual a un display de tiempo.
* Centraliza la lógica de guardado manual evitando callbacks redundantes.
*
* @param {HTMLElement} displayEl - Elemento raíz del display (.ypp-time-display).
* @param {HTMLElement} player - Instancia del player correspondiente.
* @param {string} contextType - Contexto ('watch', 'shorts', 'miniplayer', 'preview').
*/
function setupManualSaveButton(displayEl, player, contextType) {
if (!cachedSettings?.manualSaveMode || !displayEl) return;
// Evitar duplicados si ya fue inyectado
if (displayEl.querySelector('.ypp-btn-save')) return;
const saveBtn = createElement('button', {
className: 'ypp-btn-save',
html: SVG_ICONS.bookmarkOutline,
atribute: { title: t('save'), type: 'button' },
onClickEvent: async (e) => {
e.stopPropagation();
e.preventDefault();
const video =
contextType === 'watch' ? DOMHelpers.getWatchPlayerVideo() :
contextType === 'shorts' ? DOMHelpers.getShortsPlayerVideo() :
contextType === 'miniplayer' ? DOMHelpers.getMiniplayerPlayerVideo() :
contextType === 'preview' ? DOMHelpers.getInlinePreviewPlayerVideo() : null;
const videoId = getPlayerVideoId(player);
if (!videoId || !video) {
logWarn('setupManualSaveButton', 'No se pudo determinar video o ID para guardado manual');
return;
}
logLog('setupManualSaveButton', `Guardando manualmente el video ${videoId} en el contexto ${contextType}`);
await PlaybackController.saveStatus(player, video, contextType, videoId, null, { isManual: true });
}
});
// Insertar después del botón de lista (listBtn)
const btnHistory = displayEl.querySelector('.ypp-btn-history');
if (btnHistory) {
btnHistory.insertAdjacentElement('afterend', saveBtn);
} else {
displayEl.appendChild(saveBtn);
}
}
/**
* Inicializa la visualización de tiempo en la barra de reproducción del player Watch.
* Idempotente: retorna sin efecto si el display ya está conectado al DOM.
* @param {HTMLElement} playerContainer - Referencia al player root (#movie_player).
*/
function initTimeDisplay(playerContainer) {
/*
<div class="ytp-time-display notranslate" style="">
<div class="ytp-time-wrapper ytp-time-wrapper-delhi">
<div class="ytp-time-contents" role="button" tabindex="0"
aria-label="-0 minutos 45 segundos de 2 minutos 30 segundos"><span class="ytp-time-clip-icon"
aria-label="Clip"><svg height="100%" version="1.1" viewBox="0 0 24 24" width="100%">
<path
d="M22,3h-4l-5,5l3,3l6-6V3L22,3z M10.79,7.79C10.91,7.38,11,6.95,11,6.5C11,4.01,8.99,2,6.5,2S2,4.01,2,6.5S4.01,11,6.5,11 c0.45,0,.88-0.09,1.29-0.21L9,12l-1.21,1.21C7.38,13.09,6.95,13,6.5,13C4.01,13,2,15.01,2,17.5S4.01,22,6.5,22s4.5-2.01,4.5-4.5 c0-0.45-0.09-0.88-0.21-1.29L12,15l6,6h4v-2L10.79,7.79z M6.5,8C5.67,8,5,7.33,5,6.5S5.67,5,6.5,5S8,5.67,8,6.5S7.33,8,6.5,8z M6.5,19C5.67,19,5,18.33,5,17.5S5.67,16,6.5,16S8,16.67,8,17.5S7.33,19,6.5,19z">
</path>
</svg></span><span class="ytp-time-current">-0:45</span><span class="ytp-time-separator"> / </span><span
class="ytp-time-duration">2:30</span></div><button class="ytp-live-badge ytp-button">En vivo</button><span
class="ypp-time-display" id="ypp-time-display-indicator" title="Ver videos guardados"><svg
class="ypp-svgFolderIcon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2h-8l-2-2z"></path>
</svg></span>
</div><span class="ytp-clip-watch-full-video-button-separator">•</span><span
class="ytp-clip-watch-full-video-button">Mirar video completo</span>
</div>
└─ div.ytp-time-display.notranslate
├─ div.ytp-time-wrapper.ytp-time-wrapper-delhi
│ ├─ div.ytp-time-contents
│ │ ├─ span.ytp-time-clip-icon
│ │ │ └─ svg.[object.SVGAnimatedString]
│ │ │ └─ path.[object.SVGAnimatedString]
│ │ ├─ span.ytp-time-current
│ │ ├─ span.ytp-time-separator
│ │ └─ span.ytp-time-duration
│ ├─ button.ytp-live-badge.ytp-button
│ └─ span#ypp-time-display-indicator.ypp-time-display
│ └─ svg.[object.SVGAnimatedString]
│ └─ path.[object.SVGAnimatedString]
├─ span.ytp-clip-watch-full-video-button-separator
└─ span.ytp-clip-watch-full-video-button
*/
// Cleanup defensivo: si el miniplayer dejó su span en esta misma barra de controles, eliminarlo
destroyMiniplayerTimeDisplay();
// Si ya está conectado y TIENE la estructura de split button (v2), no hacer nada
if (watchTimeDisplay?.isConnected && watchTimeDisplay.querySelector('.ypp-time-display-message')) return;
// Si el player no existe, no hacer nada
if (!playerContainer) return;
// Si ya existe pero no tiene la estructura v2, limpiarlo para re-crear
if (watchTimeDisplay) {
try { watchTimeDisplay.remove(); } catch (_) { }
watchTimeDisplay = null;
}
logLog('initTimeDisplay', 'playerContainer', playerContainer);
// Soporte para el rediseño "Delhi": el contenedor es un pill wrapper (.ytp-time-wrapper)
const timeWrapper = DOMHelpers.get('player:timeWrapper', () =>
playerContainer.querySelector('.ytp-time-wrapper')
?? document.querySelector('.ytp-time-wrapper'), 100);
logLog('initTimeDisplay', 'timeWrapper seleccionado:', timeWrapper);
watchTimeDisplay = createElement('div', {
id: 'ypp-time-display-indicator',
className: 'ypp-time-display'
});
// Crear las dos partes del botón usando el helper compartido
const { listBtn, messageEl } = createSplitButtonGroup();
watchTimeDisplay.appendChild(listBtn);
// El botón de guardado manual se añade mediante helper si está habilitado
setupManualSaveButton(watchTimeDisplay, playerContainer, 'watch');
watchTimeDisplay.appendChild(messageEl);
delete watchTimeDisplay.dataset.isFixedTime;
// En Delhi UI, insertar al final (después del badge de directo)
if (timeWrapper) {
timeWrapper.insertAdjacentElement('beforeend', watchTimeDisplay);
}
logLog('initTimeDisplay', '✅ Creada visualización de tiempo en la barra de reproducción');
clearPlaybackBarMessage();
}
/**
* Determina si un elemento está visible en el layout (no display:none/visibility:hidden, con tamaño > 0)
* @param {HTMLElement} el
* @returns {boolean}
*/
function isVisiblyDisplayed(el) {
if (!el || !el.isConnected) return false;
try {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const visible = rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity || '1') > 0;
return visible;
} catch (_) {
return !!el.offsetWidth && !!el.offsetHeight;
}
}
/**
* Obtiene el contenedor de controles del Short activo (#metapanel)
* Intenta seleccionar el overlay del Short actualmente activo, con fallbacks seguros.
* @returns {HTMLElement|null} Contenedor de controles del Short activo o null si no existe aún.
*/
function getActiveShortsControlsContainer() {
// return null; // para testear fallback floating
// En la nueva interfaz de Shorts, pueden existir múltiples de estos contenedores
// precargados. Iteramos y devolvemos el que realmente es visible.
const panels = document.querySelectorAll(S.IDS.METAPANEL);
for (const panel of panels) {
if (isVisiblyDisplayed(panel)) {
return panel;
}
}
// Priorizar el overlay del reproductor de Shorts (UI visible)
// ejemplo jerarquia en DOM
// └─ ytd-shorts.style-scope.ytd-page-manager
// ├─ div#header.style-scope.ytd-shorts
// ├─ div#offline-container.style-scope.ytd-shorts
// └─ div#shorts-container.style-scope.ytd-shorts
// └─ div#cinematic-shorts-scrim.style-scope.ytd-shorts
// └─ div#shorts-inner-container.style-scope.ytd-shorts
// └─ div#0.reel-video-in-sequence-new.style-scope.ytd-shorts
// └─ div#experiment-overlay.overlay.style-scope.ytd-reel-video-renderer
// └─ ytd-reel-player-overlay-renderer.style-scope.ytd-reel-video-renderer
// └─ div.metadata-container.style-scope.ytd-reel-player-overlay-renderer
// └─ div#overlay.style-scope.ytd-reel-player-overlay-renderer
// └─ div#metapanel
const selectors = [
`${S.ELEMENTS.REEL_VIDEO_RENDERER} ${S.IDS.METAPANEL}`,
`${S.ELEMENTS.REEL_VIDEO_RENDERER} ${S.IDS.METADATA_CONTAINER}`,
`ytd-reel-player-overlay-renderer .metadata-container`,
`ytd-reel-player-overlay-renderer ${S.IDS.METADATA_CONTAINER}`,
`ytd-reel-player-overlay-renderer ${S.IDS.METAPANEL}`,
`#experiment-overlay ${S.IDS.METAPANEL}`,
`#reel-overlay-container ${S.IDS.METAPANEL}`,
`${S.ELEMENTS.YTD_SHORTS} ${S.IDS.METAPANEL}`,
`${S.ELEMENTS.YTD_SHORTS} ${S.IDS.METADATA_CONTAINER}`
];
for (const selector of selectors) {
const elements = document.querySelectorAll(selector);
for (const el of elements) {
if (isVisiblyDisplayed(el)) {
return el;
}
}
}
// Fallback final: No se encontró ningún contenedor que sea visible
return null;
}
/**
* Inicializa la visualización de tiempo para videos Shorts.
* Idempotente: retorna sin efecto si el display ya está conectado al DOM y es válido (v2).
*/
function initShortsTimeDisplay() {
// Buscar el contenedor de controles dentro del Short ACTIVO
const shortsPlayerControls = getActiveShortsControlsContainer();
const overlayRoot =
document.querySelector('ytd-reel-player-overlay-renderer') ||
DOMHelpers.getShortsPlayer() ||
document.querySelector(`${S.IDS.YTD_SHORTS}`);
logLog('initShortsTimeDisplay', 'shortsPlayerControls encontrado:', shortsPlayerControls)
// 1. "Find or Create" logic for global reference
if (!shortsTimeDisplay || !document.contains(shortsTimeDisplay)) {
const existing = document.querySelector('.ypp-shorts-time-display');
if (existing) {
shortsTimeDisplay = existing;
} else {
// Crear estructura v2 si no existe en el DOM
shortsTimeDisplay = createElement('div', {
className: 'ypp-shorts-time-display'
});
const { listBtn, messageEl } = createSplitButtonGroup();
shortsTimeDisplay.appendChild(listBtn);
setupManualSaveButton(shortsTimeDisplay, DOMHelpers.getShortsPlayer(), 'shorts');
shortsTimeDisplay.appendChild(messageEl);
// Inicializar visualmente en estado de reposo (remover clase oculta)
clearShortsMessage();
}
}
// 2. Limpieza agresiva de duplicados en el panel activo
if (shortsPlayerControls) {
const redundant = shortsPlayerControls.querySelectorAll('.ypp-shorts-time-display');
redundant.forEach(el => {
if (el !== shortsTimeDisplay) el.remove();
});
}
// 3. Re-anclaje (Re-anchoring)
const target = shortsPlayerControls || overlayRoot || document.body;
if (shortsTimeDisplay.parentElement !== target) {
try { target.appendChild(shortsTimeDisplay); } catch (_) { }
}
// 4. Actualizar estado visual (floating vs anchored)
shortsTimeDisplay.classList.toggle('ypp-floating', !shortsPlayerControls);
if (shortsPlayerControls) {
logLog('initShortsTimeDisplay', '✅ Visualización de tiempo para Shorts vinculada al Metapanel');
// Si lo encontramos y se incrustó correctamente, detener operaciones de reintento pendientes
shortsRetryTimers.forEach(t => clearTimeout(t));
shortsRetryTimers = [];
// Si lo encontramos, detener el observador si existía
if (shortsPanelObserver) {
try { shortsPanelObserver.disconnect(); } catch (_) { }
shortsPanelObserver = null;
}
} else {
startShortsPanelObserver();
logLog('initShortsTimeDisplay', 'Metapanel no disponible; usando fallback flotante');
}
}
/**
* Inicia un observador para detectar cuándo aparece el metapanel en Shorts
* y anclar el botón de guardado correctamente.
*/
function startShortsPanelObserver() {
if (shortsPanelObserver) return;
logLog('startShortsPanelObserver', '🔍 Iniciando observador y reintentos para Metapanel...');
// Purga segura de reintentos acumulados
shortsRetryTimers.forEach(t => clearTimeout(t));
shortsRetryTimers = [];
// 1. Reintentos temporizados (Brute-force para cargas lentas)
const checkPoints = [500, 1000, 2000, 4000, 8000];
checkPoints.forEach(ms => {
const timer = setTimeout(() => {
const panel = getActiveShortsControlsContainer();
if (panel) {
logInfo('startShortsPanelObserver', `✅ Metapanel encontrado por reintento (${ms}ms)`);
initShortsTimeDisplay();
}
}, ms);
shortsRetryTimers.push(timer);
});
// 2. Observador de mutaciones para cambios dinámicos
shortsPanelObserver = new MutationObserver(debounce(() => {
const panel = getActiveShortsControlsContainer();
if (panel && (shortsTimeDisplay?.parentElement !== panel || shortsTimeDisplay?.classList.contains('ypp-floating'))) {
logInfo('startShortsPanelObserver', '✅ Metapanel detectado por MutationObserver, re-anclando...');
initShortsTimeDisplay();
}
}, 150));
try {
shortsPanelObserver.observe(document.body, { childList: true, subtree: true });
} catch (e) {
logError('startShortsPanelObserver', 'Error al iniciar observador', e);
}
}
// ------------------------------------------
// MARK: 📢 Playback Bar Messages
// ------------------------------------------
/**
* Actualiza el mensaje en la barra de reproducción.
* El display debe estar previamente inicializado por `processWatchVideo` vía `initTimeDisplay`.
* @param {string} message - Mensaje a mostrar
* @param {HTMLElement} videoEl - Elemento de video para verificar estado de pausa
*/
function updateWatchPlaybackBarMessage(message, videoEl, isSeek = false, isFixedTime = false, isManual = false) {
if (!watchTimeDisplay?.isConnected) return;
// No sobreescribir mensajes importantes (seek/fixed) con progreso si está pausado
const isVideoPaused = videoEl?.paused ?? false;
const hasActiveSeek = watchTimeDisplay.dataset.activeSeek === 'true';
const hasFixedTime = watchTimeDisplay.dataset.isFixedTime === 'true';
if (!isSeek && !isFixedTime && isVideoPaused && (hasActiveSeek || hasFixedTime) && !isManual) {
return; // Preservar el mensaje importante
}
// Actualizar contenido y visibilidad usando helper compartido
showDisplayMessage(watchTimeDisplay, message);
// Actualizar metadatos de estado
if (isSeek) watchTimeDisplay.dataset.activeSeek = 'true';
else if (!isVideoPaused) delete watchTimeDisplay.dataset.activeSeek;
if (isFixedTime) watchTimeDisplay.dataset.isFixedTime = 'true';
logLog('updateWatchPlaybackBarMessage', `🔍 Estado: videoPaused=${isVideoPaused}, isSeek=${isSeek}, isFixed=${isFixedTime}`);
// No limpiar si es fixed (permanente)
if (isFixedTime) return;
// Si no es seek/fixed y está pausado, limpiar inmediatamente (comportamiento legacy para progreso si llegara aquí)
if (!isSeek && isVideoPaused && !isManual) {
clearPlaybackBarMessage();
return;
}
scheduleDisplayClear('watch', clearPlaybackBarMessage, 1600);
}
function clearPlaybackBarMessage() {
if (watchTimeDisplay) {
restoreDisplayButtons(watchTimeDisplay);
delete watchTimeDisplay.dataset.activeSeek;
delete watchTimeDisplay.dataset.isFixedTime;
}
const prev = displayClearTimeouts.get('watch');
if (prev) { clearTimeout(prev); displayClearTimeouts.delete('watch'); }
}
// MARK: 📢 Shorts Messages
/**
* Actualiza el mensaje para videos Shorts.
* Mantiene init reactivo porque el DOM de Shorts es altamente dinámico.
* @param {string} message - Mensaje a mostrar en Shorts
* @param {HTMLElement} videoEl - Elemento de video para verificar estado de pausa
*/
function updateShortsMessage(message, videoEl, isSeek = false, isFixedTime = false, isManual = false) {
// Asegurar inicialización y anclaje robusto (evita duplicados y re-vincula si es necesario)
initShortsTimeDisplay();
if (!shortsTimeDisplay) {
logWarn('updateShortsMessage', '⚠️ No se pudo inicializar el display de Shorts');
return;
}
if (currentPageType !== 'shorts') {
logWarn('updateShortsMessage', '⚠️ No se pudo inicializar el display de Shorts, currentPageType:', currentPageType);
return;
}
// No sobreescribir mensajes importantes (seek/fixed) con progreso si está pausado
const isVideoPaused = videoEl?.paused ?? false;
const hasActiveSeek = shortsTimeDisplay.dataset.activeSeek === 'true';
const hasFixedTime = shortsTimeDisplay.dataset.isFixedTime === 'true';
if (!isSeek && !isFixedTime && isVideoPaused && (hasActiveSeek || hasFixedTime) && !isManual) {
return;
}
// Asegurar que el observador esté activo aunque el display existiera previamente
try { startShortsPanelObserver(); } catch (_) { }
// Re-anclar al contenedor del Short activo si cambió por scroll
const activePanel = getActiveShortsControlsContainer();
const overlayRoot = document.querySelector('ytd-reel-player-overlay-renderer') || DOMHelpers.getShortsPlayer();
// Si aún no existe o no es visible (DOM en transición), reintentar en el próximo frame
if (!activePanel || !isVisiblyDisplayed(activePanel)) {
try {
const reattach = () => {
const p = getActiveShortsControlsContainer();
if (p && isVisiblyDisplayed(p)) {
try { p.appendChild(shortsTimeDisplay); } catch (_) { }
shortsTimeDisplay.classList.remove('ypp-floating');
} else if (overlayRoot) {
try { overlayRoot.appendChild(shortsTimeDisplay); } catch (_) { }
shortsTimeDisplay.classList.add('ypp-floating');
} else {
return;
}
showDisplayMessage(shortsTimeDisplay, message);
// Post-check: si aún no es visible, forzar fallback al overlayRoot
const postCheck = () => {
try {
if (!isVisiblyDisplayed(shortsTimeDisplay) && overlayRoot) {
try { overlayRoot.appendChild(shortsTimeDisplay); } catch (_) { }
shortsTimeDisplay.classList.add('ypp-floating');
showDisplayMessage(shortsTimeDisplay, message);
}
} catch (_) { }
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(postCheck);
} else {
setTimeout(postCheck, 50);
}
};
if (document.visibilityState === 'visible' && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(reattach);
} else {
setTimeout(reattach, 50);
}
} catch (_) { }
return;
}
showDisplayMessage(shortsTimeDisplay, message);
// Si está en overlayRoot (no metapanel visible), marcar flotante
try {
shortsTimeDisplay.classList.toggle('ypp-floating', shortsTimeDisplay.parentElement !== activePanel);
} catch (_) { }
// Actualizar metadatos de estado
if (isSeek) shortsTimeDisplay.dataset.activeSeek = 'true';
else if (!isVideoPaused) delete shortsTimeDisplay.dataset.activeSeek;
if (isFixedTime) shortsTimeDisplay.dataset.isFixedTime = 'true';
logLog('updateShortsMessage', `🔍 Estado: videoPaused=${isVideoPaused}, isSeek=${isSeek}, isFixed=${isFixedTime}`);
// No limpiar si es fixed (permanente)
if (isFixedTime) return;
const baseMinSeconds = cachedSettings?.minSecondsBetweenSaves || CONFIG.defaultSettings.minSecondsBetweenSaves || 1;
const ttlMs = Math.max((baseMinSeconds * 1000) + 1500, 1600);
scheduleDisplayClear('shorts', clearShortsMessage, ttlMs);
}
function clearShortsMessage() {
if (shortsTimeDisplay) {
restoreDisplayButtons(shortsTimeDisplay);
shortsTimeDisplay.classList.remove('ypp-floating');
delete shortsTimeDisplay.dataset.activeSeek;
delete shortsTimeDisplay.dataset.isFixedTime;
}
const prev = displayClearTimeouts.get('shorts');
if (prev) { clearTimeout(prev); displayClearTimeouts.delete('shorts'); }
}
// MARK: 📢 Miniplayer Messages
/**
* Inicializa la visualización de tiempo para el Miniplayer.
* Idempotente: retorna sin efecto si el display ya está conectado al DOM.
* @param {HTMLElement} playerContainer - Referencia al player miniplayer (#movie_player interno).
*/
function initMiniplayerTimeDisplay(playerContainer) {
// Si ya está conectado y tiene estructura completa, no hacer nada
if (miniplayerTimeDisplay?.isConnected && miniplayerTimeDisplay.querySelector('.ypp-time-display-message')) return;
if (!playerContainer) return;
// Si ya existe pero estructura vieja, limpiar
if (miniplayerTimeDisplay) {
try { miniplayerTimeDisplay.remove(); } catch (_) { }
miniplayerTimeDisplay = null;
}
// Búsqueda ultra-robusta de contenedores de UI en el miniplayer
// El miniplayer puede tener estructuras variables según el experimento o si es un Mix.
const controls =
playerContainer.querySelector('.ytp-time-wrapper') ||
document.querySelector('ytd-miniplayer-player-container .ytp-time-wrapper');
logLog('initMiniplayerTimeDisplay', '🔍 Contenedor encontrado:', controls);
if (!controls) {
logLog('initMiniplayerTimeDisplay', '❌ No se encontró ningún contenedor para el display del miniplayer.');
return;
}
miniplayerTimeDisplay = createElement('div', {
id: 'ypp-miniplayer-time-display',
className: 'ypp-time-display ypp-miniplayer-time-display'
});
// Crear estructura del split-button usando el helper compartido
const { listBtn, messageEl } = createSplitButtonGroup();
miniplayerTimeDisplay.appendChild(listBtn);
setupManualSaveButton(miniplayerTimeDisplay, playerContainer, 'miniplayer');
miniplayerTimeDisplay.appendChild(messageEl);
controls.appendChild(miniplayerTimeDisplay);
logLog('initMiniplayerTimeDisplay', '✅ Visualización de tiempo inicializada en Miniplayer');
clearMiniplayerMessage();
}
/**
* Actualiza el mensaje en el Miniplayer.
* El display debería ya estar inicializado por `processMiniplayerVideo`.
* @param {string} message - Mensaje a mostrar
* @param {HTMLElement} videoEl - Elemento de video
*/
function updateMiniplayerMessage(message, videoEl, isSeek = false, isFixedTime = false, isManual = false) {
// Fallback reactivo: si el display fue removido (ej: miniplayer cerrado y reabierto)
if (!miniplayerTimeDisplay?.isConnected) {
const player = DOMHelpers.getMiniplayerElementActive();
if (player) initMiniplayerTimeDisplay(player);
}
if (!miniplayerTimeDisplay) return;
// No sobreescribir mensajes importantes (seek/fixed) con progreso si está pausado
const isVideoPaused = videoEl?.paused ?? false;
const hasActiveSeek = miniplayerTimeDisplay.dataset.activeSeek === 'true';
const hasFixedTime = miniplayerTimeDisplay.dataset.isFixedTime === 'true';
if (!isSeek && !isFixedTime && isVideoPaused && (hasActiveSeek || hasFixedTime) && !isManual) {
return;
}
// Actualizar contenido y visibilidad usando helper compartido
showDisplayMessage(miniplayerTimeDisplay, message);
// Actualizar metadatos de estado
if (isSeek) miniplayerTimeDisplay.dataset.activeSeek = 'true';
else if (!isVideoPaused) delete miniplayerTimeDisplay.dataset.activeSeek;
if (isFixedTime) miniplayerTimeDisplay.dataset.isFixedTime = 'true';
// No limpiar si es fixed (permanente)
if (isFixedTime) return;
scheduleDisplayClear('mini', clearMiniplayerMessage, 1600);
}
function clearMiniplayerMessage() {
if (miniplayerTimeDisplay) {
restoreDisplayButtons(miniplayerTimeDisplay);
delete miniplayerTimeDisplay.dataset.activeSeek;
delete miniplayerTimeDisplay.dataset.isFixedTime;
}
const prev = displayClearTimeouts.get('mini');
if (prev) { clearTimeout(prev); displayClearTimeouts.delete('mini'); }
}
/**
* Destruye el display del Miniplayer: lo desconecta del DOM y nullea la referencia.
* Debe llamarse cuando el miniplayer colapsa de vuelta al player regular (Watch),
* ya que YouTube reutiliza el mismo #movie_player DOM y el span quedaría huérfano en la barra Watch.
*/
function destroyMiniplayerTimeDisplay() {
if (miniplayerTimeDisplay) {
try { miniplayerTimeDisplay.remove(); } catch (_) { }
miniplayerTimeDisplay = null;
logLog('destroyMiniplayerTimeDisplay', '🗑️ miniplayerTimeDisplay eliminado del DOM');
}
const prev = displayClearTimeouts.get('mini');
if (prev) { clearTimeout(prev); displayClearTimeouts.delete('mini'); }
}
// MARK: 📢 Inline Preview Messages
/**
* Inicializa la visualización de tiempo para Inline Previews.
* Idempotente: retorna sin efecto si el display ya está conectado al DOM.
* @param {HTMLElement} previewPlayerEl - Referencia al preview player resuelta.
*/
function initInlinePreviewTimeDisplay(previewPlayerEl) {
// Si ya está conectado y tiene estructura v2, no hacer nada
if (inlinePreviewTimeDisplay?.isConnected && inlinePreviewTimeDisplay.querySelector('.ypp-time-display-message')) return;
if (!previewPlayerEl) return;
// Si ya existe pero estructura vieja, limpiar
if (inlinePreviewTimeDisplay) {
try { inlinePreviewTimeDisplay.remove(); } catch (_) { }
inlinePreviewTimeDisplay = null;
}
const previewPlayer = previewPlayerEl;
inlinePreviewTimeDisplay = createElement('div', {
id: 'ypp-inline-preview-time-display',
className: 'ypp-time-display ypp-inline-preview-time-display'
});
// Crear estructura del split-button usando el helper compartido
const { listBtn, messageEl } = createSplitButtonGroup();
inlinePreviewTimeDisplay.appendChild(listBtn);
setupManualSaveButton(inlinePreviewTimeDisplay, previewPlayer, 'preview');
inlinePreviewTimeDisplay.appendChild(messageEl);
previewPlayer.appendChild(inlinePreviewTimeDisplay);
// Bloquear clics accidentales en el contenedor (evitar navegación al video en Previews)
inlinePreviewTimeDisplay.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
}, { passive: false });
logLog('initInlinePreviewTimeDisplay', '✅ Visualización de tiempo inicializada en Inline Preview (Split Button)');
clearInlinePreviewMessage();
}
/**
* Actualiza el mensaje en Inline Previews.
* El display debería ya estar inicializado por `processPreviewVideo`.
* @param {string} message - Mensaje a mostrar
* @param {HTMLElement} videoEl - Elemento de video
*/
function updateInlinePreviewMessage(message, videoEl, isSeek = false, isFixedTime = false, isManual = false) {
// Fallback reactivo si fue removido del DOM
if (!inlinePreviewTimeDisplay?.isConnected) {
const player = DOMHelpers.getInlinePreviewPlayer();
if (player) initInlinePreviewTimeDisplay(player);
}
if (!inlinePreviewTimeDisplay) return;
// No sobreescribir mensajes importantes (seek/fixed) con progreso si está pausado
const isVideoPaused = videoEl?.paused ?? false;
const hasActiveSeek = inlinePreviewTimeDisplay.dataset.activeSeek === 'true';
const hasFixedTime = inlinePreviewTimeDisplay.dataset.isFixedTime === 'true';
if (!isSeek && !isFixedTime && isVideoPaused && (hasActiveSeek || hasFixedTime) && !isManual) {
return;
}
// Actualizar contenido y visibilidad usando helper compartido
showDisplayMessage(inlinePreviewTimeDisplay, message);
// Actualizar metadatos de estado
if (isSeek) inlinePreviewTimeDisplay.dataset.activeSeek = 'true';
else if (!isVideoPaused) delete inlinePreviewTimeDisplay.dataset.activeSeek;
if (isFixedTime) inlinePreviewTimeDisplay.dataset.isFixedTime = 'true';
// No limpiar si es fixed (permanente)
if (isFixedTime) return;
scheduleDisplayClear('preview', clearInlinePreviewMessage, 1600);
}
function clearInlinePreviewMessage() {
if (inlinePreviewTimeDisplay) {
restoreDisplayButtons(inlinePreviewTimeDisplay);
delete inlinePreviewTimeDisplay.dataset.activeSeek;
delete inlinePreviewTimeDisplay.dataset.isFixedTime;
}
const prev = displayClearTimeouts.get('preview');
if (prev) { clearTimeout(prev); displayClearTimeouts.delete('preview'); }
}
/**
* Limpia proactivamente todos los posibles mensajes y estados de la barra de reproducción.
* Útil durante la transición entre videos para evitar "zombies" de la interfaz.
*/
function clearAllPlaybackMessages() {
clearPlaybackBarMessage();
clearShortsMessage();
clearMiniplayerMessage();
clearInlinePreviewMessage();
logLog('UI', '🧹 Limpieza total de mensajes de reproducción realizada');
}
// ------------------------------------------
// MARK: 🍞 Toasts
// ------------------------------------------
const toastTimeouts = new WeakMap();
function createToastContainer() {
let container = document.querySelector('.ypp-toast-container');
if (!container) {
container = createElement('div', { className: 'ypp-toast-container' });
document.body.appendChild(container);
logLog('createToastContainer', 'Contenedor de toasts creado');
}
return container;
}
/**
* Desvanece y elimina un toast después de un tiempo.
* @param {HTMLElement} toast - Elemento toast a eliminar.
* @param {number} duration - Tiempo en ms antes de iniciar el fade out.
*/
function fadeAndRemoveToast(toast, duration) {
// Limpiar timeout previo si existe
if (toastTimeouts.has(toast)) {
clearTimeout(toastTimeouts.get(toast));
toastTimeouts.delete(toast);
}
const timeoutId = setTimeout(() => {
// Desactivar interacción y lanzar fade
toast.style.pointerEvents = 'none';
toast.style.opacity = '0';
const container = toast.parentElement;
let cleanupTimer = null;
const onTransitionEnd = () => {
toast.removeEventListener('transitionend', onTransitionEnd);
if (cleanupTimer) {
clearTimeout(cleanupTimer);
cleanupTimer = null;
}
if (toast.isConnected) {
toast.remove();
}
// Si el contenedor queda vacío, eliminarlo
if (container && container.children.length === 0) {
container.remove();
}
};
toast.addEventListener('transitionend', onTransitionEnd);
// Fallback por si transitionend no dispara (seguridad)
cleanupTimer = setTimeout(onTransitionEnd, 600);
toastTimeouts.delete(toast);
}, duration);
toastTimeouts.set(toast, timeoutId);
}
/**
* Muestra un toast flotante.
* @param {string} message - Texto del toast.
* @param {number} [duration=2500] - Duración en ms del toast temporal.
* @param {Object} [options={}] - Opciones:
* - persistent: boolean (reutiliza un toast único)
* - keep: boolean (no se auto elimina)
* - action: { label: string, callback: function }
*/
function showFloatingToast(message, duration, options = {}) {
// Si el segundo argumento es un objeto, asumimos que son las opciones
if (typeof duration === 'object' && duration !== null) {
options = duration;
duration = undefined;
}
// Fallback robusto para saber si se envió un tiempo o se usa el default
const isDurationExplicit = duration !== undefined;
const actualDuration = isDurationExplicit ? duration : 2500;
const container = createToastContainer();
let toast;
if (options.persistent) {
toast = container.querySelector('.ypp-toast.persistent');
if (!toast) {
toast = createElement('div', { className: 'ypp-toast persistent sombra' });
container.appendChild(toast);
}
// Resetear contenido y estilo
setInnerHTML(toast, '');
toast.style.opacity = '1';
} else {
toast = createElement('div', { className: 'ypp-toast sombra' });
if (options.action) toast.classList.add('has-action');
container.appendChild(toast);
// Inicializar opacity 0 antes de animar
toast.style.opacity = '0';
requestAnimationFrame(() => (toast.style.opacity = '1'));
}
// Contenido
const messageSpan = createElement('span', { html: message });
toast.appendChild(messageSpan);
if (options.action) {
const actionBtn = createElement('button', {
className: 'ypp-toast-action',
text: options.action.label,
onClickEvent: () => {
if (typeof options.action.callback === 'function') {
options.action.callback();
}
fadeAndRemoveToast(toast, 0);
},
atribute: { 'aria-label': options.action.label, type: 'button' }
});
toast.appendChild(actionBtn);
}
// Agregar botón de cerrar para toasts persistentes o de tipo keep
/* if (options.persistent || options.keep) { */
const closeBtn = createElement('button', {
className: 'ypp-toast-close',
html: SVG_ICONS.close,
atribute: { 'aria-label': t('close'), title: t('close'), type: 'button' },
onClickEvent: () => {
fadeAndRemoveToast(toast, 0);
}
});
toast.appendChild(closeBtn);
/* } */
// Si no es keep, desvanecer por defecto si no es persistente,
// o si es persistente pero se le especificó una duración explícita mayor a 0.
if (!options.keep) {
if (!options.persistent || (options.persistent && isDurationExplicit && actualDuration > 0)) {
// Agregar barra indicadora de tiempo restante
const progress = createElement('div', { className: 'ypp-toast-progress' });
toast.appendChild(progress);
// Forzar el repintado del navegador antes de animar (imprescindible para que CSS procese start-state)
void progress.offsetWidth;
progress.style.transition = `transform ${actualDuration}ms linear`;
progress.style.transform = 'scaleX(0)';
fadeAndRemoveToast(toast, actualDuration);
}
}
logLog('showFloatingToast', 'Toast mostrado', { message, options });
}
// ------------------------------------------
// MARK: ⚙️ Settings UI Rendering Helpers
// ------------------------------------------
const renderLanguageSection = (currentLang) => {
const sortedLanguages = Object.entries(LANGUAGE_FLAGS).sort((a, b) => a[1].name.localeCompare(b[1].name));
const optionsHTML = sortedLanguages.map(([code, lang]) => `
<option value="${code}" ${currentLang === code ? 'selected' : ''}>
${escapeHTML(lang.emoji || '🌐')} ${escapeHTML(lang.name)}
</option>
`).join('');
return `
<div class="ypp-settings-section">
<label class="ypp-label ypp-label-language">
<span>${t('language')}: </span>
<select class="ypp-select" name="language">${optionsHTML}</select>
</label>
</div>
`;
};
const renderGeneralSettingSection = (settings) => `
<div class="ypp-settings-section">
<label class="ypp-label">
<input type="checkbox" name="showFloatingButtons" ${settings.showFloatingButtons ? 'checked' : ''}>
<span>${t('showFloatingButton')}</span>
</label>
<label class="ypp-label">
<input type="checkbox" name="enableProgressBarGradient" ${settings.enableProgressBarGradient ? 'checked' : ''}>
<span>${t('enableProgressBarGradient')}</span>
</label>
</div>
`;
const renderManualSavingOptionsSection = (settings) => `
<div class="ypp-manual-saving-options">
<div class="ypp-settings-second-level-section">
<label class="ypp-label" title="${t('manualSaveModeTooltip')}">
<input type="checkbox" name="manualSaveMode" ${settings.manualSaveMode ? 'checked' : ''}>
<span>${SVG_ICONS.bookmarkOutline}${t('manualSaveMode')}</span>
</label>
</div>
</div>
`;
const renderAutomaticSavingOptionsSection = (settings) => `
<div class="ypp-automatic-saving-options">
<div class="ypp-settings-second-level-section">
<h3 class="ypp-section-title">${t('enableAutomaticSavingFor')}:</h2>
<label class="ypp-label-save-type">
<input type="checkbox" name="saveRegularVideos" ${settings.saveRegularVideos ? 'checked' : ''}>
<span>${t('regularVideos')}</span>
</label>
<label class="ypp-label-save-type">
<input type="checkbox" name="saveMiniplayerVideos" ${settings.saveMiniplayerVideos !== false ? 'checked' : ''}>
<span>${t('miniplayerVideos')}</span>
</label>
<label class="ypp-label-save-type">
<input type="checkbox" name="saveShorts" ${settings.saveShorts ? 'checked' : ''}>
<span>${t('shorts')}</span>
</label>
<label class="ypp-label-save-type">
<input type="checkbox" name="saveLiveStreams" ${settings.saveLiveStreams ? 'checked' : ''}>
<span>${t('liveStreams')}</span>
</label>
<label class="ypp-label-save-type">
<input type="checkbox" name="saveInlinePreviews" ${settings.saveInlinePreviews === true ? 'checked' : ''}>
<span>${t('inlinePreviews')}</span>
</label>
<label class="ypp-label">
<span>${t('minSecondsBetweenSaves')}: </span>
<input type="number" class="ypp-input-small" name="minSecondsBetweenSaves" value="${settings.minSecondsBetweenSaves}" min="1" max="9999">
</label>
</div>
</div>
`;
const renderNotificationSettingsSection = (settings) => {
const styles = [
{ value: 'iconText', text: `🔔📝 ${t('alertIconText')}` },
{ value: 'iconOnly', text: `🔔 ${t('alertIconOnly')}` },
{ value: 'textOnly', text: `📝 ${t('alertTextOnly')}` },
{ value: 'hidden', text: `🚫 ${t('alertHidden')}` }
];
const optionsHTML = styles.map(s => `
<option value="${s.value}" ${settings.alertStyle === s.value ? 'selected' : ''}>${s.text}</option>
`).join('');
return `
<label class="ypp-label">
<span>${t('alertStyle')}: </span>
<select class="ypp-select" name="alertStyle">${optionsHTML}</select>
</label>
<div class="ypp-settings-second-level-section">
<label class="ypp-label">
<span>${t('staticFinishPercent')}: </span>
<input type="number" class="ypp-input-small" name="staticFinishPercent" value="${settings.staticFinishPercent}" min="1" max="99">
<span class="ypp-percent-symbol">%</span>
</label>
<div class="ypp-settings-third-level-section">
<div class="ypp-d-flex">
<input type="checkbox" name="countOncePerSession" ${settings.countOncePerSession ? 'checked' : ''}>
<span>${t('countOncePerSession')}</span>
</div>
<i class="ypp-tooltip">${SVG_ICONS.info} ${t('countOncePerSessionTooltip')}</i>
</div>
</div>
`;
}
const renderGitHubBackupSection = (rawSettings) => {
const githubSettings = rawSettings;
const lastViewedType = githubSettings.lastViewedType || 'gist';
const renderTabContent = (type) => {
const s = githubSettings[type] || CONFIG.defaultGithubSettings[type] || {};
const lastSyncStr = s.lastSync ? new Date(s.lastSync).toLocaleString() : t('unknown');
const isGist = type === 'gist';
return `
<div id="ypp-github-${type}-content" class="ypp-github-tab-content" style="display: ${lastViewedType === type ? 'flex' : 'none'};">
<div>
<label class="ypp-label">
<input type="checkbox" name="${type}_autoBackup" ${s.autoBackup ? 'checked' : ''}>
<span style="font-weight: bold;">${t('githubAutoBackup')}</span>
</label>
<label class="ypp-label" style="margin-top: 5px;">
<span>${t('githubInterval')}: </span>
<input type="number" class="ypp-input-small" name="${type}_interval" value="${s.interval || 24}" min="1" max="24" oninput="if(this.value > 24) this.value = 24;">
</label>
</div>
<div class="ypp-settings-third-level-section">
<label class="ypp-label ypp-m0">
<span>${t('githubToken')}: </span>
<input type="password" class="ypp-input" name="${type}_token" value="${s.token || ''}" placeholder="ghp_xxxxxxxxxxxx">
</label>
${isGist ? `
<div>
<label class="ypp-label ypp-m0">
<span>${t('githubGistId')}: </span>
<div style="display: flex; align-items: center; gap: 6px; flex: 1;">
<input type="password" class="ypp-input" name="gist_id" value="${s.id || ''}" placeholder="${t('githubGistIdPlaceholder')}" style="flex: 1;">
<button type="button" class="ypp-btn ypp-btn-small ypp-btn-outlined ypp-gist-id-toggle" title="${t('show')}" style="padding: 4px 8px; flex-shrink: 0;">${SVG_ICONS.eye}</button>
</div>
</label>
<div style="font-size: 0.85em; color: var(--ypp-text-secondary); margin-top: 2px;">${t('githubGistIdExample')}</div>
</div>
` : `
<div>
<label class="ypp-label">
<span>${t('githubRepoOwner')}: </span>
<input type="text" class="ypp-input" name="repo_owner" value="${s.owner || ''}" placeholder="${t('githubRepoOwnerPlaceholder')}">
</label>
<label class="ypp-label">
<span>${t('githubRepoName')}: </span>
<input type="text" class="ypp-input" name="repo_name" value="${s.name || ''}" placeholder="${t('githubRepoNamePlaceholder')}">
</label>
</div>
<div style="font-size: 0.85em; color: var(--ypp-text-secondary); margin-top: 5px;">
${SVG_ICONS.info} ${t('saveAs')}: <code>youtube-playback-plox-backup.json</code>
</div>
`}
</div>
<div style="display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--ypp-border-color); padding-top: 12px;">
<div style="font-size: 0.85em; color: var(--ypp-text-secondary); line-height: 1.4;">
${isGist ? t('githubBackupNowInfo') : t('githubRepoBackupNowInfo')}
</div>
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;">
<button type="button" class="ypp-btn ypp-btn-primary ypp-github-backup-btn" data-type="${type}" style="width: fit-content; padding: 6px 15px;">
${SVG_ICONS.upload} ${t('githubBackupNow')}
</button>
<div id="ypp-github-last-sync-${type}" style="font-size: 0.85em; color: var(--ypp-text-secondary);">
<strong>${t('githubLastSync')}:</strong> ${lastSyncStr}
</div>
</div>
<div id="ypp-github-gist-link-container-${type}">
${isGist && s.url ? `
<a href="${s.url}" class="ypp-link" target="_blank" rel="noopener noreferrer">
${t('githubGistView')} ${SVG_ICONS.externalLink}
</a>
` : ''}
</div>
</div>
</div>
`;
};
return `
<div class="ypp-github-settings-header">
<h2 class="ypp-section-title">
<span>${SVG_ICONS.github} ${t('githubBackup')}</span>
</h2>
<div class="ypp-github-help-toggle" id="ypp-github-help-toggle">
${SVG_ICONS.info} ${t('githubHelp')}
</div>
<div class="ypp-github-help-content" id="ypp-github-help-content">
<div style="margin-bottom: 10px; font-weight: bold;">${t('githubHelp')}</div>
<div id="ypp-github-help-gist" style="display: ${lastViewedType === 'gist' ? 'block' : 'none'};">
<div>${t('githubHelpStep1')} <a href="https://github.com/settings/tokens" class="ypp-link" target="_blank" rel="noopener noreferrer">https://github.com/settings/tokens</a></div>
<div>${t('githubHelpStep2Gist')}</div>
<div>${t('githubHelpStep3')}</div>
</div>
<div id="ypp-github-help-repo" style="display: ${lastViewedType === 'repo' ? 'block' : 'none'};">
<div>${t('githubHelpStep1')} <a href="https://github.com/settings/tokens" class="ypp-link" target="_blank" rel="noopener noreferrer">https://github.com/settings/tokens</a></div>
<div>${t('githubHelpStep2Repo')}</div>
<div>${t('githubHelpStep3')}</div>
<div>${t('githubHelpStep4Repo')}</div>
</div>
<div style="margin-top: 10px; padding-top: 5px; border-top: 1px solid var(--ypp-border-color); font-size: 0.9em;">
<strong style="color: var(--ypp-primary);">${t('githubCleanupGuide')}:</strong><br>
- ${t('githubCleanupStep1')}<br>
- ${t('githubCleanupStep2')}
</div>
</div>
</div>
<div id="ypp-github-repo-warning" class="ypp-github-help-important">
${SVG_ICONS.warning} ${t('githubHelpImportant')}
<label class="ypp-label" style="padding: 10px 0 5px;">
<input type="checkbox" name="githubAutoDeleteToken" ${githubSettings.autoDeleteToken ? 'checked' : ''}>
<span style="font-size: 0.9em; color: var(--ypp-text-secondary);text-wrap: auto">${t('githubAutoDeleteToken')}</span>
</label>
</div>
<div class="ypp-github-tabs">
<div class="ypp-github-tab ${lastViewedType === 'gist' ? 'active' : ''}" data-type="gist">
${SVG_ICONS.bookmarkOutline} Gist
</div>
<div class="ypp-github-tab ${lastViewedType === 'repo' ? 'active' : ''}" data-type="repo">
${SVG_ICONS.folder} Repository
</div>
</div>
<div class="ypp-settings-section ypp-github-section" style="border-top: none; border-radius: 0 0 8px 8px; margin-top: -15px;">
<input type="hidden" name="githubLastViewedType" value="${lastViewedType}">
${renderTabContent('gist')}
${renderTabContent('repo')}
</div>
`;
};
// ------------------------------------------
// MARK: ⚙️ Settings UI
// ------------------------------------------
async function showSettingsUI() {
// Ocultar modal de videos si existe, pero no eliminarlo
let wasVideosModalOpen = false;
if (videosOverlay && videosContainer) {
videosOverlay.style.display = 'none';
videosContainer.style.display = 'none';
wasVideosModalOpen = true;
}
// Cerrar otros modales que no sean el de videos
const existingModals = DOMHelpers.get('ui:allModals', () => document.querySelectorAll('.ypp-modalOverlay'), 50);
existingModals.forEach(modal => {
if (modal !== videosOverlay) modal.remove();
});
const closeModal = () => {
overlay.remove();
document.body.style.overflow = '';
// Restaurar modal de videos si estaba abierto
if (wasVideosModalOpen && videosOverlay && videosContainer) {
videosOverlay.style.display = '';
videosContainer.style.display = '';
}
};
const settings = { ...await Settings.get() };
const githubSettings = { ...await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings) };
// Crear overlay
const overlay = createElement('div', {
className: 'ypp-modalOverlay',
atribute: { 'aria-modal': 'true', role: 'dialog' },
onClickEvent: (e) => { if (e.target === overlay) closeModal(); }
});
const modal = createElement('div', { className: 'ypp-modalBox ypp-shadow-md' });
// Header
const header = createElement('div', { className: 'ypp-modalHeader' });
setInnerHTML(header, `
<h1 class="ypp-modalTitle">️${SVG_ICONS.settings} ${t('settings')} <span class="ypp-modalTitle-version">v${SCRIPT_VERSION}</span></h1>
<button class="ypp-btn ypp-btn-small ypp-btn-close" aria-label="${t('close')}" title="${t('close')}" type="button">
${SVG_ICONS.close}
</button>
`);
header.querySelector('.ypp-btn-close').addEventListener('click', closeModal);
// Body
const settingsHTML = `
<div class="ypp-settingsContent">
${renderLanguageSection(settings.language)}
${renderGeneralSettingSection(settings)}
<div class="ypp-settings-main-section">
${renderManualSavingOptionsSection(settings)}
${renderAutomaticSavingOptionsSection(settings)}
${renderNotificationSettingsSection(settings)}
</div>
<div class="ypp-settings-main-section">
${renderGitHubBackupSection(githubSettings)}
</div>
<div class="ypp-support-options">
<h3 style="margin:10px 0; display:flex; align-items:center; gap:8px; font-size:1.4rem; color: var(--ypp-text);">
${SVG_ICONS.warning} ${t('supportLogsTitle')}
</h3>
<textarea readonly class="ypp-log-textarea ypp-shadow-sm" spellcheck="false" placeholder="${t('noLogs')}">${(window.MyScriptLogger._errorLogs && window.MyScriptLogger._errorLogs.length > 0)
? window.MyScriptLogger._errorLogs.join('\n')
: ''
}</textarea>
<button class="ypp-btn ypp-btn-secondary" type="button" style="margin-top: 10px;" id="ypp-copy-logs-btn">
${SVG_ICONS.save} ${t('copyLogsBtn')}
</button>
<button class="ypp-btn ypp-btn-outlined ypp-create-issue-btn" type="button" style="margin-top: 10px; margin-left: 10px;">
${SVG_ICONS.issueDraft} ${t('reportIssue')} ${SVG_ICONS.externalLink}
</button>
</div>
</div>
`;
const body = createElement('div', {
className: 'ypp-modalBody',
html: settingsHTML
});
// Footer
const footer = createElement('footer', { className: 'ypp-settings-footer' });
const repositoryBtn = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
// html: `${SVG_ICONS.github} ${t('youtubePlaybackPlox')} ${SVG_ICONS.externalLink}`,
html: `${SVG_ICONS.github} ${SVG_ICONS.externalLink}`,
onClickEvent: () => { window.open('https://github.com/Alplox/Youtube-Playback-Plox/', '_blank'); }
});
const viewBtn = createElement('button', {
className: 'ypp-btn ypp-btn-outlined ypp-btn-view-saved-videos ypp-shadow-md',
html: `${SVG_ICONS.clockRotateLeft} ${t('savedVideos')}`,
onClickEvent: async () => { overlay.remove(); await showSavedVideosList(); }
});
const saveBtn = createElement('button', {
className: 'ypp-btn ypp-save-button ypp-shadow-md',
html: `${SVG_ICONS.save} ${t('save')}`,
onClickEvent: async () => {
const getVal = (name) => modal.querySelector(`[name="${name}"]`)?.value;
const isChecked = (name) => modal.querySelector(`[name="${name}"]`)?.checked;
const newSettings = {
minSecondsBetweenSaves: Math.max(1, parseInt(getVal('minSecondsBetweenSaves'), 10) || 1),
showFloatingButtons: isChecked('showFloatingButtons'),
enableProgressBarGradient: isChecked('enableProgressBarGradient'),
staticFinishPercent: Math.max(1, Math.min(99, parseInt(getVal('staticFinishPercent'), 10) || 90)),
saveRegularVideos: isChecked('saveRegularVideos'),
saveShorts: isChecked('saveShorts'),
saveLiveStreams: isChecked('saveLiveStreams'),
saveMiniplayerVideos: isChecked('saveMiniplayerVideos'),
saveInlinePreviews: isChecked('saveInlinePreviews'),
manualSaveMode: isChecked('manualSaveMode'),
countOncePerSession: isChecked('countOncePerSession'),
language: getVal('language'),
alertStyle: getVal('alertStyle'),
};
const newGithubSettings = {
gist: {
token: getVal('gist_token'),
id: getVal('gist_id'),
url: githubSettings.gist?.url || '',
autoBackup: isChecked('gist_autoBackup'),
interval: Math.max(1, parseInt(getVal('gist_interval'), 10) || 24),
lastSync: githubSettings.gist?.lastSync || 0
},
repo: {
token: getVal('repo_token'),
owner: getVal('repo_owner'),
name: getVal('repo_name'),
autoBackup: isChecked('repo_autoBackup'),
interval: Math.max(1, parseInt(getVal('repo_interval'), 10) || 24),
lastSync: githubSettings.repo?.lastSync || 0
},
autoDeleteToken: isChecked('githubAutoDeleteToken'),
lastViewedType: getVal('githubLastViewedType') || 'gist'
};
await Promise.all([
Settings.set(newSettings),
GM_setValue(CONFIG.STORAGE_KEYS.github, newGithubSettings)
]);
cachedSettings = newSettings;
await setLanguage(newSettings.language);
showFloatingToast(`${SVG_ICONS.check} ${t('configurationSaved')}`);
location.reload();
}
});
// Event listeners para Backup Manual (Delegación)
body.querySelectorAll('.ypp-github-backup-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const type = e.currentTarget.getAttribute('data-type');
const getVal = (name) => modal.querySelector(`[name="${name}"]`)?.value;
const isChecked = (name) => modal.querySelector(`[name="${name}"]`)?.checked;
// Preparamos los ajustes actuales de esta pestaña para el respaldo manual
const currentModeSettings = {
token: getVal(`${type}_token`),
autoBackup: isChecked(`${type}_autoBackup`),
interval: Math.max(1, parseInt(getVal(`${type}_interval`), 10) || 24),
autoDeleteToken: isChecked('githubAutoDeleteToken')
};
if (type === 'gist') {
currentModeSettings.id = getVal('gist_id');
} else {
currentModeSettings.repoOwner = getVal('repo_owner');
currentModeSettings.repoName = getVal('repo_name');
}
// Pasamos los ajustes actuales a performRemoteBackup
await performRemoteBackup(type, true, currentModeSettings);
});
});
// Lógica Copy Logs
const copyLogsBtn = body.querySelector('#ypp-copy-logs-btn');
if (copyLogsBtn) {
copyLogsBtn.addEventListener('click', async () => {
const storageInfo = typeof StorageAsync !== 'undefined' ? StorageAsync.getBackendInfo() : { error: 'StorageAsync no disponible' };
const logData = [
`--- YouTube Playback Plox Logs ---`,
`Script Version: ${SCRIPT_VERSION}`,
`User Agent: ${navigator.userAgent}`,
`Storage Backend: ${storageInfo.indexedDBSupported ? 'IndexedDB' : 'Fallback'} (Cache: ${storageInfo.cacheSize || 0})`,
`Date: ${new Date().toISOString()}`,
`Current URL: ${window.location.href}`,
`----------------------------------`,
(window.MyScriptLogger._errorLogs || []).join('\n') || t('noLogs')
].join('\n');
try {
await navigator.clipboard.writeText(logData);
showFloatingToast(`${SVG_ICONS.check} ${t('logsCopied')}`);
} catch (e) {
showFloatingToast(`${SVG_ICONS.error} Error: ${e.message}`);
}
});
}
// boton para abrir enlace para crear issue a repositorio
const createIssueBtn = body.querySelector('.ypp-create-issue-btn');
if (createIssueBtn) {
createIssueBtn.addEventListener('click', () => {
window.open('https://github.com/Alplox/Youtube-Playback-Plox/issues/new', '_blank');
});
}
// Lógica de Tabs
body.querySelectorAll('.ypp-github-tab').forEach(tab => {
tab.addEventListener('click', () => {
const type = tab.getAttribute('data-type');
// Actualizar clases de tabs
body.querySelectorAll('.ypp-github-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Actualizar visibilidad de contenidos
body.querySelectorAll('.ypp-github-tab-content').forEach(c => c.style.display = 'none');
const activeContent = body.querySelector(`#ypp-github-${type}-content`);
if (activeContent) activeContent.style.display = 'flex';
// Actualizar campo oculto de último tipo visualizado
const lastViewedInput = body.querySelector('input[name="githubLastViewedType"]');
if (lastViewedInput) lastViewedInput.value = type;
// Actualizar visibilidad de ayuda y advertencias
const helpGist = body.querySelector('#ypp-github-help-gist');
const helpRepo = body.querySelector('#ypp-github-help-repo');
if (helpGist) helpGist.style.display = type === 'gist' ? 'block' : 'none';
if (helpRepo) helpRepo.style.display = type === 'repo' ? 'block' : 'none';
});
});
// Event listener para el toggle de ayuda de GitHub
body.querySelector('#ypp-github-help-toggle')?.addEventListener('click', (e) => {
e.currentTarget.classList.toggle('active');
});
footer.appendChild(repositoryBtn);
footer.appendChild(viewBtn);
footer.appendChild(saveBtn);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.appendChild(modal);
// Toggle para mostrar/ocultar gist_id
body.querySelectorAll('.ypp-gist-id-toggle').forEach(btn => {
btn.addEventListener('click', () => {
const input = btn.closest('div')?.querySelector('input[name="gist_id"]');
if (!input) return;
const isHidden = input.type === 'password';
input.type = isHidden ? 'text' : 'password';
});
});
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
}
// ------------------------------------------
// MARK: 📢 Notify Seek or Progress
// ------------------------------------------
const alertStyles = {
iconOnly: (icon, text, timeStr) => `${icon} ${timeStr}`,
textOnly: (icon, text) => text,
iconText: (icon, text) => `${icon} ${text}`
};
const handlers = {
watch: updateWatchPlaybackBarMessage,
embed: updateWatchPlaybackBarMessage,
live: updateWatchPlaybackBarMessage,
shorts: updateShortsMessage,
miniplayer: updateMiniplayerMessage,
preview: updateInlinePreviewMessage
};
async function notifySeekOrProgress(time, context = 'progress', options = {}) {
if (cachedSettings.alertStyle === 'hidden') return;
const { videoType, isForced = false, videoEl, saveResult = {} } = options;
const isSeek = context === 'seek';
if (context === 'progress') {
if (!saveResult.success && !saveResult.isManual) return;
// Permitir miniplayer incluso en Shorts
if (currentPageType === 'shorts' && videoType !== 'shorts' && videoType !== 'miniplayer') return;
// Seleccionar display correcto para validación de pausa
let display = watchTimeDisplay;
if (videoType === 'shorts') display = shortsTimeDisplay;
else if (videoType === 'miniplayer') display = miniplayerTimeDisplay;
else if (videoType === 'preview') display = inlinePreviewTimeDisplay;
if (videoEl?.paused && display?.querySelector('.svgPlayOrPauseIcon') && !saveResult.isManual) return;
}
const timeStr = formatTime(normalizeSeconds(time));
const icon = isSeek
? (isForced ? `${SVG_ICONS.timer}${SVG_ICONS.pin}` : SVG_ICONS.playOrPause)
: SVG_ICONS.saveWithCheckCircular;
const text = isSeek
? `${t(isForced ? 'alwaysStartFrom' : 'resumedAt')}: ${timeStr}`
: (saveResult.success ? `${t('progressSaved')}: ${timeStr}` : t('errorSaving'));
const message =
(alertStyles[cachedSettings.alertStyle] || alertStyles[CONFIG.defaultSettings.alertStyle])(
icon,
text,
timeStr
);
const isFixedTime = !!isForced;
handlers[videoType]?.(message, videoEl, isSeek, isFixedTime, saveResult.isManual);
}
// ------------------------------------------
// MARK: 🎵 Selección de Videos
// ------------------------------------------
let selectedVideos = new Set(); // IDs de videos seleccionados
let isPlaylistCreationMode = false; // Modo de selección activo
/** @type {boolean} Modo de gestión de videos (borrado masivo) */
let isManagementMode = false;
let createPlaylistBtn = null; // Botón de crear playlist
let playlistInfoEl = null; // parafo donde se meustra numero de item seleccionados
let playlistTextareaEl = null; // textarea donde se muestra enlace de playlist creado
let modalVideosFooterFirtsRow = null; // Botones de exportacion en modal videos
let modalVideosFooterSecondRow = null; // Botones eliminar todo, crear playlist y configuraciones
let playlistContainer = null;
/**
* Activa/desactiva el modo de gestión de videos (borrado masivo)
*/
async function toggleManagementMode() {
isManagementMode = !isManagementMode;
if (isManagementMode) isPlaylistCreationMode = false;
selectedVideos.clear();
await updateVideoList();
updateFooterButtons();
}
/**
* Actualiza los botones del footer dinámicamente según el modo activo (Gestión o Normal)
* Solo modifica los botones propios de cada modo; no toca el flujo de playlist.
*/
function updateFooterButtons() {
const modalFooter = document.querySelector('.ypp-footer');
if (!modalFooter) return;
if (isManagementMode) {
// ocultar botones normales del footer
modalVideosFooterFirtsRow?.classList.add('ypp-d-none');
modalVideosFooterSecondRow?.classList.add('ypp-d-none');
// Remueve contenedor de botones playlist creation mode
modalFooter.querySelector('#ypp-playlist-creation-footer-container')?.remove();
const managementModeFragment = document.createDocumentFragment();
// crear contenedor para botones management mode
const managementModeContainer = createElement('div', {
className: 'ypp-management-footer-container',
id: 'ypp-management-footer-container'
});
// Añadir botones de gestión masiva
const items = virtualScroller && virtualScroller.items ? virtualScroller.items.filter(i => i.info) : [];
const allSelected = items.length > 0 && items.every(v => selectedVideos.has(v.info.videoId));
const selectionInfo = createElement('span', {
id: 'ypp-management-selection-info',
className: 'ypp-management-footer-item',
html: `<strong>${t('selectedVideos')}:</strong> ${selectedVideos.size}`
});
const btnGroup = createElement('div', {
className: 'ypp-management-footer-item-group',
id: 'ypp-management-footer-item-group'
});
const btnSelectAll = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md ypp-management-footer-item',
id: 'ypp-select-all-btn',
html: allSelected ? `${SVG_ICONS.close} ${t('deselectAllResults')}` : `${SVG_ICONS.check} ${t('selectAllResults')}`,
onClickEvent: async () => {
const currentItems = virtualScroller && virtualScroller.items ? virtualScroller.items.filter(i => i.info) : [];
if (currentItems.length === 0) return;
const currentAllSelected = currentItems.every(v => selectedVideos.has(v.info.videoId));
if (currentAllSelected) {
for (const v of currentItems) selectedVideos.delete(v.info.videoId);
} else {
for (const v of currentItems) selectedVideos.add(v.info.videoId);
}
// Actualizar checkboxes en el DOM sin recrear el VirtualScroller
document.querySelectorAll('.ypp-video-checkbox').forEach(checkbox => {
const vid = checkbox.getAttribute('data-video-id');
if (vid) checkbox.checked = selectedVideos.has(vid);
});
updateManagementFooterState();
}
});
const btnExportSelected = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md ypp-management-footer-item',
html: `${SVG_ICONS.upload} ${t('exportSelected')} (JSON)`,
onClickEvent: async () => {
if (selectedVideos.size === 0) {
alert(t('selectAtLeastOne'));
return;
}
await exportDataToFile(Array.from(selectedVideos));
}
});
const btnExportSelectedFreeTube = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md ypp-management-footer-item',
html: `${SVG_ICONS.upload} ${t('exportSelected')} (FreeTube)`,
onClickEvent: async () => {
if (selectedVideos.size === 0) {
alert(t('selectAtLeastOne'));
return;
}
await exportToFreeTube(Array.from(selectedVideos));
}
});
const btnDeleteSelected = createElement('button', {
className: 'ypp-btn ypp-btn-danger ypp-shadow-md ypp-management-footer-item',
html: `${SVG_ICONS.trash} ${t('deleteSelected')}`,
onClickEvent: async () => {
if (selectedVideos.size === 0) {
alert(t('selectAtLeastOne'));
return;
}
if (!confirm(t('confirmDeleteSelected').replace('{count}', selectedVideos.size))) return;
const idsToDelete = Array.from(selectedVideos);
const allKeys = await Storage.keys();
// Caché para Undo
const rollbackData = [];
let skippedProtected = 0;
for (const id of idsToDelete) {
const itemData = await Storage.get(id);
if (!itemData) continue;
if (itemData.isProtected) {
skippedProtected++;
continue;
}
rollbackData.push({ type: 'video', id, data: itemData });
await Storage.del(id);
syncFixedTimeUI(id, false);
syncManualSaveUI(id, false);
}
selectedVideos.clear();
await updateVideoList();
const deletedCount = rollbackData.length;
if (deletedCount > 0) {
// Toast con opción Deshacer visible por 10 segundos
showFloatingToast(`🚮 ${deletedCount} ${t('itemDeleted')}${skippedProtected > 0 ? ` (${t('protectedItemsSkipped', { count: skippedProtected })})` : ''}`, 10000, {
action: {
label: t('undo'),
callback: async () => {
for (const item of rollbackData) {
if (item.type === 'video') {
await Storage.set(item.id, item.data);
}
}
await updateVideoList();
showFloatingToast(`${SVG_ICONS.check} ${t('itemsRestored').replace('{count}', deletedCount)}`, 3000);
}
}
});
} else if (skippedProtected > 0) {
showFloatingToast(`${SVG_ICONS.warning} ${t('protectedItemsSkipped', { count: skippedProtected })}`);
}
}
});
const btnClearAll = createElement('button', {
className: 'ypp-btn ypp-btn-danger ypp-shadow-md ypp-management-footer-item',
html: `${SVG_ICONS.trash} ${t('clearAll')}`,
onClickEvent: async () => { await clearAllData(); }
});
const cancelBtn = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md ypp-management-footer-item',
html: `${SVG_ICONS.close} ${t('cancel')}`,
onClickEvent: async () => {
toggleManagementMode();
}
});
managementModeContainer.append(selectionInfo);
btnGroup.append(btnSelectAll);
btnGroup.append(btnExportSelected);
btnGroup.append(btnExportSelectedFreeTube);
btnGroup.append(btnDeleteSelected);
btnGroup.append(btnClearAll);
btnGroup.append(cancelBtn);
managementModeContainer.append(btnGroup);
managementModeFragment.append(managementModeContainer);
modalFooter.append(managementModeFragment);
} else if (isPlaylistCreationMode) {
modalVideosFooterFirtsRow?.classList.add('ypp-d-none');
modalVideosFooterSecondRow?.classList.add('ypp-d-none');
// Remueve contenedor de botones management mode
modalFooter.querySelector('#ypp-management-footer-container')?.remove();
// crear contenedor para botones playlist creation mode
const playlistCreationModeContainer = createElement('div', {
className: 'ypp-playlist-creation-footer-container',
id: 'ypp-playlist-creation-footer-container'
});
/* btnToggleManagement?.classList.add('ypp-d-none');
createPlaylistBtn?.classList.add('ypp-d-none');
btnSettings?.classList.add('ypp-d-none'); */
updatePlaylistArea();
} else {
modalVideosFooterFirtsRow?.classList.remove('ypp-d-none');
modalVideosFooterSecondRow?.classList.remove('ypp-d-none');
// Remueve contenedor de botones management mode
modalFooter.querySelector('#ypp-management-footer-container')?.remove();
// Remueve contenedor de botones playlist creation mode
modalFooter.querySelector('#ypp-playlist-creation-footer-container')?.remove();
updatePlaylistArea();
}
}
/**
* Actualiza el estado visual de los botones del footer de gestión dinámicamente sin destruir el DOM
*/
function updateManagementFooterState() {
if (!isManagementMode) return;
const btnSelectAll = document.getElementById('ypp-select-all-btn');
if (btnSelectAll) {
const items = virtualScroller && virtualScroller.items ? virtualScroller.items.filter(i => i.info) : [];
const allSelected = items.length > 0 && items.every(v => selectedVideos.has(v.info.videoId));
const icon = allSelected ? SVG_ICONS.close : SVG_ICONS.check;
const text = allSelected ? t('deselectAllResults') : t('selectAllResults');
setInnerHTML(btnSelectAll, `${icon} ${text}`);
}
// Elemento DOM generado manualmente o existente
const selectionInfo = document.getElementById('ypp-management-selection-info');
if (selectionInfo) {
setInnerHTML(selectionInfo, `<strong>${t('selectedVideos')}:</strong> ${selectedVideos.size}`);
} else {
// Re-render in case it's not defined (User had changed structure recently)
const secondRowItem = document.querySelector('.ypp-management-footer-item strong');
if (secondRowItem && secondRowItem.parentElement) {
secondRowItem.parentElement.id = 'ypp-management-selection-info';
setInnerHTML(secondRowItem.parentElement, `<strong>${t('selectedVideos')}:</strong> ${selectedVideos.size}`);
}
}
}
/**
* Activa/desactiva el modo de selección de videos
*/
async function togglePlaylistCreationMode() {
isPlaylistCreationMode = !isPlaylistCreationMode;
if (isPlaylistCreationMode) isManagementMode = false;
selectedVideos.clear();
// Fallback: buscar el botón solo si no está disponible la referencia
createPlaylistBtn ??= document.querySelector('#ypp-create-playlist-btn');
if (!createPlaylistBtn) return;
// Actualizar la interfaz
await updateVideoList();
if (isPlaylistCreationMode) {
setInnerHTML(createPlaylistBtn, `${SVG_ICONS.close} ${t('selectVideos')} (${selectedVideos.size})`);
createPlaylistBtn.className = 'ypp-btn ypp-btn-danger ypp-shadow-md';
} else {
setInnerHTML(createPlaylistBtn, `${SVG_ICONS.playlist} ${t('createPlaylist')}`);
createPlaylistBtn.className = 'ypp-btn ypp-btn-primary ypp-shadow-md';
}
// Mostrar/ocultar área de playlist y botones del footer
updatePlaylistArea();
logLog('togglePlaylistCreationMode', `Modo de selección: ${isPlaylistCreationMode ? 'ACTIVADO' : 'DESACTIVADO'}`);
}
/**
* Actualiza el área de playlist integrada
*/
function updatePlaylistArea() {
playlistContainer ??= document.querySelector('#ypp-playlist-area');
modalVideosFooterFirtsRow ??= document.querySelector('.ypp-footer-row:first-child');
modalVideosFooterSecondRow ??= document.querySelector('.ypp-footer-row:last-child');
if (!modalVideosFooterFirtsRow || !modalVideosFooterSecondRow || !playlistContainer) return;
// Mostrar área de playlist y ocultar botones normales de footer modal videos
playlistContainer.classList.toggle('active', isPlaylistCreationMode);
modalVideosFooterFirtsRow?.classList.toggle('ypp-d-none', isPlaylistCreationMode);
modalVideosFooterSecondRow?.classList.toggle('ypp-d-none', isPlaylistCreationMode);
if (isPlaylistCreationMode) {
// Actualizar el área de playlist si hay videos seleccionados
if (selectedVideos.size > 0) {
updatePlaylistContent();
}
} else {
// Limpiar el área de playlist
clearPlaylistContent();
}
}
/**
* Actualiza el contenido del área de playlist
*/
function updatePlaylistContent() {
// Operador ??= solo consulta el DOM si la variable es null o undefined
playlistInfoEl ??= document.querySelector('#ypp-playlist-info');
playlistTextareaEl ??= document.querySelector('#ypp-playlist-textarea');
if (!playlistInfoEl || !playlistTextareaEl) return;
const size = selectedVideos.size;
playlistInfoEl.textContent = `${t('selectedVideos')}: ${size}`;
if (size === 0) {
playlistTextareaEl.value = '';
return;
}
playlistTextareaEl.value =
`https://www.youtube.com/watch_videos?video_ids=${Array.from(selectedVideos).join(',')}`;
}
/**
* Limpia el contenido del área de playlist
*/
function clearPlaylistContent() {
playlistInfoEl ??= document.querySelector('#ypp-playlist-info');
playlistTextareaEl ??= document.querySelector('#ypp-playlist-textarea');
if (!playlistInfoEl || !playlistTextareaEl) return;
playlistInfoEl.textContent = `${t('selectedVideos')}: 0`;
playlistTextareaEl.value = '';
}
/**
* Copia el enlace de playlist al portapapeles
*/
function copyPlaylistLink() {
playlistTextareaEl ??= document.querySelector('#ypp-playlist-textarea');
if (!playlistTextareaEl || !playlistTextareaEl.value) {
alert(t('selectAtLeastOne'));
return;
}
copyToClipboard(playlistTextareaEl.value, document.querySelector('#ypp-copy-playlist-btn'));
}
/**
* Abre el enlace de playlist en una nueva pestaña
*/
function openPlaylistLink() {
playlistTextareaEl ??= document.querySelector('#ypp-playlist-textarea');
if (!playlistTextareaEl || !playlistTextareaEl.value) {
alert(t('selectAtLeastOne'));
return;
}
window.open(playlistTextareaEl.value, '_blank');
}
/**
* Copia texto al portapapeles
*/
async function copyToClipboard(text, button) {
try {
await navigator.clipboard.writeText(text);
const originalText = button.innerHTML;
setInnerHTML(button, `${SVG_ICONS.check} ${t('linkCopied')}`);
button.className = 'ypp-btn ypp-btn-success';
setTimeout(() => {
setInnerHTML(button, originalText);
button.className = 'ypp-btn ypp-btn-primary';
}, 2000);
logLog('copyToClipboard', 'Enlace copiado al portapapeles');
} catch (err) {
logError('copyToClipboard', 'Error al copiar al portapapeles:', err);
// Fallback para navegadores que no soportan clipboard API
const textarea = createElement('textarea', {
value: text
});
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setInnerHTML(button, `${SVG_ICONS.check} ${t('linkCopied')}`);
button.className = 'ypp-btn ypp-btn-success';
setTimeout(() => {
setInnerHTML(button, `${SVG_ICONS.copy} ${t('copyLink')}`);
button.className = 'ypp-btn ypp-btn-primary';
}, 2000);
}
}
/**
* Alterna la selección de un video
*/
function toggleVideoSelection(videoId) {
if (selectedVideos.has(videoId)) {
selectedVideos.delete(videoId);
logLog('toggleVideoSelection', `Video ${videoId} deseleccionado`);
} else {
selectedVideos.add(videoId);
logLog('toggleVideoSelection', `Video ${videoId} seleccionado`);
}
// Actualizar el checkbox específico
const checkbox = document.querySelector(`input[data-video-id="${videoId}"]`);
if (checkbox) {
checkbox.checked = selectedVideos.has(videoId);
}
// Actualizar contenido de playlist
updatePlaylistContent();
// Refrescar estado de botones en modo gestión
if (isManagementMode) {
updateManagementFooterState();
}
}
// ------------------------------------------
// MARK: 📺 Video Observer & Processing Manager
// ------------------------------------------
/**
* Maneja la observación y procesamiento de videos de forma aislada por tipo.
*/
const VideoObserverManager = (() => {
let videoTypeCache = new WeakMap();
const pendingVideos = new Set();
let isBatchProcessing = false;
/** @description Cache de visibilidad del miniplayer para evitar querySelector excesivos */
let isMiniplayerActive = false;
/** @description Registro de videos actualmente esperando que termine un anuncio para re-encolarse */
const activeAdWaiters = new WeakSet();
/**
* Ejecuta el procesamiento de los videos encolados de forma asíncrona.
*/
const processBatch = () => {
if (pendingVideos.size === 0) {
isBatchProcessing = false;
return;
}
isBatchProcessing = true;
const batch = Array.from(pendingVideos);
pendingVideos.clear();
batch.forEach(video => {
const type = videoTypeCache.get(video);
if (!type) return;
// Si el video ya no tiene src, ignorar
if (!video.src) return;
logInfo('VideoObserverManager', `🎥 Procesando video tipo: ${type}`, { src: video.src });
switch (type) {
case 'watch':
processWatchVideo(video);
break;
case 'shorts':
processShortsVideo(video);
break;
case 'miniplayer':
processMiniplayerVideo(video);
break;
case 'preview':
processPreviewVideo(video);
break;
}
});
// Continuar con el siguiente lote si hay más videos
if (pendingVideos.size > 0) {
setTimeout(processBatch, 0);
} else {
isBatchProcessing = false;
}
};
/**
* Escanea el DOM en busca de videos existentes para procesarlos inmediatamente.
* @param {boolean} force - Si es true, ignora el cache para forzar el re-procesamiento (útil en navegación).
*/
const bootstrap = (force = false) => {
const body = document.body;
if (!body) return;
logInfo('VideoObserverManager', `🔍 Realizando bootstrap de videos existentes...${force ? ' (FORZADO)' : ''}`);
// 1. Buscar video en Watch
if (currentPageType === 'watch') {
const watchVideo = DOMHelpers.getWatchPlayerVideo();
if (watchVideo) {
if (force) videoTypeCache.delete(watchVideo);
enqueueVideo(watchVideo, 'watch');
}
}
// 2. Buscar video en Shorts
if (currentPageType === 'shorts') {
const shortsVideo = DOMHelpers.getShortsPlayerVideo();
if (shortsVideo) {
if (force) videoTypeCache.delete(shortsVideo);
enqueueVideo(shortsVideo, 'shorts');
}
}
// 3. Buscar video en Miniplayer
if (currentPageType !== 'watch') {
const miniplayerVideo = DOMHelpers.getMiniplayerPlayerVideo();
if (miniplayerVideo) {
if (force) videoTypeCache.delete(miniplayerVideo);
enqueueVideo(miniplayerVideo, 'miniplayer');
} else {
// YouTube puede aplicar `miniplayer-is-active` en ytd-app DESPUÉS de la navegación.
// Programamos un reintento breve para cubrir ese gap de timing.
setTimeout(() => {
// El TTL del cache es de 125ms, por lo que a los 600ms re-evaluará el DOM garantizado.
const retryVideo = DOMHelpers.getMiniplayerPlayerVideo();
if (!retryVideo) return;
logLog('VideoObserverManager', '📱 Miniplayer detectado en reintento post-bootstrap, encolando...');
videoTypeCache.delete(retryVideo);
isMiniplayerActive = true;
enqueueVideo(retryVideo, 'miniplayer');
}, 600);
}
}
// 4. Buscar video tipo Preview (Home / Search)
if (currentPageType !== 'shorts') {
const previewVideo = DOMHelpers.getInlinePreviewPlayerVideo();
if (previewVideo) {
if (force) videoTypeCache.delete(previewVideo);
enqueueVideo(previewVideo, 'preview');
}
}
};
/**
* Encola un video para su procesamiento.
* @param {HTMLVideoElement} videoElement - El video a encolar.
* @param {string} type - El tipo de video (watch, shorts, miniplayer, preview).
*/
const enqueueVideo = (videoElement, type) => {
if (!videoElement) return;
// Protección: No encolar videos que son detectados como anuncios
if (AdDetector.isNodeWithinAdContainer(videoElement)) {
logInfo('VideoObserverManager', `🚫 Omitiendo video [${type}] detectado como anuncio por AdDetector`);
// Si el anuncio finaliza en este mismo contenedor, re-encolamos para no perder su progreso
if (!activeAdWaiters.has(videoElement)) {
activeAdWaiters.add(videoElement);
const onAdWait = () => {
if (!document.contains(videoElement)) {
videoElement.removeEventListener('timeupdate', onAdWait);
videoElement.removeEventListener('play', onAdWait);
activeAdWaiters.delete(videoElement);
return;
}
if (!AdDetector.isNodeWithinAdContainer(videoElement)) {
videoElement.removeEventListener('timeupdate', onAdWait);
videoElement.removeEventListener('play', onAdWait);
activeAdWaiters.delete(videoElement);
logInfo('VideoObserverManager', `✅ Video [${type}] liberado de anuncios, re-evaluando...`);
enqueueVideo(videoElement, type);
}
};
videoElement.addEventListener('timeupdate', onAdWait);
videoElement.addEventListener('play', onAdWait);
}
return;
}
if (videoTypeCache.get(videoElement) === type && !pendingVideos.has(videoElement)) {
// Ya procesado o en cola para este tipo
return;
}
logLog('VideoObserverManager', `📥 Encolando video [${type}] para procesar (Total pend: ${pendingVideos.size + 1})`);
videoTypeCache.set(videoElement, type);
pendingVideos.add(videoElement);
if (!isBatchProcessing) {
setTimeout(processBatch, 0);
}
};
// Instancias de observadores
let observers = {
watch: null,
shorts: null,
miniplayer: null,
preview: null
};
const initObservers = (forceBootstrap = false, preserveMiniplayer = false) => {
cleanup(preserveMiniplayer);
const config = { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] };
// 1. Selector para Watch
observers.watch = new MutationObserver((mutations) => {
// Safeguard: solo procesar como "watch" si estamos realmente en la página de watch
if (currentPageType !== 'watch') return;
// Recorre todas las mutaciones detectadas por MutationObserver
for (const m of mutations) {
// Filtra solo mutaciones donde:
// 1) El tipo de cambio sea en atributos
// 2) El atributo modificado sea "src"
// 3) El elemento afectado sea un <video>
if (
m.type === 'attributes' &&
m.attributeName === 'src' &&
m.target instanceof HTMLVideoElement
) {
// El elemento que cambió es el video
const videoEl = m.target;
// Comprueba que el video esté dentro del player principal (#movie_player)
// y que NO esté dentro del miniplayer
if (
videoEl.closest(S.IDS.MOVIE_PLAYER) &&
!videoEl.closest(S.ELEMENTS.MINIPLAYER_ELEMENT)
) {
// Log en consola para depuración
// Indica que el src del video cambió y se reiniciará la sesión
logLog(
'VideoObserverManager',
'📺 Watch: cambio de src detectado, reiniciando sesión y encolando'
);
// Elimina de la caché el tipo de video asociado a este elemento
// Esto fuerza a recalcular si es watch, short, etc.
videoTypeCache.delete(videoEl);
// Obtiene la sesión de procesamiento activa asociada a este video
const prevSession = activeProcessingSessions.get(videoEl);
// Si existía un intervalo activo (setInterval),
// se detiene para evitar procesos duplicados
if (prevSession?.intervalId) {
clearInterval(prevSession.intervalId);
}
// Elimina completamente la sesión previa del registro
activeProcessingSessions.delete(videoEl);
// Encola el video para ser procesado nuevamente
// El segundo parámetro "watch" indica
// que se trata de un video normal de la página de reproducción
enqueueVideo(videoEl, 'watch');
// Sale inmediatamente del loop de mutaciones
// para evitar procesar más eventos innecesarios
return;
}
}
}
// Recorre todas las mutaciones detectadas
mutations.forEach(m => {
// Determina qué nodos revisar dependiendo del tipo de mutación
const nodes = m.type === 'childList'
// Si se agregaron nodos al DOM, usa los nodos añadidos
? Array.from(m.addedNodes)
// Si no, revisa el nodo objetivo de la mutación
: [m.target];
// Recorre cada nodo afectado por la mutación
nodes.forEach(node => {
// Si el nodo no es un elemento del DOM (puede ser texto, comentario, etc.)
// se ignora
if (!(node instanceof Element)) return;
// --------------------------------------------------
// CASO 1: el nodo agregado ES directamente un <video>
// --------------------------------------------------
if (node.tagName === 'VIDEO') {
// Verifica que el video esté dentro del player principal
// y que no sea el miniplayer
if (
node.closest(S.IDS.MOVIE_PLAYER) &&
!node.closest(S.ELEMENTS.MINIPLAYER_ELEMENT)
) {
// Encola el video para procesamiento
enqueueVideo(node, 'watch');
}
// Termina aquí porque ya procesamos este nodo
return;
}
// --------------------------------------------------
// CASO 2: el nodo agregado contiene un <video> dentro
// --------------------------------------------------
// Busca un <video> dentro del nodo agregado
const video = node.querySelector?.('video');
// Si se encontró un video y está dentro del player principal
// y no está dentro del miniplayer
if (
video &&
video.closest(S.IDS.MOVIE_PLAYER) &&
!video.closest(S.ELEMENTS.MINIPLAYER_ELEMENT)
) {
// Encola ese video para procesamiento
enqueueVideo(video, 'watch');
}
});
});
});
// 2. Selector para Shorts
observers.shorts = new MutationObserver((mutations) => {
// Shorts suele reutilizar elementos, detectamos cambio de src
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'src' && m.target instanceof HTMLVideoElement) {
const videoEl = m.target;
if (videoEl.closest(S.IDS.SHORTS_PLAYER)) {
logLog('VideoObserverManager', '📱 Shorts: cambio de src detectado, reiniciando sesión y encolando');
videoTypeCache.delete(videoEl);
const prevSession = activeProcessingSessions.get(videoEl);
if (prevSession?.intervalId) clearInterval(prevSession.intervalId);
activeProcessingSessions.delete(videoEl);
enqueueVideo(videoEl, 'shorts');
return;
}
}
}
mutations.forEach(m => {
const nodes = m.type === 'childList'
? Array.from(m.addedNodes)
: [m.target];
nodes.forEach(node => {
if (!(node instanceof Element)) return;
// Caso 1: el nodo agregado ES el video
if (node.tagName === 'VIDEO') {
if (node.closest(S.IDS.SHORTS_PLAYER)) {
enqueueVideo(node, 'shorts');
}
return;
}
// Caso 2: el video está dentro del nodo agregado
const video = node.querySelector?.('video');
if (video && video.closest(S.IDS.SHORTS_PLAYER)) {
enqueueVideo(video, 'shorts');
}
});
});
});
// 3. Selector para Miniplayer
/** @type {string} Último src visto en el video del miniplayer, para detectar cambios de video. */
let lastMiniplayerSrc = '';
observers.miniplayer = new MutationObserver((mutations) => {
// miniplayer no puede existir en /watch — destruir su display si quedó huérfano
if (currentPageType === 'watch') {
destroyMiniplayerTimeDisplay();
return;
}
// 1. Actualizar estado de visibilidad si el cambio es en ytd-app
// YouTube aplica `miniplayer-is-active` en ytd-app (no en html/body).
const visibilityMutation = mutations.find(m =>
m.target?.tagName === 'YTD-APP' &&
m.type === 'attributes' &&
m.attributeName === ATTRIBUTES.MINIPLAYER_ACTIVE_ATTR
);
if (visibilityMutation) {
logLog('VideoObserverManager', `👀 Miniplayer visibilidad cambió: ${visibilityMutation.attributeName}`);
// Detección de visibilidad ultra-robusta combinando clase, atributo y estado de ytd-app
const newState = !!(
document.querySelector(`${S.ELEMENTS.MINIPLAYER_ELEMENT}${S.CLASSES.MINIPLAYER_COMPONENT_VISIBLE}`) ||
document.querySelector('ytd-app')?.matches(S.ATTR.MINIPLAYER_ACTIVE_ATTR)
);
if (newState !== isMiniplayerActive) {
isMiniplayerActive = newState;
logLog('VideoObserverManager', `📱 Miniplayer visibilidad cambió: ${isMiniplayerActive}`);
// Si se oculta, destruir el display para evitar que quede huérfano
if (!isMiniplayerActive) {
destroyMiniplayerTimeDisplay();
} else {
// El miniplayer acaba de activarse: intentar encolarlo si hay video
const v = DOMHelpers.getMiniplayerPlayerVideo();
if (v) {
videoTypeCache.delete(v);
enqueueVideo(v, 'miniplayer');
}
}
}
}
// Ruta rápida para cambio de src del video (YouTube muta src en el mismo elemento <video>)
// No dependemos de isVisible aquí: si el video está físicamente dentro del selector miniplayer
// y su src cambió, es un cambio de video que SIEMPRE debemos registrar.
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'src' && m.target instanceof HTMLVideoElement) {
const videoEl = m.target;
const newSrc = videoEl.src;
if (videoEl.closest(S.ELEMENTS.MINIPLAYER_ELEMENT) && newSrc && newSrc !== lastMiniplayerSrc) {
lastMiniplayerSrc = newSrc;
logLog('VideoObserverManager', '📱 Miniplayer: cambio de src detectado, reiniciando sesión y encolando');
// Limpiar sesión previa y forzar reprocessing
videoTypeCache.delete(videoEl); // IMPORTANTE: permitir re-encolar
const prevSession = activeProcessingSessions.get(videoEl);
if (prevSession?.intervalId) clearInterval(prevSession.intervalId);
activeProcessingSessions.delete(videoEl);
enqueueVideo(videoEl, 'miniplayer');
return;
}
}
}
// Ruta normal: verificar visibilidad usando el cache de estado
// Nota: isMiniplayerActive se actualiza en NavigationEvents y bootstrap()
// Una alternativa de detección es:
// document.querySelector(".html5-video-player")?.classList.contains("ytp-player-minimized")
if (!isMiniplayerActive) return;
mutations.forEach(m => {
const nodes = m.type === 'childList'
? Array.from(m.addedNodes)
: [m.target];
nodes.forEach(node => {
if (!(node instanceof Element)) return;
const video =
node.tagName === 'VIDEO'
? node
: node.querySelector?.('video');
if (!video) return;
if (video.closest(S.ELEMENTS.MINIPLAYER_ELEMENT)) {
enqueueVideo(video, 'miniplayer');
}
});
});
// Trigger manual en caso de que ya haya un video presente
const v = DOMHelpers.getMiniplayerPlayerVideo();
if (v) enqueueVideo(v, 'miniplayer');
});
// 4. Selector para Previews
// Igual que el miniplayer, YouTube reutiliza el mismo elemento <video> para diferentes previews.
// Usamos lastPreviewSrc para detectar cambios de video via mutación del atributo src.
let lastPreviewSrc = '';
observers.preview = new MutationObserver((mutations) => {
// Filtrar mutaciones que provienen de nuestros propios elementos de UI para evitar bucles infinitos o flickering
const filteredMutations = mutations.filter(m => {
const target = m.target;
if (!(target instanceof Element)) return true;
// Si el target o algún ancestro es nuestro, ignorar
const isOurUi = target.classList.contains('ypp-time-display') ||
target.closest('.ypp-time-display') ||
(target.className && typeof target.className === 'string' && target.className.includes('ypp-'));
return !isOurUi;
});
if (filteredMutations.length === 0) return;
for (const m of filteredMutations) {
// ⚡ Ruta rápida: cambio de src en video de preview (YouTube muta src en el mismo elemento)
if (m.type === 'attributes' && m.attributeName === 'src' && m.target instanceof HTMLVideoElement) {
const videoEl = m.target;
const newSrc = videoEl.src;
const isPreview =
videoEl.closest(S.IDS.INLINE_PREVIEW_PLAYER) ||
videoEl.closest(S.IDS.VIDEO_PREVIEW_CONTAINER);
if (isPreview && newSrc && newSrc !== lastPreviewSrc) {
lastPreviewSrc = newSrc;
logLog('VideoObserverManager', '👁️ Preview: cambio de src detectado, reiniciando sesión y encolando');
// Limpiar sesión previa y forzar reprocessing
videoTypeCache.delete(videoEl); // IMPORTANTE: permitir re-encolar
const prevSession = activeProcessingSessions.get(videoEl);
if (prevSession?.intervalId) clearInterval(prevSession.intervalId);
activeProcessingSessions.delete(videoEl);
enqueueVideo(videoEl, 'preview');
return;
}
}
}
// Ruta normal: nodo nuevo añadido al DOM
filteredMutations.forEach(m => {
if (m.type !== 'childList') return;
Array.from(m.addedNodes).forEach(node => {
if (!(node instanceof Element)) return;
const video =
node.tagName === 'VIDEO'
? node
: node.querySelector?.('video');
if (!video) return;
const isPreview =
video.closest(S.IDS.INLINE_PREVIEW_PLAYER) ||
video.closest(S.IDS.VIDEO_PREVIEW_CONTAINER);
if (isPreview && video.src && video.src !== lastPreviewSrc) {
lastPreviewSrc = video.src;
enqueueVideo(video, 'preview');
}
});
});
});
// Iniciar observación
try {
// Observar el contenedor principal del video (watch)
const playerContainer = document.querySelector(S.IDS.MOVIE_PLAYER);
if (playerContainer) {
observers.watch.observe(playerContainer, config);
logInfo('VideoObserverManager', '✅ Observador de Watch inicializado');
}
// Observar el contenedor principal de Shorts
const shorts = document.querySelector(S.IDS.SHORTS_PLAYER);
if (shorts) {
observers.shorts.observe(shorts, config);
logInfo('VideoObserverManager', '✅ Observador de Shorts inicializado');
}
// Observar el contenedor principal de previews
const previewEl = document.querySelector(S.IDS.VIDEO_PREVIEW_MAIN_CONTAINER);
if (previewEl) {
observers.preview.observe(previewEl, config);
logLog('VideoObserverManager', 'previewEl', previewEl);
logInfo('VideoObserverManager', '✅ Observador de Previews inicializado');
}
// Observar el contenedor principal del miniplayer
const miniContainer = document.querySelector(S.ELEMENTS.MINIPLAYER_ELEMENT);
if (miniContainer) {
observers.miniplayer.observe(miniContainer, config);
// También observar ytd-app para el atributo `miniplayer-is-active`
// que YouTube usa para señalizar la activación del miniplayer.
// Nota: ytd-app NO es descendiente de ytd-miniplayer, por lo que
// necesita su propia llamada a observe() con su propio attributeFilter.
const ytdApp = document.querySelector('ytd-app');
if (ytdApp) {
observers.miniplayer.observe(ytdApp, {
attributes: true,
attributeFilter: [ATTRIBUTES.MINIPLAYER_ACTIVE_ATTR]
});
}
logInfo('VideoObserverManager', '✅ Observador de Miniplayer inicializado');
}
// Asegurar que el tipo de página sea correcto antes del bootstrap inicial
if (!currentPageType) currentPageType = getYouTubePageType();
// Realizar bootstrap inicial
bootstrap(forceBootstrap);
} catch (e) {
logError('VideoObserverManager', '❌ Error al iniciar observadores', e);
}
};
const cleanup = (preserveMiniplayer = false) => {
Object.values(observers).forEach(obs => obs?.disconnect());
observers = { watch: null, shorts: null, miniplayer: null, preview: null };
if (shortsPanelObserver) {
shortsPanelObserver.disconnect();
shortsPanelObserver = null;
}
pendingVideos.clear();
globalNavigationId++; // Invalidar sesiones en vuelo
stopAllSessions(preserveMiniplayer);
logLog('VideoObserverManager', '🧹 Observadores y sesiones desconectadas');
};
const clearCache = () => {
logLog('VideoObserverManager', '🧹 Reiniciando cache de tipos de video');
videoTypeCache = new WeakMap();
pendingVideos.clear();
};
return { init: initObservers, cleanup, bootstrap, clearCache };
})();
// ------------------------------------------
// MARK: Processing Functions
// ------------------------------------------
/**
* Almacena las sesiones de procesamiento activas por cada elemento de video
* para evitar duplicidades y permitir limpieza global en navegación.
* @type {Map<HTMLVideoElement, { intervalId: number, lastVideoId: string }>}
*/
const activeProcessingSessions = new Map();
/**
* Detiene todos los intervalos de seguimiento activos y limpia el registro.
* Se utiliza principalmente durante la navegación (cleanup).
*/
const stopAllSessions = (preserveMiniplayer = false) => {
const sessionCount = activeProcessingSessions.size;
if (sessionCount === 0) return;
let stoppedCount = 0;
for (const [videoEl, session] of activeProcessingSessions.entries()) {
if (preserveMiniplayer && session.type === 'miniplayer') continue;
if (session.intervalId) {
clearInterval(session.intervalId);
}
activeProcessingSessions.delete(videoEl);
stoppedCount++;
}
if (stoppedCount > 0) {
logInfo('process', `🧹 Deteniendo sesiones activas (${stoppedCount})`);
}
};
/**
* ID de navegación global para evitar carreras (race conditions) en la inicialización de sesiones.
* Incrementado en cada cleanup de VideoObserverManager.
*/
let globalNavigationId = 0;
/**
* Inicia una sesión de seguimiento (polling) para un video.
* @param {HTMLVideoElement} videoEl
* @param {string} type - Contexto (watch, shorts, miniplayer, preview)
* @param {string} videoId - ID del video a seguir
* @param {object} player - Objeto del player de YouTube
* @param {string|null} playlistId - ID de la playlist (opcional)
*/
const startProcessingSession = async (videoEl, type, videoId, player) => {
const navIdAtStart = globalNavigationId;
// Si ya hay una sesión para este mismo video, no hacer nada
const currentSession = activeProcessingSessions.get(videoEl);
if (currentSession?.lastVideoId === videoId) return;
// Si hay una sesión vieja para un video diferente en el mismo elemento, limpiar
if (currentSession?.intervalId) {
clearInterval(currentSession.intervalId);
}
// Limpiar proactivamente cualquier mensaje o estado visual previo (zombies de SPA)
clearAllPlaybackMessages();
logInfo('process', `🚀 Iniciando sesión de seguimiento para [${type}] - ${videoId}`);
videoEl.dataset.sessionStartTime = Date.now().toString();
// Obtener metadatos base una sola vez al inicio de la sesión
let cachedVideoInfo = null;
try {
cachedVideoInfo = await getCascadedVideoInfo(player, videoId, videoEl, type);
// Protección crítica: Si hubo navegación durante el await, abortar el inicio de la sesión
if (navIdAtStart !== globalNavigationId) {
logWarn('process', `🛑 Abortando inicio de sesión [${type}] - ${videoId}: Navegación detectada durante fetch`);
return;
}
logInfo('process', `💾 Metadatos cacheados inicialmente para sesión de [${type}] - ${videoId}`);
} catch (e) {
logWarn('process', `⚠️ Error obteniendo metadatos base, se delegará la obtención al guardado:`, e);
}
// 1. Intentar reanudar inmediatamente
// getSavedVideoData usa el playlistId del objeto de metadatos si está disponible
getSavedVideoData(videoId, cachedVideoInfo?.lastViewedPlaylistId).then(savedData => {
if (savedData) {
syncManualSaveUI(videoId, true, !!savedData.forceResumeTime);
if (savedData.watchProgress > 1 || savedData.forceResumeTime > 0) {
PlaybackController.resume(player, videoId, videoEl, savedData, type, cachedVideoInfo);
}
} else {
syncManualSaveUI(videoId, false);
}
});
// 2. Configurar intervalo de guardado
let intervalId = null;
// Optimización: Solo iniciar el intervalo si el guardado automático está habilitado para este tipo
const isLive = cachedVideoInfo?.isLive || false;
const isAutoSaveEnabled =
type === 'shorts' ? cachedSettings?.saveShorts :
type === 'preview' ? cachedSettings?.saveInlinePreviews :
type === 'miniplayer' ? cachedSettings?.saveMiniplayerVideos :
(isLive ? cachedSettings?.saveLiveStreams : cachedSettings?.saveRegularVideos);
if (isAutoSaveEnabled !== false) {
intervalId = setInterval(async () => {
// Kill Switch: Condiciones para detener el seguimiento de esta sesión
const isDisconnected = !document.contains(videoEl);
const isAdNow = AdDetector.isNodeWithinAdContainer(videoEl);
const currentVideoId = getPlayerVideoId(player);
const hasIdChanged = currentVideoId !== videoId;
if (isDisconnected || isAdNow || hasIdChanged) {
const reason = isDisconnected ? 'Elemento removido' : (isAdNow ? 'Anuncio detectado' : `ID cambiado: ${currentVideoId}`);
logInfo('process', `🛑 Deteniendo sesión [${type}] - ${videoId}. Razón: ${reason}`);
clearInterval(intervalId);
activeProcessingSessions.delete(videoEl);
return;
}
// Llamada unificada al controlador modular de guardado usando metadatos cacheados
await PlaybackController.saveStatus(player, videoEl, type, videoId, cachedVideoInfo);
}, (Math.max(cachedSettings?.minSecondsBetweenSaves || 1, 1)) * 1000); // Guardar según configuración (default 1s)
} else {
logLog('process', `Intervalo omitido para [${type}] - ${videoId} (Guardado automático desactivado)`);
}
activeProcessingSessions.set(videoEl, { intervalId, lastVideoId: videoId, type, hasLoggedCompletion: false });
};
async function processWatchVideo(videoEl) {
const isAd = AdDetector.isNodeWithinAdContainer(videoEl);
if (isAd) {
logWarn('processWatchVideo', '🚫 Anuncio detectado en Watch, omitiendo procesamiento.');
return;
}
// Safeguard: Solo procesar como Watch si la URL es realmente de video
// (Previene que el miniplayer en el Home active el procesador de Watch por error)
if (currentPageType !== 'watch') {
logLog('processWatchVideo', '⚠️ Abortando: No estamos en /watch (posible Miniplayer en Home)');
return;
}
const player = DOMHelpers.getWatchPlayer();
if (!player) {
logWarn('processWatchVideo', '⚠️ Player de Watch no encontrado, omitiendo procesamiento.');
return;
}
const videoId = player ? getPlayerVideoId(player) : null;
if (!videoId) {
logWarn('processWatchVideo', '⚠️ ID del video no encontrado en Watch, omitiendo procesamiento.');
return;
}
// Inicializar display proactivamente pasando el player ya resuelto
initTimeDisplay(player);
logInfo('processWatchVideo', `📝 Procesando video de Watch: ${videoId}`);
startProcessingSession(videoEl, 'watch', videoId, player);
}
async function processShortsVideo(videoEl) {
const isAd = AdDetector.isNodeWithinAdContainer(videoEl);
if (isAd) {
logWarn('processShortsVideo', '🚫 Anuncio detectado en Shorts, omitiendo procesamiento.');
return;
}
if (currentPageType !== 'shorts') {
logLog('processShortsVideo', '⚠️ Abortando: No estamos en /shorts');
return;
}
const player = DOMHelpers.getShortsPlayer();
if (!player) {
logWarn('processShortsVideo', '⚠️ Player de Shorts no encontrado, omitiendo procesamiento.');
return;
}
const videoId = player ? getPlayerVideoId(player) : null;
if (!videoId) {
logWarn('processShortsVideo', '⚠️ ID del video no encontrado en Shorts, omitiendo procesamiento.');
return;
}
initShortsTimeDisplay()
logInfo('processShortsVideo', `📱 Procesando video de Shorts: ${videoId}`);
startProcessingSession(videoEl, 'shorts', videoId, player);
}
async function processMiniplayerVideo(videoEl) {
const isAd = AdDetector.isNodeWithinAdContainer(videoEl);
if (isAd) {
logWarn('processMiniplayerVideo', '🚫 Anuncio detectado en miniplayer, omitiendo procesamiento.');
return;
}
const isMiniplayerActive = DOMHelpers.getMiniplayerPlayer();
const player = isMiniplayerActive ? isMiniplayerActive : null;
if (!isMiniplayerActive) {
logLog('processMiniplayerVideo', '🔍 Omitiendo: Miniplayer detectado pero no está activo/visible todavía.', {
isMiniplayerActive: !!isMiniplayerActive,
});
return;
}
const videoId = player ? getPlayerVideoId(player) : null;
if (!videoId) {
logWarn('processMiniplayerVideo', '⚠️ ID del video no encontrado en miniplayer, omitiendo procesamiento.');
return;
}
// Inicializar display del miniplayer proactivamente
initMiniplayerTimeDisplay(player);
logInfo('processMiniplayerVideo', `📺 Procesando video de Miniplayer: ${videoId}`);
startProcessingSession(videoEl, 'miniplayer', videoId, player);
}
async function processPreviewVideo(videoEl) {
const isAd = AdDetector.isNodeWithinAdContainer(videoEl);
if (isAd) {
logWarn('processPreviewVideo', '🚫 Anuncio detectado en Preview, omitiendo procesamiento.');
return;
}
const player = DOMHelpers.getInlinePreviewPlayer();
if (!player) {
logWarn('processPreviewVideo', '⚠️ Player de Preview no encontrado, omitiendo procesamiento.');
return;
}
const videoId = player ? getPlayerVideoId(player) : null;
if (!videoId) {
logWarn('processPreviewVideo', '⚠️ ID del video no encontrado para Preview, omitiendo procesamiento.');
return;
}
// Inicializar display de preview proactivamente con referencia al player ya resuelta
initInlinePreviewTimeDisplay(player);
logInfo('processPreviewVideo', `👁️ Procesando video de Preview: ${videoId}`);
startProcessingSession(videoEl, 'preview', videoId, player);
}
// ------------------------------------------
// MARK: PlaybackController
// ------------------------------------------
/**
* Controlador modular para manejar la reanudación y el guardado de progreso
* sin depender de funciones monolíticas globales.
*/
const PlaybackController = {
/**
* Reanuda la reproducción de un video basándose en datos guardados.
* @param {object} player - Instancia del player de YouTube
* @param {string} videoId - Id del video
* @param {HTMLVideoElement} videoEl - Elemento de video
* @param {object} savedData - Datos recuperados del Storage
* @param {string} type - Contexto (watch, shorts, miniplayer, preview)
* @param {object|null} cachedVideoInfo - Metadatos opcionales ya resueltos
*/
async resume(player, videoId, videoEl, savedData, type, cachedVideoInfo = null) {
if (!savedData || !videoId || !videoEl) return;
const isForced = savedData.forceResumeTime > 0;
const timeToSeek = isForced ? savedData.forceResumeTime : savedData.watchProgress;
if (timeToSeek <= 1 && !isForced) return;
logLog('PlaybackController', `🎬 Intentando reanudar ${videoId} en ${formatTime(timeToSeek)} (${type})`);
const getExpectedDuration = () => {
if (cachedVideoInfo?.lengthSeconds > 0) return cachedVideoInfo.lengthSeconds;
let dur = 0;
try {
dur =
player?.getPlayerResponse?.()?.videoDetails?.lengthSeconds ||
player?.getDuration?.() ||
player?.getVideoData?.()?.length_seconds ||
videoEl.duration;
} catch (e) {
dur = videoEl.duration;
}
return Number(dur) || 0;
};
// Esperar a que el video tenga duración válida
const isReady = () => {
const dur = getExpectedDuration();
// Para livestreams o casos especiales donde la duración es 0/infinita,
// consideramos que está listo si el videoEl tiene un readyState suficiente
if (type === 'live' || type === 'shorts') {
return dur > 0 || videoEl.readyState >= 1;
}
return isFinite(dur) && dur > 0;
};
if (!isReady()) {
logLog('PlaybackController', '⏳ Esperando condiciones óptimas para seek...');
let attempts = 0;
while (!isReady() && attempts < 20) {
await new Promise(r => setTimeout(r, 500));
// Abortar si el video ha cambiado durante la espera
if (videoId !== (player ? getPlayerVideoId(player) : null)) {
logWarn('PlaybackController', `🛑 Abortando resume para ${videoId}: navegación detectada.`);
return;
}
attempts++;
}
}
// Verificación final justo antes de aplicar el seek
if (videoId !== (player ? getPlayerVideoId(player) : null)) {
logWarn('PlaybackController', `🛑 Cancelando seek para ${videoId}: ID ya no coincide.`);
return;
}
if (isReady()) {
const finalDuration = getExpectedDuration();
// Si la duración es 0 o inválida (común en Shorts inicial o Lives), confiar en timeToSeek
const safeTime = (finalDuration > 0 && timeToSeek >= finalDuration)
? Math.max(0, finalDuration - 1)
: timeToSeek;
try {
// Aplicar seek mediante API si está disponible, sino directo al elemento
if (typeof player?.seekTo === 'function') {
player.seekTo(safeTime, true);
} else {
videoEl.currentTime = safeTime;
}
logLog('PlaybackController', `✅ Seek aplicado exitosamente a ${formatTime(safeTime)}`);
// Marcar el tiempo esperado y el momento de la reanudación para evitar "backwards jumps" falsos durante carga
videoEl.dataset.lastSavedTime = safeTime.toString();
videoEl.dataset.lastResumedTime = safeTime.toString();
videoEl.dataset.lastResumeTimestamp = Date.now().toString();
// Notificar al usuario (Toast/PlaybackBar)
notifySeekOrProgress(safeTime, 'seek', { videoType: type, isForced: savedData.forceResumeTime > 0, videoEl });
} catch (e) {
logError('PlaybackController', '❌ Error al aplicar seek:', e);
}
}
},
/**
* Extrae metadatos y guarda el estado actual del video.
* @param {object} player - Instancia del player
* @param {HTMLVideoElement} videoEl - Elemento de video
* @param {string} type - Contexto
* @param {string} videoId - Id del video
* @param {object|null} videoInfo - Metadatos cacheados
* @param {object|null} options - Opciones adicionales, { isManual: true }
*/
async saveStatus(player, videoEl, type, videoId, videoInfo = null, options = {}) {
// Protección redundante: No procesar si no hay elementos o es un anuncio
if (!videoEl || !videoId || AdDetector.isNodeWithinAdContainer(videoEl)) return;
// Si el video está pausado y no hemos cambiado de tiempo tras pausa (seek)
// entonces no procesamos ninguna lógica de guardado ni metadata para ahorrar recursos.
const currentTime = videoEl.currentTime || (typeof player?.getCurrentTime === 'function' ? player.getCurrentTime() : 0);
const duration = videoEl.duration || (typeof player?.getDuration === 'function' ? player.getDuration() : 0);
if (!isFinite(currentTime) || currentTime < 0.1 || isNaN(duration) || duration <= 0) return;
if (videoEl.paused) {
logInfo('saveStatus', `Video ${type} - ${videoId} pausado`)
const prevSavedTime = parseFloat(videoEl.dataset.lastSavedTime || '0');
const diff = currentTime - prevSavedTime;
// Si hay un salto hacia atrás significativo (ej. > 5s) justo después de iniciar o reanudar,
// es probable que sea el player de YouTube reseteándose durante la carga.
const sessionStartTime = parseInt(videoEl.dataset.sessionStartTime || '0', 10);
const lastResumeTimestamp = parseInt(videoEl.dataset.lastResumeTimestamp || '0', 10);
const timeSinceRelevantStart = Date.now() - Math.max(sessionStartTime, lastResumeTimestamp);
if (diff < -5 && timeSinceRelevantStart < 3000) {
logWarn('saveStatus', `⚠️ Saltando guardado: Posible reset del player tras carga/resume (diff: ${diff.toFixed(2)}s, age: ${timeSinceRelevantStart}ms)`);
return { success: false, reason: 'player_reset_detected' };
}
if (Math.abs(diff) < CONFIG.minSeekDiff && !options.isManual) {
// El video está pausado y su tiempo no se movió lo suficiente como para justificar un guardado (no fue un seek)
return { success: false, reason: 'paused_no_seek' };
}
}
// Registrar el último tiempo guardado exitosamente para el próximo tick
videoEl.dataset.lastSavedTime = currentTime;
// Refresco dinámico: Si no hay cache, o si la cache existe pero no tiene vistas (timing de carga),
// intentamos extraer de nuevo para completar los datos.
if (!videoInfo || !videoInfo.viewCount || videoInfo.viewCount === 0) {
const freshInfo = await getCascadedVideoInfo(player, videoId, videoEl, type);
if (videoInfo) {
// Si ya teníamos cache (pasada por referencia), la actualizamos in-place
Object.assign(videoInfo, freshInfo);
} else {
videoInfo = freshInfo;
}
}
// Determinar tipo real actual (Transición Watch -> Miniplayer)
let actualType = type;
if (type === 'watch' && DOMHelpers.getMiniplayerElementActive()) {
actualType = 'miniplayer';
}
// Determinar tipo final (LIVE gana a todo)
const finalType = videoInfo.isLive ? 'live' : actualType;
// Actualizar degradado de color en la barra de progreso
updateProgressBarGradient(currentTime, duration, finalType);
// Verificar si el guardado está habilitado para este tipo final para modo AUTOMÁTICO
let isEnabledForAutoSave = true;
if (finalType === 'live' && !cachedSettings?.saveLiveStreams) isEnabledForAutoSave = false;
else if (finalType === 'shorts' && !cachedSettings?.saveShorts) isEnabledForAutoSave = false;
else if (finalType === 'preview' && !cachedSettings?.saveInlinePreviews) isEnabledForAutoSave = false;
else if (finalType === 'miniplayer' && !cachedSettings?.saveMiniplayerVideos) isEnabledForAutoSave = false;
else if (finalType === 'watch' && !cachedSettings?.saveRegularVideos) isEnabledForAutoSave = false;
// Si es un guardado automático y el tipo está desactivado, salimos.
// Si es manual (options.isManual), permitimos el guardado independientemente del tipo.
if (!options.isManual && !isEnabledForAutoSave) return { success: false, reason: 'disabled_by_settings' };
logLog('PlaybackController', `Datos obtenidos para ${videoId}: ${formatTime(currentTime)}/${formatTime(duration)} (${finalType}) ${options.isManual ? '[MANUAL]' : ''}`);
if (finalType === 'preview') {
logInfo('PlaybackController', `saveStatus call: videoId=${videoId}, cur=${currentTime}, dur=${duration}`);
}
const saveOptions = { isManual: !!options.isManual };
// Armonizar con formato FreeTube (Integer): Actualizamos solo si hay cambio real en segundos redondeados.
const roundedDuration = Math.round(duration);
if (videoInfo && roundedDuration > 0 && videoInfo.lengthSeconds !== roundedDuration) {
videoInfo.lengthSeconds = roundedDuration;
}
// Delegar a la función especializada directamente
let result;
try {
switch (finalType) {
case 'live':
result = await saveLivestream(currentTime, videoInfo, videoEl, saveOptions);
break;
case 'shorts':
result = await saveShortsVideo(currentTime, videoInfo, videoEl, saveOptions);
break;
case 'preview':
{
// Cachear en dataset para evitar querySelector en cada tick del guardado.
// Se resuelve una sola vez por elemento; se invalida automáticamente
// cuando el video sale del DOM y el elemento es recolectado por el garbage collector.
let isShortsPreview;
if (videoEl.dataset.yppShortsPreview) {
isShortsPreview = videoEl.dataset.yppShortsPreview === 'true';
} else {
const previewContainer = videoEl.closest(ELEMENTS.INLINE_PREVIEW_ELEMENT);
isShortsPreview = previewContainer
? previewContainer.querySelector('a[href^="/shorts/"]') !== null
: false;
videoEl.dataset.yppShortsPreview = String(isShortsPreview);
}
const specificPreviewType = isShortsPreview ? 'preview_shorts' : 'preview_watch';
result = await savePreview(currentTime, videoInfo, videoEl, specificPreviewType, saveOptions);
}
break;
case 'watch':
result = await saveRegularVideo(currentTime, videoInfo, videoEl, saveOptions);
break;
case 'miniplayer':
result = await saveMiniplayer(currentTime, videoInfo, videoEl, saveOptions);
break;
default:
result = await saveRegularVideo(currentTime, videoInfo, videoEl, saveOptions);
break;
}
} catch (e) {
logError('PlaybackController', `❌ Error inesperado guardando ${videoId}:`, e);
result = { success: false, reason: e.message };
}
// Mostrar alerta si el almacenamiento está lleno
if (result?.reason === 'storage_full') {
showFloatingToast(`${SVG_ICONS.warning} ${t('storageFull')}`, 6000, { persistent: true });
}
// Notificar progreso si el guardado fue exitoso o es manual
if (result && (result.success || options.isManual)) {
// Propagar flag manual al resultado para la visualización
if (options.isManual) result.isManual = true;
syncManualSaveUI(videoId, true);
notifySeekOrProgress(currentTime, 'progress', { saveResult: result, videoType: actualType, videoEl });
}
return result;
}
};
// MARK: 📋 Get Cascaded Video Info
/**
* Extrae y normaliza metadatos del video mediante una estrategia de resolución en cascada ("Waterfall").
*
* El proceso garantiza la integridad de los datos intentando obtener la información desde múltiples fuentes
* en orden de fiabilidad:
* 1. APIs Internas de YouTube: Acceso directo a `getPlayerResponse()` y `getVideoData()` del reproductor.
* 2. YouTube Helper API: Consulta a la interfaz global del script (YTHelper).
* 3. DOM Fallbacks: Heurísticas basadas en selectores CSS y atributos de elementos según el contexto.
*
* @async
* @param {Object} initialPlayer - Instancia del reproductor de YouTube (Elemento DOM o API object).
* @param {string} videoId - Identificador único de 11 caracteres del video.
* @param {HTMLVideoElement} videoEl - Referencia al elemento `<video>` activo.
* @param {string} type - Contexto de la interfaz ('watch', 'shorts', 'miniplayer', 'preview').
* @returns {Promise<Object>} Promesa que resuelve en un objeto con los metadatos normalizados:
* - `videoId` (string): El ID del video.
* - `title` (string): Título del video.
* - `author` (string): Nombre del canal/autor.
* - `authorId` (string): ID del canal de YouTube.
* - `isLive` (boolean): `true` si es una transmisión en vivo.
* - `published` (number): Timestamp en ms de la fecha de publicación.
* - `description` (string): Descripción corta o fragmento.
* - `viewCount` (number): Número total de visualizaciones.
* - `lengthSeconds` (number): Duración total en segundos.
* - `lastViewedPlaylistId` (string|null): ID de la lista de reproducción actual.
* - `playlistTitle` (string|null): Título de la lista activa.
* - `lastViewedPlaylistType` (string): Categoría de la playlist detectada.
* - `lastViewedPlaylistItemId` (string|null): ID único del ítem en la secuencia.
*/
async function getCascadedVideoInfo(initialPlayer, videoId, videoEl, type) {
let info = {
videoId: videoId,
title: null,
author: null,
authorId: null,
isLive: false,
published: null,
description: null,
viewCount: null,
lengthSeconds: null,
lastViewedPlaylistId: null,
playlistTitle: null, // Solo se usa en formato interno, campo no usado en FreeTube
lastViewedPlaylistType: '', // No se asigna, es una cadena vacía por defecto para compatibilidad con FreeTube
lastViewedPlaylistItemId: null // No se asigna, es null por defecto para compatibilidad con FreeTube
};
let player = initialPlayer;
// 🟢 Nivel 1: YouTube Internal API -
// getPlayerResponse().videoDetails
// getPlayerResponse().microformat.playerMicroformatRenderer
// getVideoData()
try {
// A: getPlayerResponse
const playerResponse = player?.getPlayerResponse?.();
const details = playerResponse?.videoDetails;
// logLog('getCascadedVideoInfo', 'PlayerResponse.videoDetails:', details);
if (details?.videoId === videoId) {
// info.videoId: videoId (ya viene desde parametros de funcion y fue comprobado ya para llegar a este punto)
info.title = details.title ?? info.title;
info.author = details.author ?? info.author;
info.authorId = details.channelId ?? info.authorId;
info.isLive = details.isLive ?? info.isLive; // .isLiveContent puede dar falsos positivos en VODs y .isLive solo aparece si esta actualmente en vivo
// info.published: null (no obtenible mediante este metodo)
info.description = details.shortDescription ?? info.description;
info.viewCount = !isNaN(parseInt(details.viewCount, 10))
? parseInt(details.viewCount, 10)
: info.viewCount;
info.lengthSeconds = !isNaN(parseInt(details.lengthSeconds, 10))
? Math.round(Number(details.lengthSeconds))
: info.lengthSeconds;
// info.lastViewedPlaylistId: null (no obtenible mediante este metodo)
// info.playlistTitle: null (no obtenible mediante este metodo)
// info.lastViewedPlaylistType: '' (No se asigna)
// info.lastViewedPlaylistItemId: null (No se asigna)
// logInfo('getCascadedVideoInfo', 'info after getPlayerResponse:', { ...info });
}
// B: getVideoData
const internalData = player?.getVideoData?.();
// logLog('getCascadedVideoInfo', 'InternalData:', internalData);
if (internalData?.video_id === videoId) {
// info.videoId: videoId (ya viene desde parametros de funcion y fue comprobado ya para llegar a este punto)
info.title = info.title ?? internalData.title;
info.author = info.author ?? internalData.author;
// info.authorId = null (no obtenible mediante este metodo)
info.isLive = info.isLive ?? internalData.isLive;
// info.published = null (no obtenible mediante este metodo)
// info.description = null (no obtenible mediante este metodo)
// info.viewCount = null (no obtenible mediante este metodo)
// info.lengthSeconds = null (no obtenible mediante este metodo)
if (internalData?.list != null) {
// Elemento (internalData?.list) no existe si video no esta en una playlist
// tambien puede no estar listo cuando se ejecuta getVideoData
// por eso comprobar si existe antes de asignar
info.lastViewedPlaylistId ??= internalData.list;
}
// info.playlistTitle: null (no obtenible mediante este metodo)
// info.lastViewedPlaylistType: '' (No se asigna)
// info.lastViewedPlaylistItemId: null (No se asigna)
// logInfo('getCascadedVideoInfo', 'info after getVideoData:', { ...info });
}
// C: Microformat (Metadatos de renderizado)
const microformat = playerResponse?.microformat?.playerMicroformatRenderer;
// logLog('getCascadedVideoInfo', 'Microformat:', microformat);
if (microformat?.externalVideoId === videoId) {
// info.videoId: videoId (ya viene desde parametros de funcion y fue comprobado ya para llegar a este punto)
info.title = microformat.title?.simpleText ?? info.title;
info.author = microformat.ownerChannelName ?? info.author;
info.authorId = microformat.externalChannelId ?? info.authorId;
// info.isLive = null (no obtenible mediante este metodo)
info.published = microformat.publishDate
? new Date(microformat.publishDate).getTime()
: info.published;
info.description = microformat.description?.simpleText
?? info.description;
info.viewCount = !isNaN(parseInt(microformat.viewCount, 10))
? parseInt(microformat.viewCount, 10)
: info.viewCount;
info.lengthSeconds = !isNaN(parseInt(microformat.lengthSeconds, 10))
? Math.round(Number(microformat.lengthSeconds))
: info.lengthSeconds;
// info.lastViewedPlaylistId: null (no obtenible mediante este metodo)
// info.playlistTitle: null (no obtenible mediante este metodo)
// info.lastViewedPlaylistType: '' (No se asigna)
// info.lastViewedPlaylistItemId: null (No se asigna)
// logInfo('getCascadedVideoInfo', 'info after microformat:', { ...info });
}
} catch (e) {
logError('getCascadedVideoInfo', '⚠️ Error en Nivel 1 (Internal API):', e);
}
// 🔵 Nivel 2: YouTube Helper API
try {
if (YTHelper?.video?.id === videoId) {
// info.videoId: videoId (ya viene desde parametros de funcion y fue comprobado ya para llegar a este punto)
info.title = info.title ?? YTHelper.video.title;
info.author = info.author ?? YTHelper.video.channel;
info.authorId = info.authorId ?? YTHelper.video.channelId;
info.isLive = info.isLive ?? YTHelper.video.isCurrentlyLive;
info.published = info.published ?? (YTHelper.video.publishDate ? YTHelper.video.publishDate.getTime() : null);
info.description = info.description ?? YTHelper.video.rawDescription;
info.viewCount = info.viewCount ?? (parseInt(YTHelper.video.viewCount, 10) || null);
info.lengthSeconds = info.lengthSeconds ?? (YTHelper.video.lengthSeconds ? Math.round(YTHelper.video.lengthSeconds) : null);
info.lastViewedPlaylistId = info.lastViewedPlaylistId ?? YTHelper.video.playlistId; // No confiable, suele fallar deteccion
// info.playlistTitle: null (no obtenible mediante este metodo)
// info.lastViewedPlaylistType: '' (No se asigna)
// info.lastViewedPlaylistItemId: null (No se asigna)
//logInfo('getCascadedVideoInfo', 'info after YTHelper.video:', { ...info });
}
} catch (e) {
logError('getCascadedVideoInfo', '⚠️ Error en Nivel 2 (YouTube Helper API):', e);
}
// 🟡 Nivel 3: DOM Fallbacks + Fetchs
try {
if (type === 'shorts' && currentPageType === 'shorts') {
// Si es Shorts, usar metapanel del Short ACTIVO
const metapanel = getActiveShortsControlsContainer();
if (metapanel) {
if (info.title == null) {
const titleEl =
metapanel.querySelector('yt-shorts-video-title-view-model') ||
metapanel.querySelector('h2') ||
// De sidebar
document.querySelector('ytd-video-description-header-renderer #title');
const extractedTitle = titleEl?.textContent?.trim();
if (extractedTitle) {
info.title = extractedTitle;
}
}
if (info.author == null || info.author === t('unknown')) {
const authorEl =
metapanel.querySelector('#channel-name a') ||
metapanel.querySelector('a[href*="/@"]');
const extractedAuthor = authorEl?.textContent?.trim();
if (extractedAuthor) {
info.author = extractedAuthor;
}
}
if (info.authorId == null) {
const channelLink =
metapanel.querySelector('a[href*="/channel/"]') ||
document.querySelector(`${S.IDS.SHORTS_PLAYER} a[href*="/channel/"]`);
const extractedChannelLink = channelLink?.href?.split('/channel/')?.[1]?.split(/[/?#]/)?.[0];
if (extractedChannelLink) {
info.authorId = extractedChannelLink;
}
}
}
if (info.viewCount === 0 || info.viewCount == null) {
async function fetchShortsViews() {
if (!videoId) return null;
const res = await fetch(
"https://www.youtube.com/youtubei/v1/player?key=" + ytcfg.get("INNERTUBE_API_KEY"),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
context: ytcfg.get("INNERTUBE_CONTEXT"),
videoId
})
}
);
const data = await res.json();
return data.videoDetails?.viewCount;
}
let viewCountFromFetch = await fetchShortsViews();
if (viewCountFromFetch) {
info.viewCount = !isNaN(parseInt(viewCountFromFetch, 10))
? parseInt(viewCountFromFetch, 10)
: info.viewCount;
logLog('getCascadedVideoInfo', 'Vistas shorts obtenidas mediante fetch: ' + info.viewCount);
} else {
// view-count-factoid-renderer es un singleton en el panel compartido
// que se actualiza con ~2s de retraso al navegar entre Shorts.
let shortContainer = document.querySelector(`${S.ELEMENTS.YTD_SHORTS} #shorts-panel-container view-count-factoid-renderer`);
if (shortContainer && shortContainer.isConnected) {
const viewEl =
// "1,167,872 vistas"
shortContainer.querySelector('.ytwFactoidRendererFactoid')?.getAttribute?.('aria-label');
const match = viewEl?.match(/[\d.,\s]+/)?.[0] || '';
let cleanMatch = Number(match.replace(/[^\d]/g, ''));
logLog('getCascadedVideoInfo', 'Vistas shorts obtenidas 1er intento ', cleanMatch);
// chequear que no siga en cero
if (cleanMatch === 0) {
const viewEl2 =
// 519,586
shortContainer.querySelector('span.yt-core-attributed-string[role="text"]')?.textContent;
cleanMatch = Number((viewEl2 ?? '').replace(/[^\d]/g, ''));
logLog('getCascadedVideoInfo', 'Vistas shorts obtenidas 2do intento ', cleanMatch);
}
info.viewCount = cleanMatch || info.viewCount; // número limpio
} else {
logWarn('getCascadedVideoInfo', 'No se encontró el contenedor de vistas de shorts');
}
}
}
}
if (info.title == null) {
let titleEl = null;
if (type === 'watch' && currentPageType === 'watch') {
titleEl = DOMHelpers.get(`video:titleWatch:${videoId}`, () =>
document.querySelector('h1.ytd-video-primary-info-renderer') ||
document.querySelector('yt-formatted-string.ytd-video-description-header-renderer'), 250);
} else if (type === 'miniplayer') {
titleEl = DOMHelpers.get(`video:titleMini:${videoId}`, () =>
document.querySelector('ytd-miniplayer-info-bar h1.ytdMiniplayerInfoBarTitle span') ||
document.querySelector('ytd-miniplayer-info-bar h1.ytdMiniplayerInfoBarTitle span[role="text"]'), 250);
} else if (type === 'preview') {
titleEl = DOMHelpers.get(`video:titlePreview:${videoId}`, () =>
document.querySelector(`${S.IDS.INLINE_PREVIEW_PLAYER} .ytp-title-text`) ||
document.querySelector(`${S.IDS.INLINE_PREVIEW_PLAYER} .ytp-title-link`), 250);
}
info.title = titleEl?.textContent?.trim() ?? info.title;
}
if (info.author == null || info.author === t('unknown')) {
let authorEl;
if (type === 'watch' && currentPageType === 'watch') {
authorEl = DOMHelpers.get(`video:authorWatch:${videoId}`, () =>
document.querySelector('#owner #channel-name yt-formatted-string'),
250
);
}
info.author = authorEl?.textContent?.trim() ?? info.author;
}
// info.authorId: '',
// info.isLive: false,
// info.published: 0,
// info.description: '',
// info.viewCount: 0,
// info.lengthSeconds: 0,
if (info.lastViewedPlaylistId == null) {
// Intentar obtener del objeto Player
if (typeof player?.getPlaylistId === 'function') {
const playerPlaylistId = player.getPlaylistId();
if (playerPlaylistId) {
info.lastViewedPlaylistId = playerPlaylistId;
logLog('getCascadedVideoInfo', `Playlist id obtenida usando getPlaylistId(): [${info.lastViewedPlaylistId}]`);
}
}
if (type === 'watch' && currentPageType === 'watch') {
const { list: urlPlaylistId, id: videoIdFromUrl } = extractOrNormalizeVideoId(window.location.href);
if (urlPlaylistId && videoIdFromUrl === info.videoId) {
info.lastViewedPlaylistId = urlPlaylistId;
logLog('getCascadedVideoInfo', `Playlist id obtenido de URL fallback: [${urlPlaylistId}]`);
}
}
if (type === 'preview') {
const videoPreviewLink = document.querySelector(`${S.IDS.VIDEO_PREVIEW_CONTAINER} a#media-container-link`);
if (videoPreviewLink?.href && videoPreviewLink?.href.includes('list=')) {
logLog('getCascadedVideoInfo', `Video preview link: [${videoPreviewLink?.href}]`);
const { list: videoPreviewPlaylistId } = extractOrNormalizeVideoId(`https://www.youtube.com${videoPreviewLink?.href}`);
if (videoPreviewPlaylistId) {
info.lastViewedPlaylistId = videoPreviewPlaylistId;
logLog('getCascadedVideoInfo', `Playlist id obtenido de video preview fallback: [${videoPreviewPlaylistId}]`);
}
} else {
logInfo('getCascadedVideoInfo', 'No se encontró referencia a playlist en el video preview');
}
}
if (type === 'miniplayer') {
logLog('getCascadedVideoInfo', `Miniplayer playlist id: [${info.lastViewedPlaylistId}]`);
let currentPlaylistId = null;
let retryCount = 0;
const maxRetries = 5;
while (!currentPlaylistId && retryCount < maxRetries) {
try {
const selectors = ['.ytp-next-button', '.ytp-prev-button'];
for (const selector of selectors) {
const anchor = player?.querySelector?.(selector);
if (anchor?.href) {
// los selectores al ser de botones se avanzar/retroceder del dropdown de playlist miniplayer,
// su videoId no hacen match con video actualmente reproduciendose
const { list: listParam, id: videoIdFromUrl } = extractOrNormalizeVideoId(anchor?.href);
logLog('getCascadedVideoInfo', `Anchor href: [${anchor?.href}] extrac id: ${videoIdFromUrl} list: ${listParam}`);
if (listParam) {
logLog('getCascadedVideoInfo', `List param: [${listParam}]`);
currentPlaylistId = listParam;
break;
}
}
}
} catch (e) {
logError('getCascadedVideoInfo', 'Error al obtener playlist id:', e);
}
if (currentPlaylistId) break;
await new Promise(r => setTimeout(r, 200));
retryCount++;
}
info.lastViewedPlaylistId = currentPlaylistId ?? info.lastViewedPlaylistId;
}
}
// Playlist Title - Fetch fallback via Innertube /next
if (
info.lastViewedPlaylistId && info.lastViewedPlaylistId !== '' && !info.lastViewedPlaylistId.startsWith('RD') &&
(info.playlistTitle == null || info.playlistTitle === '') &&
(type === 'watch' || type === 'miniplayer')
) {
// Nivel 1: Si hay playlistId, obtener título (maneja cache automáticamente)
// Si estamos en Watch, el título de la playlist suele estar ya cacheado o disponible en el DOM
info.playlistTitle = await getPlaylistName(info.lastViewedPlaylistId) ?? info.playlistTitle;
// Nivel 2: Fallback en fast-transitions: Si seguimos en null, es posible que el DOM/API aún no se hayan propagado.
async function fetchPlaylistTitle() {
if (!info.lastViewedPlaylistId) return null;
try {
const res = await fetch(
'https://www.youtube.com/youtubei/v1/next?key=' + ytcfg.get('INNERTUBE_API_KEY'),
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
context: ytcfg.get('INNERTUBE_CONTEXT'),
videoId,
playlistId: info.lastViewedPlaylistId
})
}
);
const data = await res.json();
return (
data?.contents?.twoColumnWatchNextResults?.playlist?.playlist?.titleText?.runs?.[0]?.text ??
data?.playerOverlays?.playerOverlayRenderer?.autoplay?.playerOverlayAutoplayRenderer?.playlistTitle?.simpleText ??
null
);
} catch (e) {
logWarn('getCascadedVideoInfo', 'fetchPlaylistTitle: Error al obtener título de playlist:', e);
return null;
}
}
if (info.playlistTitle == null || info.playlistTitle === '') {
const titleFromFetch = await fetchPlaylistTitle();
if (titleFromFetch) {
logLog('getCascadedVideoInfo', 'playlistTitle obtenido mediante fetch /next: ' + titleFromFetch);
info.playlistTitle = titleFromFetch;
}
}
}
// VIEWS: '.view-count, view-count-factoid-renderer .ytwFactoidRendererFactoid[role="text"], ytd-watch-info-text div#tooltip.tp-yt-paper-tooltip, yt-formatted-string.view-count'
} catch (e) {
logError('getCascadedVideoInfo', '⚠️ Error en Nivel 3 (DOM Fallbacks):', e);
}
// Limpieza final
info.title = info.title ?? (
(currentPageType === 'watch' || currentPageType === 'shorts')
? document.title.replace(/ - YouTube$/, '')
: ''
);
info.author = info.author ?? t('unknown');
info.authorId = info.authorId ?? '';
info.published = info.published ?? 0;
info.description = info.description ?? '';
info.viewCount = info.viewCount ?? 0;
info.lengthSeconds = info.lengthSeconds ?? 0;
info.lastViewedPlaylistId = info.lastViewedPlaylistId ?? null;
info.playlistTitle = info.playlistTitle ?? null;
// logInfo('getCascadedVideoInfo', 'info final:', { ...info });
return info;
}
// ------------------------------------------
// MARK: 📂 Sort UI
// ------------------------------------------
function createSortSelector(currentValue, onChange) {
const wrapper = createElement('div', { className: 'ypp-d-flex ypp-filter-1' });
const label = createElement('label', { className: 'ypp-label ypp-label-filters', text: `${t('sortBy')}:`, atribute: { for: 'sort-selector' } });
const select = createElement('select', {
className: 'ypp-sort-select',
id: 'sort-selector',
html: `
<optgroup label="📅 ${t('sortBy')}">
<option value="recent" ${currentValue === 'recent' ? 'selected' : ''}>📅 ${t('mostRecent')}</option>
<option value="oldest" ${currentValue === 'oldest' ? 'selected' : ''}>📆 ${t('oldest')}</option>
</optgroup>
<optgroup label="🔤 ${t('titleAZ')} / ${t('authorAZ')}">
<option value="titleAZ" ${currentValue === 'titleAZ' ? 'selected' : ''}>🔤 ${t('titleAZ')}</option>
<option value="titleZA" ${currentValue === 'titleZA' ? 'selected' : ''}>🔤 ${t('titleZA')}</option>
<option value="authorAZ" ${currentValue === 'authorAZ' ? 'selected' : ''}>👤 ${t('authorAZ')}</option>
<option value="authorZA" ${currentValue === 'authorZA' ? 'selected' : ''}>👤 ${t('authorZA')}</option>
</optgroup>
<optgroup label="⏳ ${t('duration')}">
<option value="durationShort" ${currentValue === 'durationShort' ? 'selected' : ''}>⏳ ${t('durationShort')}</option>
<option value="durationLong" ${currentValue === 'durationLong' ? 'selected' : ''}>⌛ ${t('durationLong')}</option>
</optgroup>
<optgroup label="🔥 ${t('yourMostWatched')}">
<option value="yourMostWatched" ${currentValue === 'yourMostWatched' ? 'selected' : ''}>🔥 ${t('yourMostWatched')}</option>
<option value="yourLeastWatched" ${currentValue === 'yourLeastWatched' ? 'selected' : ''}>🧊 ${t('yourLeastWatched')}</option>
</optgroup>
<optgroup label="👀 ${t('mostViewsYoutube')}">
<option value="mostViewsYoutube" ${currentValue === 'mostViewsYoutube' ? 'selected' : ''}>👀 ${t('mostViewsYoutube')}</option>
<option value="leastViewsYoutube" ${currentValue === 'leastViewsYoutube' ? 'selected' : ''}>👓 ${t('leastViewsYoutube')}</option>
</optgroup>
<optgroup label="📉 ${t('progressDESC')}">
<option value="progressDESC" ${currentValue === 'progressDESC' ? 'selected' : ''}>📉 ${t('progressDESC')}</option>
<option value="progressASC" ${currentValue === 'progressASC' ? 'selected' : ''}>📈 ${t('progressASC')}</option>
</optgroup>
`});
select.onchange = () => onChange(select.value);
wrapper.appendChild(label);
wrapper.appendChild(select);
return wrapper;
}
// ------------------------------------------
// MARK: 📂 Filters UI
// ------------------------------------------
// nota futuro yo: <option> no soporta SVG
function createFilterSelector(currentValue, onChange) {
const wrapper = createElement('div', { className: 'ypp-d-flex ypp-filter-2' });
const label = createElement('label', { className: 'ypp-label ypp-label-filters', text: `${t('filterByType')}:`, atribute: { for: 'filter-selector' } });
const select = createElement('select', {
className: 'ypp-filter-select', id: 'filter-selector', html: `
<option value="all" ${currentValue === 'all' ? 'selected' : ''}>🔎 ${t('all')}</option>
<option value="video" ${currentValue === 'video' ? 'selected' : ''}>▶️ ${t('videos')}</option>
<option value="shorts" ${currentValue === 'shorts' ? 'selected' : ''}>📱 ${t('shorts')}</option>
<option value="preview" ${currentValue === 'preview' ? 'selected' : ''}>👁️ ${t('previews')}</option>
<option value="live" ${currentValue === 'live' ? 'selected' : ''}>🔴 ${t('liveStreams')}</option>
<option value="playlist" ${currentValue === 'playlist' ? 'selected' : ''}>📁 ${t('playlist')}</option>
<option value="completed" ${currentValue === 'completed' ? 'selected' : ''}>✅ ${t('completedVideos')}</option>
<option value="completedOnce" ${currentValue === 'completedOnce' ? 'selected' : ''}>✅1️⃣ ${t('completedOnce')}</option>
<option value="fixedTime" ${currentValue === 'fixedTime' ? 'selected' : ''}>⏱️📌 ${t('videosWithFixedTime')}</option>
<option value="protected" ${currentValue === 'protected' ? 'selected' : ''}>🔒 ${t('protectedVideos')}</option>`
});
select.onchange = () => onChange(select.value);
wrapper.appendChild(label);
wrapper.appendChild(select);
return wrapper;
}
/**
* Crea un filtro híbrido que combina un selector de presets con campos de rango personalizados.
* @param {string} type - 'views' o 'percent' para definir los presets.
* @param {number} minVal - Valor mínimo actual.
* @param {number} maxVal - Valor máximo actual.
* @param {(val: number) => void} onMinChange - Callback para cambio de mín.
* @param {(val: number) => void} onMaxChange - Callback para cambio de máx.
*/
function createRangeFilter(type, minVal, maxVal, onChange) {
const wrapper = createElement('div', { className: 'ypp-range-filter-section' });
let labelKey = '';
if (type === 'views') labelKey = 'views';
else if (type === 'percent') labelKey = 'percentWatched';
const label = createElement('label', { className: 'ypp-label ypp-label-filters', text: `${t(labelKey)}:` });
const controls = createElement('div', { className: 'ypp-range-controls' });
// Generar HTML de opciones directamente (mismo patrón que createSortSelector)
const presets = type === 'views'
? [
{ label: t('all'), min: 0, max: 0 },
{ label: '1k+', min: 1000, max: 0 },
{ label: '10k+', min: 10000, max: 0 },
{ label: '100k+', min: 100000, max: 0 },
{ label: '1M+', min: 1000000, max: 0 },
{ label: '10M+', min: 10000000, max: 0 },
{ label: '100M+', min: 100000000, max: 0 },
{ label: '1B+', min: 1000000000, max: 0 }
]
: [
{ label: t('all'), min: 0, max: 100 },
{ label: '1%+', min: 1, max: 100 },
{ label: '5%+', min: 5, max: 100 },
{ label: '10%+', min: 10, max: 100 },
{ label: '15%+', min: 15, max: 100 },
{ label: '20%+', min: 20, max: 100 },
{ label: '25%+', min: 25, max: 100 },
{ label: '30%+', min: 30, max: 100 },
{ label: '35%+', min: 35, max: 100 },
{ label: '40%+', min: 40, max: 100 },
{ label: '45%+', min: 45, max: 100 },
{ label: '50%+', min: 50, max: 100 },
{ label: '55%+', min: 55, max: 100 },
{ label: '60%+', min: 60, max: 100 },
{ label: '65%+', min: 65, max: 100 },
{ label: '70%+', min: 70, max: 100 },
{ label: '75%+', min: 75, max: 100 },
{ label: '80%+', min: 80, max: 100 },
{ label: '85%+', min: 85, max: 100 },
{ label: '90%+', min: 90, max: 100 },
{ label: '95%+', min: 95, max: 100 },
{ label: `✅ ${t('completed')}`, min: 100, max: 100 }
];
// Añadir opción personalizada (visible solo cuando se activa)
presets.push({ label: `⚙️ ${t('custom')}`, min: -1, max: -1, isCustom: true });
const optionsHtml = presets.map(p => {
const val = p.isCustom ? 'custom' : JSON.stringify({ min: p.min, max: p.max });
const isSelected = (!p.isCustom && minVal === p.min && (p.max === 0 || maxVal === p.max)) ? 'selected' : '';
return `<option value='${val}' ${isSelected}>${p.label}</option>`;
}).join('');
const select = createElement('select', {
className: 'ypp-filter-select ypp-filter-preset',
html: optionsHtml
});
// Si al cargar no coincide con ningún preset, seleccionar 'custom'
const hasMatch = presets.some(p => !p.isCustom && minVal === p.min && (p.max === 0 || maxVal === p.max));
if (!hasMatch) select.value = 'custom';
// Inputs numéricos personalizados con etiquetas superiores
const customGroup = createElement('div', { className: 'ypp-range-inputs-group' });
// Min
const minWrapper = createElement('div', { className: 'ypp-range-input-wrapper' });
minWrapper.appendChild(createElement('label', { className: 'ypp-range-input-label', text: t('minLimit') }));
const inputMin = createElement('input', {
className: 'ypp-range-input',
attributes: {
type: 'number',
placeholder: t('minLimit'),
title: t('minLimit'),
min: 0,
value: minVal ?? 0,
...(type === 'percent' ? { max: 100 } : {})
}
});
minWrapper.appendChild(inputMin);
// Max
const maxWrapper = createElement('div', { className: 'ypp-range-input-wrapper' });
maxWrapper.appendChild(createElement('label', { className: 'ypp-range-input-label', text: t('maxLimit') }));
const inputMax = createElement('input', {
className: 'ypp-range-input',
attributes: {
type: 'number',
placeholder: t('maxLimit'),
title: t('maxLimit'),
min: 0,
value: maxVal ?? 0,
...(type === 'percent' ? { max: 100 } : {})
}
});
maxWrapper.appendChild(inputMax);
// Sincronización: preset → inputs
select.onchange = () => {
if (select.value === 'custom') return;
try {
const { min, max } = JSON.parse(select.value);
inputMin.value = min;
inputMax.value = max;
onChange(min, max);
} catch (_) { }
};
// Sincronización: inputs → filtros
const updateFromInputs = () => {
const min = parseInt(inputMin.value) || 0;
const max = parseInt(inputMax.value) || 0;
// Buscar si coincide con algún preset para marcarlo, si no, poner 'Custom'
const found = presets.find(p => !p.isCustom && min === p.min && (p.max === 0 || max === p.max));
if (found) {
select.value = JSON.stringify({ min: found.min, max: found.max });
} else {
select.value = 'custom';
}
onChange(min, max);
};
inputMin.onchange = updateFromInputs;
inputMax.onchange = updateFromInputs;
customGroup.appendChild(minWrapper);
customGroup.appendChild(createElement('span', { text: ' - ' }));
customGroup.appendChild(maxWrapper);
controls.appendChild(select);
controls.appendChild(customGroup);
wrapper.appendChild(label);
wrapper.appendChild(controls);
return wrapper;
}
function createSearchInput(currentValue, onChange) {
const wrapper = createElement('div', { className: 'ypp-d-flex ypp-searchbar' });
const input = createElement('input', {
className: 'ypp-search-input',
id: 'search-input',
atribute: {
'aria-label': t('searchByTitleOrAuthor'),
title: t('searchByTitleOrAuthor'),
placeholder: `🔍 ${t('searchByTitleOrAuthor')}`,
type: 'text'
}
});
input.value = currentValue;
// Aplicar debounce para no procesar cada tecla inmediatamente
const debouncedOnChange = debounce((value) => onChange(value), 300);
input.addEventListener('input', () => debouncedOnChange(input.value.trim()));
wrapper.appendChild(input);
return wrapper;
}
// ------------------------------------------
// MARK: 📂 Video List UI
// ------------------------------------------
/** @type {HTMLElement|null} Overlay principal de la lista de videos (fondo negro) */
let videosOverlay = null;
/** @type {HTMLElement|null} Contenedor principal de la lista de videos */
let videosContainer = null;
/** @type {HTMLElement|null} Contenedor de la lista de videos */
let listContainer = null;
/** @type {string|null} Orden actual de la lista de videos */
let currentOrderBy = null;
/** @type {string|null} Filtro actual de la lista de videos */
let currentFilterBy = null;
/** @type {string|null} Búsqueda actual de la lista de videos */
let currentSearchQuery = null;
/** @type {number} Mínimo de vistas para filtrar */
let currentMinViews = 0;
/** @type {number} Máximo de vistas para filtrar */
let currentMaxViews = 0;
/** @type {number} Porcentaje mínimo de reproducción para filtrar */
let currentMinPercent = 0;
/** @type {number} Porcentaje máximo de reproducción para filtrar */
let currentMaxPercent = 100;
/** @type {VirtualScroller|null} Instancia del scroller virtual para la lista de videos */
let virtualScroller = null;
/** @type {number|null} ID del intervalo de actualización del uso de almacenamiento */
let storageUsageRefreshIntervalId = null;
/** @type {Map<string, string>} Cache global de títulos por ID para uso en createVideoEntry */
let modalVideoTitleById = new Map();
/** @constant {number} Altura estimada de cada item de video en px. Se usa para calcular posiciones en el virtual scroller. */
const VIDEO_ITEM_HEIGHT = 120;
/**
* Carga los datos de video del Storage en lotes paralelos para mejor rendimiento.
* @param {string[]} keys - Keys a cargar
* @param {number} [batchSize=50] - Tamaño del lote
* @returns {Promise<Map<string, any>>}
*/
async function batchLoadStorageData(keys, batchSize = 50) {
const results = new Map();
for (let i = 0; i < keys.length; i += batchSize) {
const batch = keys.slice(i, i + batchSize);
const promises = batch.map(async key => {
const data = await Storage.get(key);
return [key, data];
});
const batchResults = await Promise.all(promises);
batchResults.forEach(([key, data]) => {
if (data) results.set(key, data);
});
// Ceder control al event loop cada lote para no bloquear UI
if (i + batchSize < keys.length) {
await new Promise(r => requestAnimationFrame(r));
}
}
return results;
}
// MARK: 📁 Update Video List
/**
* Actualiza la lista de videos usando virtualización para rendimiento óptimo.
* Solo renderiza los items visibles en el viewport, ideal para miles de videos.
*/
async function updateVideoList() {
if (!listContainer) return;
// Si el scroller ya existe, solo actualizar stats sin destruir el DOM
const scrollerElCheck = document.getElementById('ypp-virtual-scroller-container');
let loadingIndicator = listContainer.querySelector('.ypp-virtual-loading');
if (!loadingIndicator) {
loadingIndicator = createElement('div', {
className: 'ypp-virtual-loading',
text: `⏳ ${t('loading')}...`
});
}
if (!virtualScroller || !scrollerElCheck) {
// Primera carga o reconstrucción completa: limpiar observers previos y añadir loader
if (virtualScroller) {
virtualScroller.destroy?.();
virtualScroller = null;
}
setInnerHTML(listContainer, '');
listContainer.appendChild(loadingIndicator);
} else {
// Actualización: usar overlay para no ocultar scroller y evitar pérdida de scroll/parpadeo
loadingIndicator.style.cssText = `
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(15, 15, 15, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
color: var(--ypp-white);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
`;
if (!loadingIndicator.parentElement) listContainer.appendChild(loadingIndicator);
loadingIndicator.style.display = 'flex';
const statsEl = document.querySelector('#ypp-virtual-stats');
if (statsEl) setInnerHTML(statsEl, `⏳ ${t('loading')}...`);
}
// Guardar posición de scroll antes de actualizar
const currentScrollTop = virtualScroller?.container?.scrollTop ?? 0;
const keys = await Storage.keys();
// Cargar todos los datos en lotes paralelos
const allData = await batchLoadStorageData(keys);
if (!listContainer) return;
let allItems = [];
for (const [key, data] of allData) {
if (!data) continue;
// Formato actual: cada video es una entrada independiente con sus metadatos consolidados
const videoId = data.videoId || key;
const playlistId = data.lastViewedPlaylistId || null;
const playlistTitle = data.playlistTitle || playlistId || null;
allItems.push({
type: playlistId ? 'playlist-video' : 'regular-video',
videoId,
// Aplicar normalizeVideoData en lectura para que la migración de
// completionHistory (0.0.9-7) se ejecute aunque el video no haya
// sido re-guardado aún desde la actualización.
info: normalizeVideoData(data),
playlistKey: playlistId,
playlistTitle: playlistTitle
});
}
// Resolver títulos estables para Mix (RD...) usando el video semilla (RD{videoId}) cuando el título es genérico
const videoTitleById = new Map();
for (const item of allItems) {
if (!item?.videoId) continue;
const title = item.info?.title;
if (typeof title === 'string' && title.trim().length > 0) {
videoTitleById.set(item.videoId, title.trim());
}
}
// Publicar en cache global del modal para que createVideoEntry pueda usarlo
modalVideoTitleById.clear();
for (const [id, title] of videoTitleById) {
modalVideoTitleById.set(id, title);
}
for (const item of allItems) {
if (item?.type !== 'playlist-video') continue;
const playlistKey = item.playlistKey;
if (!playlistKey || !playlistKey.startsWith('RD')) continue;
const currentTitle = item.playlistTitle || playlistKey;
if (currentTitle !== playlistKey) continue; // ya hay un título real (o al menos no genérico)
const seedVideoId = playlistKey.slice(2);
const seedTitle = videoTitleById.get(seedVideoId);
if (seedTitle) {
item.playlistTitle = `Mix - ${seedTitle}`;
}
}
// Aplicar filtros
let filteredItems = allItems.filter(item => {
const vType = item.info.type;
if (currentFilterBy === 'completed') return item.info.isCompleted === true;
if (currentFilterBy === 'completedOnce') return item.info.completionHistory?.length > 0;
if (currentFilterBy === 'fixedTime') return item.info.forceResumeTime && item.info.forceResumeTime > 0;
if (currentFilterBy === 'protected') return item.info.isProtected === true;
if (currentFilterBy === 'playlist') return item.type === 'playlist-video';
if (currentFilterBy === 'preview') return (vType && vType.startsWith('preview'));
if (currentFilterBy === 'video') return vType === 'video';
if (currentFilterBy === 'shorts') return vType === 'shorts';
if (currentFilterBy === 'live') return vType === 'live';
if (currentFilterBy === 'all') return true;
return vType === currentFilterBy;
}).filter(item => {
if (!currentSearchQuery) return true;
const query = currentSearchQuery.toLowerCase();
return (item.info.title || '').toLowerCase().includes(query) ||
(item.info.author || '').toLowerCase().includes(query) ||
(item.playlistTitle || '').toLowerCase().includes(query);
}).filter(item => {
// Filtro por vistas
const views = item.info.viewCount ?? 0;
if (currentMinViews > 0 && views < currentMinViews) return false;
if (currentMaxViews > 0 && views > currentMaxViews) return false;
// Filtro por porcentaje
const watchProgress = item.info.watchProgress ?? 0;
const lengthSeconds = item.info.lengthSeconds ?? 0;
let percent = 0;
if (lengthSeconds > 0) {
percent = Math.round((watchProgress / lengthSeconds) * 100);
}
// Si el video está marcado como completado, lo tratamos como 100% para el filtro
const effectivePercent = item.info.isCompleted ? 100 : percent;
// Si no hay duración y no está completado, pero se requiere un progreso mínimo, ocultar
if (lengthSeconds <= 0 && !item.info.isCompleted && currentMinPercent > 0) {
return false;
}
if (currentMinPercent > 0 && effectivePercent < currentMinPercent) return false;
if (currentMaxPercent < 100 && effectivePercent > currentMaxPercent) return false;
return true;
});
// Aplicar ordenamiento
const getSortValue = (item) => {
if (currentOrderBy === 'titleAZ' || currentOrderBy === 'title') return (item.info.title || item.videoId).toLowerCase();
if (currentOrderBy === 'titleZA') {
const t = (item.info.title || item.videoId).toLowerCase();
// Para invertir strings, podemos usar un truco de charCode o simplemente invertir la lógica en el sort,
// pero aquí devolvemos el valor crudo y el sort se encarga si detecta string.
return t;
}
if (currentOrderBy === 'authorAZ' || currentOrderBy === 'author') return (item.info.author || '').toLowerCase();
if (currentOrderBy === 'authorZA') return (item.info.author || '').toLowerCase();
if (currentOrderBy === 'durationShort') return item.info.lengthSeconds || 0;
if (currentOrderBy === 'durationLong') return -(item.info.lengthSeconds || 0);
if (currentOrderBy === 'yourMostWatched') return -(item.info.completionHistory?.length || 0);
if (currentOrderBy === 'yourLeastWatched') return (item.info.completionHistory?.length || 0);
if (currentOrderBy === 'mostViewsYoutube') return -(item.info.viewCount || 0);
if (currentOrderBy === 'leastViewsYoutube') return (item.info.viewCount || 0);
if (currentOrderBy === 'progressDESC' || currentOrderBy === 'progress') {
const prog = (item.info.lengthSeconds > 0) ? (item.info.watchProgress / item.info.lengthSeconds) : (item.info.isCompleted ? 1 : 0);
return -prog;
}
if (currentOrderBy === 'progressASC') {
const prog = (item.info.lengthSeconds > 0) ? (item.info.watchProgress / item.info.lengthSeconds) : (item.info.isCompleted ? 1 : 0);
return prog;
}
const time = item.info.timeWatched || 0;
if (currentOrderBy === 'oldest') return time;
return -time;
};
filteredItems.sort((a, b) => {
const valA = getSortValue(a);
const valB = getSortValue(b);
if (typeof valA === 'string') {
const cmp = valA.localeCompare(valB);
if (currentOrderBy === 'titleZA' || currentOrderBy === 'authorZA') return -cmp;
return cmp;
}
return valA - valB;
});
// Pre-procesar items para incluir headers de playlist
const virtualItems = [];
let lastPlaylistKey = null;
for (const item of filteredItems) {
if (item.type === 'playlist-video' && item.playlistKey && item.playlistKey !== lastPlaylistKey) {
const basePlaylistTitle = item.playlistTitle || item.playlistKey;
const headerTitle = (item.playlistKey?.startsWith('RD') && basePlaylistTitle === item.playlistKey)
? `Mix - ${item.info?.title || t('unknown')}`
: basePlaylistTitle;
virtualItems.push({
type: 'playlist-header',
playlistKey: item.playlistKey,
playlistTitle: headerTitle,
firstVideoId: item.videoId // Guardar ID para enlaces de Mixes
});
lastPlaylistKey = item.playlistKey;
} else if (item.type !== 'playlist-video') {
lastPlaylistKey = null;
}
virtualItems.push(item);
}
// Si ya existe el scroller y el contenedor en el DOM, solo actualizar items y restaurar scroll
// Manejar estado vacío (sin resultados) para evitar que se quede la lista previa
const scrollerEl = document.getElementById('ypp-virtual-scroller-container');
let emptyMsg = listContainer.querySelector('.ypp-emptyMsg');
if (filteredItems.length === 0) {
if (loadingIndicator) loadingIndicator.style.display = 'none';
if (scrollerEl) scrollerEl.style.display = 'none';
if (virtualScroller) virtualScroller.updateItems([]);
if (!emptyMsg) {
emptyMsg = createElement('p', { className: 'ypp-emptyMsg', text: t('noSavedVideos') });
listContainer.appendChild(emptyMsg);
}
emptyMsg.style.display = 'block';
// Actualizar stats a 0
const statsEl = document.querySelector('#ypp-virtual-stats');
if (statsEl) {
setInnerHTML(statsEl, `
<span>0 ${t('videos')}</span>
<span id="ypp-render-stats"></span>
<span id="ypp-storage-usage"></span>
`);
try { updateStorageUsageIndicator().catch(() => { }); } catch (_) { }
}
return;
}
// Si hay items, asegurar que el mensaje vacío esté oculto y el scroller visible
if (emptyMsg) emptyMsg.style.display = 'none';
if (scrollerEl) scrollerEl.style.display = 'block';
// Si ya existe el scroller y el contenedor en el DOM, solo actualizar items y restaurar scroll
if (virtualScroller && scrollerEl) {
// Actualizar stats sin reconstruir todo
const statsEl = document.querySelector('#ypp-virtual-stats');
if (statsEl) {
setInnerHTML(statsEl, `
<span>${filteredItems.length} ${t('videos')}</span>
<span id="ypp-render-stats"></span>
<span id="ypp-storage-usage"></span>
`);
}
// Ocultar overlay
if (loadingIndicator) loadingIndicator.style.display = 'none';
virtualScroller.updateItems(virtualItems);
// Restaurar scroll de forma segura tras el ciclo de renderizado
requestAnimationFrame(() => {
scrollerEl.scrollTop = currentScrollTop;
});
try { updateStorageUsageIndicator().catch(() => { }); } catch (_) { }
return;
}
// Limpiar indicador de carga o contenido previo si vamos a inicializar
if (loadingIndicator) loadingIndicator.remove();
setInnerHTML(listContainer, '');
// Crear barra de estadísticas
const statsBar = createElement('div', {
className: 'ypp-virtual-stats',
id: 'ypp-virtual-stats'
});
setInnerHTML(statsBar, `
<span>${filteredItems.length} ${t('videos')}</span>
<span id="ypp-render-stats"></span>
<span id="ypp-storage-usage"></span>
`);
listContainer.appendChild(statsBar);
DOMHelpers.removeExact('ui:storageUsage');
try { await updateStorageUsageIndicator(); } catch (_) { }
if (!listContainer) return;
// Crear contenedor para el scroller virtual
const scrollerContainer = createElement('div', {
id: 'ypp-virtual-scroller-container',
styles: {
flexGrow: '1',
overflow: 'auto',
position: 'relative'
}
});
listContainer.appendChild(scrollerContainer);
// Inicializar VirtualScroller
virtualScroller = new VirtualScroller({
container: scrollerContainer,
items: virtualItems,
itemHeight: VIDEO_ITEM_HEIGHT, // Fallback por si acaso
getItemHeight: (item) => {
if (item.type === 'playlist-header') return 40;
if (item.playlistKey) return 140; // Optimizado a 140px
return VIDEO_ITEM_HEIGHT;
},
bufferSize: 8,
renderItem: async (item) => {
if (item.type === 'playlist-header') {
const header = createElement('div', {
className: 'ypp-playlist-header'
});
let playlistUrl = `https://www.youtube.com/playlist?list=${item.playlistKey}`;
// Para Mixes (RD...), YouTube requiere un v=ID válido.
if (item.playlistKey.startsWith('RD') && item.firstVideoId) {
playlistUrl = `https://www.youtube.com/watch?v=${item.firstVideoId}&list=${item.playlistKey}`;
}
setInnerHTML(header, `
<a href="${playlistUrl}" target="_blank" rel="noopener noreferrer">
${SVG_ICONS.playlist} ${item.playlistTitle}
</a>
`);
return header;
}
return await createVideoEntry(item);
},
onRender: () => {
const statsEl = document.querySelector('#ypp-render-stats');
if (statsEl && virtualScroller) {
let totalVideos = 0;
// let renderedVideos = 0;
virtualScroller.items.forEach(i => {
if (i.type !== 'playlist-header') totalVideos++;
});
// virtualScroller.renderedItems.forEach((el, idx) => {
// const item = virtualScroller.items[idx];
// if (item && item.type !== 'playlist-header') {
// renderedVideos++;
// }
// });
// statsEl.textContent = `${renderedVideos}/${totalVideos} ${t('rendered')}`;
// setInnerHTML(statsEl, `${SVG_ICONS.info} ${renderedVideos} ${t('rendered')}`);
// Asegurar que el total principal también esté en sincronía con el scroller
const parentStats = document.querySelector('#ypp-virtual-stats');
if (parentStats && parentStats.firstElementChild) {
parentStats.firstElementChild.textContent = `${totalVideos} ${t('videos')}`;
}
}
}
});
logLog('updateVideoList', `✅ VirtualScroller inicializado con ${filteredItems.length} items`);
}
function closeModalVideos() {
// Destruir VirtualScroller para liberar recursos
if (virtualScroller) {
virtualScroller.destroy();
virtualScroller = null;
}
if (videosOverlay) {
videosOverlay.remove();
videosOverlay = null;
}
if (videosContainer) {
videosContainer.remove();
videosContainer = null;
}
if (listContainer) {
listContainer.remove();
listContainer = null;
}
if (storageUsageRefreshIntervalId) {
clearInterval(storageUsageRefreshIntervalId);
storageUsageRefreshIntervalId = null;
}
isPlaylistCreationMode = false;
isManagementMode = false;
selectedVideos.clear();
document.body.style.overflow = '';
}
/**
* Convierte un número de bytes a una cadena legible con unidades (B, KB, MB, GB, TB).
*
* @param {number|string} bytes - Cantidad de bytes a formatear. Puede ser número o string convertible a número.
* @returns {string} Representación formateada en la unidad más apropiada (ej: "1.23 MB").
*
* @example
* formatBytes(1024); // "1 KB"
* formatBytes(1234567); // "1.18 MB"
* formatBytes(0); // "0 B"
*/
const formatBytes = (bytes) => {
const value = Number(bytes);
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const scaled = value / (1024 ** exponent);
const decimals = exponent === 0 ? 0 : (scaled < 10 ? 2 : 1);
return `${scaled.toFixed(decimals)} ${units[exponent]}`;
};
/**
* Actualiza el indicador de uso de almacenamiento en el DOM.
*
* Busca el elemento con id `#ypp-storage-usage` y muestra el uso actual
* frente al límite disponible usando la API `navigator.storage.estimate()`.
*
* Si la API no está disponible o los valores no son válidos, limpia el contenido.
*
* @async
* @function updateStorageUsageIndicator
* @returns {Promise<void>} No retorna ningún valor.
*
* @example
* await updateStorageUsageIndicator();
* // Resultado esperado en el DOM:
* // "1.23 MB / 2 GB"
*/
const updateStorageUsageIndicator = async () => {
const el = DOMHelpers.get('ui:storageUsage', () => document.querySelector('#ypp-storage-usage'), 500);
if (!el) return;
const estimateFn = navigator?.storage?.estimate;
if (typeof estimateFn !== 'function') {
el.textContent = '';
return;
}
const { usage, quota } = await estimateFn.call(navigator.storage);
if (!Number.isFinite(usage) || !Number.isFinite(quota) || quota <= 0) {
el.textContent = '';
return;
}
el.textContent = `${formatBytes(usage)} / ${formatBytes(quota)}`;
};
// ------------------------------------------
// MARK: 🔘 Floating Button
// ------------------------------------------
const createFloatingButton = async () => {
const settings = cachedSettings || await Settings.get();
if (!settings.showFloatingButtons) return;
const wrapper = createElement('div', { className: 'ypp-floatingBtnContainer' });
const btnConfig = createElement('div', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
html: `${SVG_ICONS.settings} ${t('youtubePlaybackPlox')}`,
onClickEvent: async () => { await showSettingsUI(); }
});
wrapper.appendChild(btnConfig);
document.body.appendChild(wrapper);
const updateVisibility = () => {
const isFullscreen = !!document.fullscreenElement;
wrapper.style.display = isFullscreen ? 'none' : 'flex';
};
document.addEventListener('fullscreenchange', updateVisibility);
window.addEventListener('yt-navigate-finish', updateVisibility);
updateVisibility();
};
// ------------------------------------------
// MARK: 📂 Show Saved Videos List
// ------------------------------------------
async function showSavedVideosList() {
// Siempre cerrar el modal existente para asegurar un estado limpio
closeModalVideos();
// Cargar filtros guardados para asegurar sincronización
const savedFilters = await Filters.get();
// Usar los filtros pasados como parámetro o los guardados
currentOrderBy = savedFilters.orderBy ?? CONFIG.defaultFilters.orderBy;
currentFilterBy = savedFilters.filterBy ?? CONFIG.defaultFilters.filterBy;
currentSearchQuery = savedFilters.searchQuery ?? CONFIG.defaultFilters.searchQuery;
currentMinViews = savedFilters.minViews ?? CONFIG.defaultFilters.minViews;
currentMaxViews = savedFilters.maxViews ?? CONFIG.defaultFilters.maxViews;
currentMinPercent = savedFilters.minPercent ?? CONFIG.defaultFilters.minPercent;
currentMaxPercent = savedFilters.maxPercent ?? CONFIG.defaultFilters.maxPercent;
// Crear elementos del modal
videosOverlay = createElement('div', { className: 'ypp-modalOverlay' });
videosContainer = createElement('div', { className: 'ypp-videosContainer' });
listContainer = createElement('div', { id: 'video-list-container' });
setupModalEventDelegation(listContainer);
const header = createElement('div', { className: 'ypp-header' });
const title = createElement('h1', {
className: 'ypp-modalTitle',
html: `${SVG_ICONS.clockRotateLeft} ${t('youtubePlaybackPlox')} <span class="ypp-modalTitle-version">v${SCRIPT_VERSION}</span>`
});
const closeBtn = createElement('button', {
className: 'ypp-btn ypp-btn-small ypp-btn-close',
html: SVG_ICONS.close,
atribute: { 'aria-label': t('close') },
onClickEvent: closeModalVideos
});
header.appendChild(title);
header.appendChild(closeBtn);
videosContainer.appendChild(header);
// Persistent Top Row: Search + Advanced Toggle
const topRow = createElement('div', { className: 'ypp-filters-top-row' });
const searchContainer = createElement('div', { className: 'ypp-search-container' });
searchContainer.appendChild(createSearchInput(currentSearchQuery, async (query) => {
currentSearchQuery = query;
await Filters.set({ searchQuery: query });
await updateVideoList();
}));
const advancedToggleBtn = createElement('button', {
className: 'ypp-filters-toggle-btn',
html: `${SVG_ICONS.settings} ${t('advancedFilters')}`
});
const filterBadge = createElement('span', { className: 'ypp-active-filter-badge', style: 'display: none;' });
advancedToggleBtn.appendChild(filterBadge);
topRow.appendChild(searchContainer);
topRow.appendChild(advancedToggleBtn);
videosContainer.appendChild(topRow);
// Collapsible Advanced Section
const advancedSection = createElement('div', { className: 'ypp-filters-advanced' });
const filtersGrid = createElement('div', { className: 'ypp-filters-grid' });
filtersGrid.appendChild(createSortSelector(currentOrderBy, async (selected) => {
currentOrderBy = selected;
await Filters.set({ orderBy: selected });
updateActiveFilterBadge();
await updateVideoList();
}));
filtersGrid.appendChild(createFilterSelector(currentFilterBy, async (selected) => {
currentFilterBy = selected;
await Filters.set({ filterBy: selected });
updateActiveFilterBadge();
await updateVideoList();
}));
advancedSection.appendChild(filtersGrid);
// Range Filters group
const rangeGroup = createElement('div', { className: 'ypp-range-filters-group' });
rangeGroup.appendChild(createRangeFilter('views', currentMinViews, currentMaxViews,
async (min, max) => {
currentMinViews = min;
currentMaxViews = max;
await Filters.set({ minViews: min, maxViews: max });
updateActiveFilterBadge();
await updateVideoList();
}
));
rangeGroup.appendChild(createRangeFilter('percent', currentMinPercent, currentMaxPercent,
async (min, max) => {
currentMinPercent = min;
currentMaxPercent = max;
await Filters.set({ minPercent: min, maxPercent: max });
updateActiveFilterBadge();
await updateVideoList();
}
));
advancedSection.appendChild(rangeGroup);
videosContainer.appendChild(advancedSection);
// Toggle logic for Advanced Filters
let isAdvancedExpanded = false;
const toggleAdvanced = (expand) => {
isAdvancedExpanded = expand !== undefined ? expand : !isAdvancedExpanded;
advancedSection.classList.toggle('expanded', isAdvancedExpanded);
advancedToggleBtn.classList.toggle('active', isAdvancedExpanded);
updateActiveFilterBadge();
};
advancedToggleBtn.addEventListener('click', () => toggleAdvanced());
// Function to calculate and update the active filter badge
const updateActiveFilterBadge = () => {
let activeCount = 0;
if (currentOrderBy !== CONFIG.defaultFilters.orderBy) activeCount++;
if (currentFilterBy !== CONFIG.defaultFilters.filterBy) activeCount++;
if (currentMinViews !== CONFIG.defaultFilters.minViews || currentMaxViews !== CONFIG.defaultFilters.maxViews) activeCount++;
if (currentMinPercent !== CONFIG.defaultFilters.minPercent || currentMaxPercent !== CONFIG.defaultFilters.maxPercent) activeCount++;
if (activeCount > 0 && !isAdvancedExpanded) {
filterBadge.textContent = t('activeFilters', { count: activeCount });
filterBadge.style.display = 'flex';
filterBadge.title = t('activeFilters', { count: activeCount });
} else {
filterBadge.style.display = 'none';
}
};
// Initial badge update
updateActiveFilterBadge();
videosContainer.appendChild(listContainer);
try {
if (!storageUsageRefreshIntervalId) {
storageUsageRefreshIntervalId = setInterval(() => {
updateStorageUsageIndicator().catch(() => { });
}, 60_000);
}
} catch (_) { }
const footer = createElement('div', { className: 'ypp-footer' });
// Primera fila: Botones de exportación/importación
const firstRow = createElement('div', { className: 'ypp-footer-row' });
const btnExport = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
html: `${SVG_ICONS.upload} ${t('export')} (JSON)`,
onClickEvent: async () => await exportDataToFile()
});
const btnImport = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
html: `${SVG_ICONS.download} ${t('import')} (JSON)`,
onClickEvent: async () => await importDataFromFile()
});
const btnExportFreeTube = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
html: `${SVG_ICONS.upload} ${t('export')} (FreeTube)`,
onClickEvent: async () => await exportToFreeTube()
});
const btnImportFreeTube = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
html: `${SVG_ICONS.download} ${t('import')} (FreeTube)`,
onClickEvent: async () => await importFromFreeTube()
});
firstRow.appendChild(btnExport);
firstRow.appendChild(btnImport);
firstRow.appendChild(btnExportFreeTube);
firstRow.appendChild(btnImportFreeTube);
// Segunda fila: Eliminar todo (izquierda) y Configuraciones (derecha)
const secondRow = createElement('div', { className: 'ypp-footer-row ypp-footer-row-bottom' });
const btnToggleManagement = createElement('button', {
id: 'ypp-management-mode-btn',
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
html: `${SVG_ICONS.folder} ${t('manageVideos')}`,
onClickEvent: async () => { await toggleManagementMode(); }
});
const btnCreatePlaylist = createElement('button', {
id: 'ypp-create-playlist-btn',
className: 'ypp-btn ypp-btn-primary ypp-shadow-md',
html: `${SVG_ICONS.playlist} ${t('createPlaylist')}`,
onClickEvent: async () => { await togglePlaylistCreationMode(); }
});
const btnSettings = createElement('button', {
id: 'ypp-settings-btn',
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
html: `${SVG_ICONS.settings} ${t('settings')}`,
onClickEvent: async () => { await showSettingsUI(); }
});
secondRow.appendChild(btnToggleManagement);
secondRow.appendChild(btnCreatePlaylist);
createPlaylistBtn = btnCreatePlaylist; // Guardar referencia global para evitar llamadas al DOM
secondRow.appendChild(btnSettings);
// Área de creación de playlist integrada
const playlistArea = createElement('div', {
className: 'ypp-playlist-creation-area',
id: 'ypp-playlist-area'
});
const playlistTitle = createElement('h4', {
html: `${SVG_ICONS.playlist} ${t('playlistLinkGenerated')}`,
styles: { marginBottom: '10px' }
});
const playlistInfo = createElement('p', {
id: 'ypp-playlist-info',
text: `${t('selectedVideos')}: 0`,
styles: { marginBottom: '10px', fontWeight: 'bold' }
});
const playlistTextarea = createElement('textarea', {
className: 'ypp-playlist-textarea',
id: 'ypp-playlist-textarea',
atribute: {
readonly: true,
rows: 2,
placeholder: t('playlistLinkGenerated')
}
});
const playlistActions = createElement('div', {
className: 'ypp-playlist-actions'
});
const copyBtn = createElement('button', {
className: 'ypp-btn ypp-btn-primary ypp-shadow-md',
html: `${SVG_ICONS.copy} ${t('copyLink')}`,
id: 'ypp-copy-playlist-btn',
onClickEvent: copyPlaylistLink
});
const openBtn = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
html: `${t('openPlaylist')} ${SVG_ICONS.externalLink}`,
id: 'ypp-open-playlist-btn',
onClickEvent: openPlaylistLink
});
const cancelBtn = createElement('button', {
className: 'ypp-btn ypp-btn-danger ypp-shadow-md',
html: `${SVG_ICONS.close} ${t('cancel')}`,
onClickEvent: async () => { await togglePlaylistCreationMode(); }
});
playlistActions.appendChild(copyBtn);
playlistActions.appendChild(openBtn);
playlistActions.appendChild(cancelBtn);
playlistArea.appendChild(playlistTitle);
playlistArea.appendChild(playlistInfo);
playlistArea.appendChild(playlistTextarea);
playlistArea.appendChild(playlistActions);
footer.appendChild(firstRow);
footer.appendChild(secondRow);
footer.appendChild(playlistArea);
// Guardar referencia global para evitar llamadas al DOM
playlistInfoEl = playlistInfo
playlistTextareaEl = playlistTextarea
modalVideosFooterFirtsRow = firstRow
modalVideosFooterSecondRow = secondRow
playlistContainer = playlistArea
videosContainer.appendChild(footer);
videosOverlay.addEventListener('click', (e) => {
if (e.target === videosOverlay) closeModalVideos();
});
document.body.appendChild(videosOverlay);
document.body.appendChild(videosContainer);
// Actualizar la lista de videos con los filtros actuales
await updateVideoList();
}
// ------------------------------------------
// MARK: 📂 Video Entry
// ------------------------------------------
/**
* Genera un color único basado en el hash de una cadena
* @param {string} str - Cadena para generar el color
* @returns {string} Color en formato HSL
*/
function generatePlaylistColor(str) {
if (!str) return 'var(--ypp-bg-secondary)';
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
// Generar un tono entre 0-360, con saturación y luminosidad fijas para consistencia
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 45%, 95%)`; // Colores suaves para el fondo
}
/**
* Genera un color de borde más intenso para la playlist
* @param {string} str - Cadena para generar el color
* @returns {string} Color en formato HSL
*/
function generatePlaylistBorderColor(str) {
if (!str) return 'var(--ypp-border)';
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 60%, 70%)`; // Color más intenso para el borde
}
async function handleForceTimeAction(videoId, playlistKey) {
let itemData = null;
let isOldFormat = false;
if (playlistKey) {
itemData = await Storage.get(playlistKey);
isOldFormat = itemData?.videos && typeof itemData.videos === 'object';
}
let info = null;
if (isOldFormat) {
info = itemData.videos[videoId];
} else {
info = await Storage.get(videoId);
}
if (!info) {
logWarn('handleForceTimeAction', `No se encontró información para el video ${videoId}`);
return;
}
let duration = normalizeSeconds(info.lengthSeconds) || 0;
let promptText = info.forceResumeTime
? `${t('enterStartTimeOrEmpty')}:`
: `${t('enterStartTime')}:`;
if (duration > 0) {
promptText += `\n[0 - ${formatTime(duration)}]`;
}
const timeStr = prompt(promptText, info.forceResumeTime ? formatTime(normalizeSeconds(info.forceResumeTime)) : '');
if (timeStr === null) return;
const timeSec = parseTimeToSeconds(timeStr);
if (timeSec > 0 && duration > 0 && timeSec >= duration) {
showFloatingToast(`${SVG_ICONS.warning} ${t('invalidFormat')}`);
return;
}
if (timeSec > 0) {
info.forceResumeTime = timeSec;
showFloatingToast(`${SVG_ICONS.check} ${t('startTimeSet')} ${formatTime(normalizeSeconds(timeSec))}`);
} else {
delete info.forceResumeTime;
showFloatingToast(`${SVG_ICONS.unlocked} ${t('fixedTimeRemoved')}`);
}
if (playlistKey && isOldFormat) {
itemData.videos[videoId] = info;
await Storage.set(playlistKey, itemData);
} else {
await Storage.set(videoId, info);
}
// Sincronizar UI de reproducción activa
syncFixedTimeUI(videoId, !!info.forceResumeTime, info.forceResumeTime);
await updateVideoList();
}
async function handleUnlinkPlaylistAction(videoId) {
if (!confirm(t('confirmRemoveFromPlaylist'))) return;
const data = await Storage.get(videoId);
if (data) {
data.lastViewedPlaylistId = null;
data.lastViewedPlaylistType = '';
data.lastViewedPlaylistItemId = null;
await Storage.set(videoId, data);
showFloatingToast(`${SVG_ICONS.check} ${t('playlistAssociationRemoved')}`);
await updateVideoList();
}
}
async function handleDeleteEntryAction(videoId, playlistKey, titleCache) {
const title = escapeHTML(titleCache);
// Cargar info original por si deshace
let itemInfo = null;
if (playlistKey) {
const playlist = await Storage.get(playlistKey);
if (playlist?.videos && playlist.videos[videoId]) {
itemInfo = playlist.videos[videoId];
} else {
itemInfo = await Storage.get(videoId);
}
} else {
itemInfo = await Storage.get(videoId);
}
if (itemInfo?.isProtected) {
showFloatingToast(`${SVG_ICONS.warning} ${t('protectedVideoWarning')}`);
return;
}
const deleteFromStorage = async () => {
if (playlistKey) {
const playlist = await Storage.get(playlistKey);
if (playlist?.videos && typeof playlist.videos === 'object') {
if (playlist.videos[videoId]) {
delete playlist.videos[videoId];
Object.keys(playlist.videos).length
? await Storage.set(playlistKey, playlist)
: await Storage.del(playlistKey);
}
} else {
await Storage.del(videoId);
}
} else {
await Storage.del(videoId);
}
};
const undoDelete = async () => {
if (!itemInfo) return;
if (playlistKey) {
const playlist = await Storage.get(playlistKey);
if (playlist && playlist.videos && typeof playlist.videos === 'object') {
playlist.videos[videoId] = itemInfo;
await Storage.set(playlistKey, playlist);
} else {
await Storage.set(videoId, itemInfo);
}
} else {
await Storage.set(videoId, itemInfo);
}
await updateVideoList();
// Restaurar estado de tiempo fijo en UI si existía
syncFixedTimeUI(videoId, !!itemInfo.forceResumeTime, itemInfo.forceResumeTime);
};
await deleteFromStorage();
// Limpiar estado de tiempo fijo en UI si el video estaba activo
syncFixedTimeUI(videoId, false);
syncManualSaveUI(videoId, false);
await updateVideoList();
showFloatingToast(`${SVG_ICONS.trash} "${title}" ${t('deleted')}`, 10000, {
action: {
label: t('undo'),
callback: undoDelete
}
});
}
const setupModalEventDelegation = (container) => {
container.addEventListener('click', async (e) => {
if (e.target.matches('.ypp-video-checkbox')) {
e.stopPropagation();
const videoId = e.target.dataset.videoId;
if (videoId) toggleVideoSelection(videoId);
return;
}
const btn = e.target.closest('[data-action]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const action = btn.dataset.action;
const item = e.target.closest('.ypp-video-item');
if (!item) return;
const videoId = item.dataset.videoId;
const playlistKey = item.dataset.playlistKey || null;
switch (action) {
case 'force-time':
await handleForceTimeAction(videoId, playlistKey);
break;
case 'unlink-playlist':
await handleUnlinkPlaylistAction(videoId);
break;
case 'delete-entry':
const title = btn.dataset.title;
await handleDeleteEntryAction(videoId, playlistKey, title);
break;
case 'toggle-protection':
await handleToggleProtectionAction(videoId, playlistKey);
break;
}
});
};
async function handleToggleProtectionAction(videoId, playlistKey) {
let itemData = null;
let isOldFormat = false;
if (playlistKey) {
itemData = await Storage.get(playlistKey);
isOldFormat = itemData?.videos && typeof itemData.videos === 'object';
}
let info = null;
if (isOldFormat) {
info = itemData.videos[videoId];
} else {
info = await Storage.get(videoId);
}
if (!info) {
logWarn('handleToggleProtectionAction', `No se encontró información para el video ${videoId}`);
return;
}
info.isProtected = !info.isProtected;
if (playlistKey && isOldFormat) {
itemData.videos[videoId] = info;
await Storage.set(playlistKey, itemData);
} else {
await Storage.set(videoId, info);
}
const msg = info.isProtected
? `${SVG_ICONS.locked} ${t('protected')}`
: `${SVG_ICONS.unlocked} ${t('unprotected')}`;
showFloatingToast(msg);
await updateVideoList();
}
/* Cache para URLs de miniaturas validadas para evitar re-validaciones durante el scroll */
const thumbUrlCache = new Map();
/**
* Valida de forma asíncrona la mejor miniatura disponible para un video.
* YouTube devuelve una imagen de 120px en lugar de 404 para archivos inexistentes.
* @param {string} videoId - ID del video.
* @returns {Promise<string>} - URL de la mejor miniatura validada.
*/
async function getValidatedThumbnail(videoId) {
if (!videoId) return '';
if (thumbUrlCache.has(videoId)) return thumbUrlCache.get(videoId);
const candidates = [
`https://i.ytimg.com/vi_webp/${videoId}/maxresdefault.webp`,
`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
`https://i.ytimg.com/vi/${videoId}/hq720.jpg`,
`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
];
for (const url of candidates) {
try {
const isOk = await new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img.naturalWidth > 120);
img.onerror = () => resolve(false);
img.src = url;
// Timeout de seguridad para no bloquear el scroll indefinidamente
setTimeout(() => resolve(false), 1000);
});
if (isOk) {
thumbUrlCache.set(videoId, url);
return url;
}
} catch (_) { /* continue */ }
}
const fallback = candidates[candidates.length - 1];
thumbUrlCache.set(videoId, fallback);
return fallback;
}
async function createVideoEntry(item) {
const { info, playlistKey = null, playlistTitle = null } = item;
const {
videoId,
title,
author,
authorId,
published,
description,
watchProgress,
lengthSeconds,
timeWatched,
type,
viewCount,
isLive,
isCompleted,
lastViewedPlaylistId,
lastViewedPlaylistType,
lastViewedPlaylistItemId,
forceResumeTime
} = info;
const remaining = Math.max(lengthSeconds - watchProgress, 0);
const percent = lengthSeconds ? Math.min(100, Math.round((watchProgress / lengthSeconds) * 100)) : null;
const isPlaylistItem = !!playlistKey;
const finalPlaylistTitle = escapeHTML(playlistTitle || playlistKey || '');
const isLiveEntry = type === 'live' || isLive === true;
const viewsText = `${escapeHTML(viewCount.toLocaleString())} ${t('views')}`;
const playlistUrl =
escapeHTML(playlistKey?.startsWith('RD')
? `https://www.youtube.com/watch?v=${videoId}&list=${playlistKey}`
: `https://www.youtube.com/playlist?list=${playlistKey}`);
const hasFixedTime = forceResumeTime > 0;
const isProtected = info.isProtected || false;
const fixedTimeStr =
hasFixedTime
? `${SVG_ICONS.timer} ${t('alwaysStartFrom')}: ${formatTime(normalizeSeconds(forceResumeTime))} ${SVG_ICONS.locked}`
: '';
let timestampClass =
isCompleted
? 'completed'
: (hasFixedTime ? 'forced' : 'progress');
let timestampText =
isCompleted
? (hasFixedTime ? `${fixedTimeStr} ${SVG_ICONS.check}` : `${SVG_ICONS.check} ${t('completed')}`)
: (hasFixedTime ? fixedTimeStr : `${t('progress')} ${escapeHTML(formatTime(watchProgress))} ${isLiveEntry ? '' : `/ ${formatTime(lengthSeconds)}`}`);
const liveHtml = `<div class="ypp-progressInfo" style="font-weight: bold;">${SVG_ICONS.chart} ${t('live')}</div>`;
const percentHtml = `<div class="ypp-progressInfo" style="color: ${getProgressColor(percent)}; font-weight: bold;">${SVG_ICONS.chart} ${percent} ${t('percentWatched')} (${formatTime(normalizeSeconds(remaining))} ${t('remaining')})</div>`;
let progressHtml = '';
if (!isCompleted) {
if (isLiveEntry) progressHtml = liveHtml;
else if (percent !== null) progressHtml = percentHtml;
}
// Obtener la mejor miniatura validada asíncronamente
const validatedThumbUrl = await getValidatedThumbnail(videoId);
// Estilos playlist
let wrapperStyle = '';
let playlistBorderColor = '';
let itemClass = 'regular-item';
if (isPlaylistItem) {
const playlistBgColor = generatePlaylistColor(playlistKey);
playlistBorderColor = generatePlaylistBorderColor(playlistKey);
wrapperStyle = `background-color: ${playlistBgColor}; border-left: 4px solid ${playlistBorderColor}; position: relative;`;
itemClass = 'playlist-item';
}
if (isProtected) {
itemClass += ' ypp-protected-item';
}
const selectionClass = isPlaylistCreationMode || isManagementMode ? 'selection-mode' : '';
const html = `
<div
class="ypp-videoWrapper ${itemClass} ${selectionClass} ypp-video-item"
data-video-id="${escapeHTML(videoId)}"
${playlistKey ? `data-playlist-key="${escapeHTML(playlistKey)}"` : ''}
style="${wrapperStyle}"
>
${isPlaylistCreationMode || isManagementMode ? `<input type="checkbox" data-action="toggle-selection" class="ypp-video-checkbox" data-video-id="${escapeHTML(videoId)}" ${selectedVideos.has(videoId) ? 'checked' : ''}>` : ''}
<img class="${type === 'shorts' ? 'ypp-thumb-shorts' : 'ypp-thumb'}" title="${title}" alt="${title}" src="${validatedThumbUrl}" loading="lazy" width="320" height="180" draggable="false">
<div class="ypp-infoDiv">
<a class="ypp-titleLink" title="${title}" href="https://www.youtube.com/watch?v=${escapeHTML(videoId)}${playlistKey ? '&list=' + escapeHTML(playlistKey) : ''}" target="_blank" rel="noopener noreferrer">
${title} ${SVG_ICONS.externalLink}
</a>
${isPlaylistItem && finalPlaylistTitle ? `
<div class="ypp-playlist-indicator ypp-shadow-md" title="${t('playlist')}: ${finalPlaylistTitle} (${escapeHTML(playlistKey)})" style="color: ${playlistBorderColor};">
<a class="ypp-playlist-link" title="${t('openPlaylist')}: ${finalPlaylistTitle}" href="${playlistUrl}" target="_blank" rel="noopener noreferrer">
${SVG_ICONS.playlist} ${finalPlaylistTitle} ${SVG_ICONS.externalLink}
</a>
</div>
` : ''}
${authorId ? `
<a class="ypp-author ypp-author-link" title="${t('openChannel')}: ${author}" href="https://www.youtube.com/channel/${escapeHTML(authorId)}" target="_blank" rel="noopener noreferrer">${author} ${SVG_ICONS.externalLink}</a>
` : `<div class="ypp-author">${author}</div>`}
<div class="ypp-views">
${viewsText}
${(() => {
if (!info.completionHistory?.length) return '';
const history = info.completionHistory;
const limit = 10;
const recent = history.slice(-limit).reverse();
const hasMore = history.length > limit;
let tooltip = `${t('watchedHistory')}:\n` +
recent.map(ts => new Date(ts).toLocaleString().replace(',', '')).join('\n');
if (hasMore) tooltip += `\n... (+${history.length - limit})`;
return `<span class="ypp-watched-count" title="${escapeHTML(tooltip)}"> [${SVG_ICONS.check} ${t('watchedCount', { count: history.length }, 'Watched ' + history.length + ' times')}]</span>`;
})()}
</div>
<div class="ypp-timestamp ${timestampClass}">${timestampText}</div>
${progressHtml}
</div>
<div class="ypp-containerButtonsTime">
${!isLiveEntry ? `
<button class="ypp-btn ypp-btn-outlined ypp-btn-small ypp-shadow-md" data-action="force-time" title="${hasFixedTime ? t('changeOrRemoveStartTime', { time: formatTime(normalizeSeconds(info.forceResumeTime)) }) : t('setStartTime')}">
${SVG_ICONS.timer}
</button>
` : ''}
${lastViewedPlaylistId ? `
<button class="ypp-btn ypp-btn-small ypp-shadow-md" data-action="unlink-playlist" title="${t('removeFromPlaylist')}">
${SVG_ICONS.playlistRemove}
</button>
` : ''}
<button class="ypp-btn ${isProtected ? 'ypp-btn-danger' : 'ypp-btn-outlined'} ypp-btn-small ypp-shadow-md" data-action="toggle-protection" title="${isProtected ? t('unprotect') : t('protect')}">
${isProtected ? SVG_ICONS.locked : SVG_ICONS.unlocked}
</button>
<button class="ypp-btn ypp-btn-delete ypp-btn-small ypp-shadow-md" data-action="delete-entry" title="${t('deleteEntry')}" data-title="${title}">
${SVG_ICONS.trash}
</button>
</div>
</div>
`;
return html;
}
// ------------------------------------------
// MARK: 🗑️ Clear All Data
// ------------------------------------------
let clearedData = null; // Para almacenar datos eliminados y poder deshacer
async function clearAllData() {
// Verificar si hay datos relevantes antes de pedir confirmación
const videoKeys = await Storage.keys();
if (videoKeys.length === 0) {
showFloatingToast(`${SVG_ICONS.warning} ${t('noSavedVideos')}`);
return;
}
if (!confirm(t('clearAllDataConfirm'))) return;
// Guardar datos para posible deshacer
const allKeys = await Storage.keys();
clearedData = {};
for (const k of allKeys) {
const data = await Storage.get(k);
if (data?.isProtected) continue;
clearedData[k] = data;
}
logLog('clearAllData', '🗑️ Datos a eliminar:', Object.keys(clearedData));
const skippedProtected = allKeys.length - Object.keys(clearedData).length;
// Eliminar todos los datos (excepto protegidos)
for (const k of Object.keys(clearedData)) {
await Storage.del(k);
syncManualSaveUI(k, false);
}
// Mostrar toast con opción de deshacer
showFloatingToast(`${SVG_ICONS.check} ${t('allDataCleared')}${skippedProtected > 0 ? ` (${t('protectedItemsSkipped', { count: skippedProtected })})` : ''}`, {
keep: true,
action: {
label: t('undo'),
callback: undoClearAll
}
});
// Actualizar UI si es necesario
await updateVideoList();
}
async function undoClearAll() {
if (!clearedData || Object.keys(clearedData).length === 0) {
showFloatingToast(`${SVG_ICONS.trash} ${t('noDataToRestore')}`);
return;
}
logLog('undoClearAll', '⏪ Restaurando datos:', clearedData);
// Restaurar todos los datos
for (const [key, value] of Object.entries(clearedData)) {
await Storage.set(key, value);
syncManualSaveUI(key, true);
}
// Limpiar referencia
clearedData = null;
// Actualizar UI
await updateVideoList();
}
// ------------------------------------------
// MARK: ⚙️ Menu Commands
// ------------------------------------------
// Función para registrar los comandos del menú con traducciones
function registerMenuCommands() {
GM_registerMenuCommand(`⚙️ ${t('settings')}`, async () => {
try {
if (!document || !document.body) {
setTimeout(() => { try { showSettingsUI(); } catch (_) { } }, 0);
} else {
await showSettingsUI();
}
} catch (e) { logError('registerMenuCommands', 'Error abriendo Settings UI:', e); }
});
/* GM_registerMenuCommand(`📋 ${t('savedVideos')}`, () => { try { showSavedVideosList(); } catch (_) { } }); */
GM_registerMenuCommand(`📚 ${t('viewAllHistory')}`, async () => {
// Guardar filtros y esperar a que se complete
await Filters.set({ filterBy: 'all', searchQuery: '' });
// Establecer filtro global y mostrar lista
currentFilterBy = 'all';
try { showSavedVideosList(); } catch (e) { logError('registerMenuCommands', 'Error abriendo listado de historial:', e); }
});
GM_registerMenuCommand(`✅ ${t('viewCompletedVideos')}`, async () => {
await Filters.set({ filterBy: 'completed' });
currentFilterBy = 'completed';
try { showSavedVideosList(); } catch (e) { logError('registerMenuCommands', 'Error abriendo listado de completados:', e); }
});
}
// ------------------------------------------
// MARK: 🔄 Migración de Datos
// ------------------------------------------
/**
* Normaliza un valor de videoType para corregir inconsistencias entre versiones.
* Convierte valores legacy ('watch', 'regular', 'short') al esquema actual.
* @param {string|undefined} rawType - Tipo original del video
* @returns {string} Tipo normalizado: 'video', 'shorts', 'live', 'preview_watch', 'preview_shorts'
*/
function normalizeVideoType(rawType) {
if (!rawType || typeof rawType !== 'string') return 'video';
const type = rawType.trim().toLowerCase();
// Mapa de conversión de tipos legacy
const LEGACY_TYPE_MAP = {
'watch': 'video',
'regular': 'video',
'normal': 'video',
'short': 'shorts',
};
if (LEGACY_TYPE_MAP[type]) return LEGACY_TYPE_MAP[type];
// Tipos válidos actuales: devolver tal cual
const VALID_TYPES = new Set(['video', 'shorts', 'live', 'preview_watch', 'preview_shorts']);
if (VALID_TYPES.has(type)) return type;
// Fallback seguro
return 'video';
}
/**
* Limpia datos que no son de video en IndexedDB y realiza migraciones persistentes.
* Consolida el rescate de localStorage/GM_setValue y normalización del esquema de vídeos.
*/
async function cleanupNonVideoData() {
const MIGRATION_VERSION = 5;
const MIGRATION_KEY = CONFIG.STORAGE_KEYS.migration;
try {
// --- 0. Saneamiento profundo de GM_setValue ---
// Borramos banderas legacy y unificamos
if (typeof GM_getValue === 'function' && typeof GM_setValue === 'function') {
const legacyGMFlag = await GM_getValue('ypp_migration_freetube_format_version');
if (legacyGMFlag !== undefined && legacyGMFlag !== null) {
const currentMigration = await GM_getValue(MIGRATION_KEY, 0);
await GM_setValue(MIGRATION_KEY, Math.max(currentMigration, parseInt(legacyGMFlag, 10) || 0));
if (typeof GM_deleteValue === 'function') {
try { await GM_deleteValue('ypp_migration_freetube_format_version'); } catch (_) { }
} else {
await GM_setValue('ypp_migration_freetube_format_version', null);
}
logInfo('cleanupNonVideoData', `🚚 Flag legacy de GM migrado: ypp_migration_freetube_format_version`);
}
// Rescate de configuraciones huérfanas y purgas seguras
if (typeof GM_listValues === 'function' && typeof GM_deleteValue === 'function') {
try { await GM_deleteValue('YT_PLAYBACK_PLOX_idb_migrated'); } catch (_) { }
try { await GM_deleteValue('YT_PLAYBACK_PLOX_translations_cache_v1'); } catch (_) { }
const gmKeys = await GM_listValues();
const videoKeysGM = (Array.isArray(gmKeys) ? gmKeys : []).filter(k =>
typeof k === 'string' && k.startsWith('ypp_')
);
for (const gk of videoKeysGM) {
try { await GM_deleteValue(gk); } catch (_) { }
logInfo('cleanupNonVideoData', `🧹 Clave GM legacy purgada: ${gk}`);
}
}
}
// --- 1. Saneamiento de metadatos en IndexedDB ---
const allIDBKeys = await StorageAsync.keys();
const nonVideoIDBKeys = allIDBKeys.filter(k => isNonVideoStorageKey(k));
if (nonVideoIDBKeys.length > 0) {
logInfo('cleanupNonVideoData', `📦 Procesando ${nonVideoIDBKeys.length} metadatos huerfanos en IndexedDB...`);
for (const key of nonVideoIDBKeys) {
if (key.startsWith('playlist_meta_')) {
await StorageAsync.del(key);
continue;
}
const data = await StorageAsync.get(key);
if (data !== null) {
// Rescatamos el flag IDB directamente al flag consolidado
if (key === 'ypp_migration_freetube_format_version') {
const currentMigration = await GM_getValue(MIGRATION_KEY, 0);
await GM_setValue(MIGRATION_KEY, Math.max(currentMigration, parseInt(data, 10) || 0));
} else if (key === 'idb_migrated' || key === 'idb_migrated_v1' || key === 'YT_PLAYBACK_PLOX_idb_migrated') {
// Purgarlo silenciosamente, ya es obsoleto
} else {
const gmKey = key.startsWith('YT_PLAYBACK_PLOX_') ? key : 'YT_PLAYBACK_PLOX_' + key;
// Convertir strings de JSON a objetos nativos para que GM_setValue los maneje correctamente
let dataToSave = data;
if (typeof data === 'string') {
try { dataToSave = JSON.parse(data); } catch (e) { }
}
await GM_setValue(gmKey, dataToSave);
}
await StorageAsync.del(key);
logInfo('cleanupNonVideoData', `🚚 Metadato IDB migrado a GM: ${key}`);
}
}
}
// --- 2. Saneamiento de localStorage (Barrido Profundo) ---
if (typeof localStorage !== 'undefined') {
const lsKeys = Object.keys(localStorage);
for (const key of lsKeys) {
// Detectar cualquier clave que pertenezca al script (prefijo antiguo o legacy ypp_)
if (key.startsWith('YT_PLAYBACK_PLOX_') || key.startsWith('ypp_')) {
const normalized = key.startsWith('YT_PLAYBACK_PLOX_')
? key.slice('YT_PLAYBACK_PLOX_'.length)
: key;
logInfo('cleanupNonVideoData', `🧹 Clave LS legacy purgada: ${key}`);
if (normalized === 'idb_migrated' || normalized === 'idb_migrated_v1') {
localStorage.removeItem(key);
try { await GM_deleteValue('YT_PLAYBACK_PLOX_idb_migrated'); } catch (_) { }
continue;
}
if (normalized === 'migration_freetube_format_version') {
const val = localStorage.getItem(key);
const currentMigration = await GM_getValue(MIGRATION_KEY, 0);
await GM_setValue(MIGRATION_KEY, Math.max(currentMigration, parseInt(val, 10) || 0));
localStorage.removeItem(key);
continue;
}
if (normalized === 'translations_cache_v1') {
localStorage.removeItem(key);
continue;
}
if (isNonVideoStorageKey(normalized)) {
const raw = localStorage.getItem(key);
if (raw) {
const gmKey = key.startsWith('YT_PLAYBACK_PLOX_') ? key : 'YT_PLAYBACK_PLOX_' + key;
let dataToSave = raw;
try { dataToSave = JSON.parse(raw); } catch (e) { }
await GM_setValue(gmKey, dataToSave);
localStorage.removeItem(key);
logInfo('cleanupNonVideoData', `🚚 Cache/Config LS migrado a GM: ${key}`);
}
} else {
// Rescatar vídeos olvidados de localStorage hacia IndexedDB
const raw = localStorage.getItem(key);
if (raw) {
try {
const parsed = JSON.parse(raw);
const normalizedId = key.startsWith('YT_PLAYBACK_PLOX_') ? key.slice('YT_PLAYBACK_PLOX_'.length) : key;
const normData = normalizeVideoData(parsed, normalizedId);
await StorageAsync.set(normalizedId, normData);
localStorage.removeItem(key);
logInfo('cleanupNonVideoData', `🚚 Vídeo rescatado de LS: ${normalizedId}`);
} catch (_) { }
}
}
}
}
}
// --- 3. Normalización Estructural de IndexedDB ---
const lastMigrationVersion = await GM_getValue(MIGRATION_KEY, 0);
if (lastMigrationVersion < MIGRATION_VERSION && allIDBKeys.length > 0) {
logInfo('cleanupNonVideoData', `🔄 Iniciando normalización estructural de IDB a v${MIGRATION_VERSION}...`);
let doBackup = true;
try {
doBackup = confirm(t('youtubePlaybackPlox') + ":" + "\n\n" + t('migrationBackupPrompt') + "\n\n" + t('askDownloadBackupPreMigration'));
} catch (e) {
logError('cleanupNonVideoData', 'Error displaying backup prompt', e);
}
if (doBackup) {
try {
logInfo('cleanupNonVideoData', `📥 Iniciando exportación pre-migración...`);
await exportDataToFile(null, 'PRE-MIGRATION');
await new Promise(resolve => setTimeout(resolve, 800));
} catch (e) {
logError('cleanupNonVideoData', '❌ Fallo durante backup pre-migración:', e);
}
}
let migrated = 0;
let batchCount = 0;
const videoKeys = allIDBKeys.filter(k => !isNonVideoStorageKey(k));
for (const key of videoKeys) {
try {
const data = await StorageAsync.get(key);
if (!data) continue;
// Caso A: Desanidar playlist legacy
if (data.videos && typeof data.videos === 'object') {
const playlistId = key;
for (const [vId, vData] of Object.entries(data.videos)) {
const resolvedId = vData.videoId || vId;
const normalizedVData = normalizeVideoData({ ...vData, videoId: resolvedId }, resolvedId);
await StorageAsync.set(vId, {
...normalizedVData,
lastViewedPlaylistId: playlistId,
lastViewedPlaylistType: '',
lastViewedPlaylistItemId: null,
forceResumeTime: vData.forceResumeTime
});
migrated++;
}
await StorageAsync.del(key);
logInfo('cleanupNonVideoData', `✅ Playlist legacy ${key} desanidada`);
}
// Caso B: Normalizar vídeo individual
else {
const normalized = normalizeVideoData(data, key);
// Detectar campos antiguos (timestamp, etc.)
const hadLegacy = data.timestamp !== undefined || data.duration !== undefined || data.lastUpdated !== undefined || data.viewsNumber !== undefined;
if (hadLegacy || lastMigrationVersion < 4) {
await StorageAsync.set(key, normalized);
migrated++;
}
}
} catch (e) {
logError('cleanupNonVideoData', `Error al normalizar clave ${key}:`, e);
}
if ((++batchCount % 50) === 0) {
await new Promise(r => setTimeout(r, 0)); // No bloquear main thread
}
}
await GM_setValue(MIGRATION_KEY, MIGRATION_VERSION);
logInfo('cleanupNonVideoData', `✅ Normalización completada: ${migrated} vídeos actualizados`);
if (migrated > 0) {
showFloatingToast(`${SVG_ICONS.check} ${t('migrationComplete', { migrated: migrated })}`, 10000);
}
} else if (lastMigrationVersion < MIGRATION_VERSION) {
// No hay claves en IDB, igual actualizamos la versión de migración para evitar checks futuros innecesarios
await GM_setValue(MIGRATION_KEY, MIGRATION_VERSION);
}
logInfo('cleanupNonVideoData', '✅ Saneamiento global completado.');
} catch (err) {
logError('cleanupNonVideoData', '❌ Error durante el saneamiento profundo:', err);
}
}
// MARK: 🚀 Init
// ------------------------------------------
// Variables de control de inicialización
let isGloballyInitialized = false;
let initializationPromise = null;
// Inicialización global (solo una vez)
const initializeGlobal = async () => {
if (isGloballyInitialized) {
logInfo('initializeGlobal', '✅ Ya inicializado globalmente, omitiendo...');
return;
}
if (initializationPromise) {
logInfo('initializeGlobal', '⏳ Inicialización en progreso, esperando...');
return await initializationPromise;
}
initializationPromise = (async () => {
logInfo('initializeGlobal', '🚀 Iniciando inicialización global...');
let hadLanguageInStorage = false;
let loadedSettings = null;
let loadedSettingsMeta = null;
let externalTranslations = null;
// --- Inicializar YouTube Helper API ---
waitForHelper().then(h => YTHelper = h);
// --- Cargar traducciones ---
try {
[externalTranslations, loadedSettingsMeta] = await Promise.all([
loadTranslations().catch((err) => {
logError('initializeGlobal', '❌ Error al cargar traducciones:', err);
return null;
}),
Settings.getWithMeta().catch((err) => {
logError('initializeGlobal', '❌ Error al cargar settings:', err);
return { settings: { ...CONFIG.defaultSettings }, hadLanguageInStorage: false };
})
]);
loadedSettings = loadedSettingsMeta?.settings || { ...CONFIG.defaultSettings };
hadLanguageInStorage = !!loadedSettingsMeta?.hadLanguageInStorage;
if (externalTranslations && Object.keys(externalTranslations).length > 0) {
logInfo('initializeGlobal', ' Traducciones externas cargadas correctamente');
const extFlags = externalTranslations.LANGUAGE_FLAGS || externalTranslations.flags || {};
const extTranslations = externalTranslations.TRANSLATIONS || externalTranslations.translations || {};
LANGUAGE_FLAGS = { ...FALLBACK_FLAGS, ...extFlags };
TRANSLATIONS = deepMergeTranslations(FALLBACK_TRANSLATIONS, extTranslations);
} else {
logWarn('initializeGlobal', ' Traducciones externas incompletas, usando fallback');
LANGUAGE_FLAGS = FALLBACK_FLAGS;
TRANSLATIONS = FALLBACK_TRANSLATIONS;
}
cachedSettings = { ...CONFIG.defaultSettings, ...(loadedSettings || {}) };
logInfo('initializeGlobal', 'Settings cargados:', cachedSettings);
} catch (error) {
logError('initializeGlobal', ' Error al preparar traducciones/settings:', error);
LANGUAGE_FLAGS = FALLBACK_FLAGS;
TRANSLATIONS = FALLBACK_TRANSLATIONS;
cachedSettings = { ...CONFIG.defaultSettings };
}
// --- Cargar configuración y establecer idioma ---
try {
let langToUse;
if (hadLanguageInStorage && cachedSettings?.language && TRANSLATIONS[cachedSettings.language]) {
// Idioma guardado por el usuario y válido
langToUse = cachedSettings.language;
logInfo('initializeGlobal', `Idioma guardado válido: ${langToUse}`);
} else {
// Primera carga o idioma no configurado, usar navegador si existe
const browserLang = detectBrowserLanguage();
langToUse = TRANSLATIONS[browserLang] ? browserLang : CONFIG.defaultSettings.language;
logInfo('initializeGlobal', `Idioma detectado o fallback: ${langToUse}`);
}
await setLanguage(langToUse, { persist: false });
logInfo('initializeGlobal', ` Idioma configurado: ${langToUse}`);
// Actualizar siempre cachedSettings.language con el idioma detectado/seleccionado
cachedSettings = cachedSettings || { ...CONFIG.defaultSettings };
cachedSettings.language = langToUse;
// Guardar preferencia si era primera carga o si el idioma cambió
if (!hadLanguageInStorage || (loadedSettings?.language !== langToUse)) {
await Settings.set(cachedSettings);
logInfo('initializeGlobal', `Idioma guardado/actualizado en settings: ${langToUse}`);
}
} catch (error) {
logError('initializeGlobal', '❌ Error al establecer idioma:', error);
}
// --- Inicializar StorageAsync (migración a IndexedDB) ---
try {
await StorageAsync.initialize();
logInfo('initializeGlobal', '✅ StorageAsync inicializado');
} catch (err) {
logError('initializeGlobal', '❌ Error al inicializar StorageAsync:', err);
}
// --- Limpieza de metadatos en IndexedDB (Migración a GM_setValue y purga legacy) ---
try {
await cleanupNonVideoData();
} catch (err) {
logError('initializeGlobal', '❌ Error durante cleanupNonVideoData:', err);
}
// --- Registrar comandos e inyectar estilos ---
try {
registerMenuCommands();
injectStyles();
injectProgressBarCSS();
logInfo('initializeGlobal', '✅ Comandos y estilos registrados');
// Crear botón flotante si está habilitado en settings
if (typeof createFloatingButton === 'function') {
await createFloatingButton();
}
} catch (error) {
logError('initializeGlobal', '❌ Error al registrar menú o inyectar estilos:', error);
}
// --- Configurar eventos de navegación ---
/**
* Maneja eventos de navegación para actualizar el estado global del script.
* Aunque los observadores manejan la detección de videos, esta función asegura
* que el tipo de página y el ID actual estén sincronizados.
*/
const handleNavigation = () => {
currentPageType = getYouTubePageType();
logInfo('handleNavigation', `🌐 Navegación detectada: ${currentPageType}`);
// Solo preservamos el miniplayer si NO vamos a la página 'watch', porque
// en 'watch' el miniplayer se fusiona con el reproductor principal.
const preserveMiniplayer = currentPageType !== 'watch';
// Limpiar cachés de DOM para asegurar que el siguiente procesamiento use elementos frescos
DOMHelpers.clearAll();
// Forzar escaneo de videos después de la navegación y reinicializar observers
if (typeof VideoObserverManager?.clearCache === 'function') VideoObserverManager.clearCache();
if (typeof VideoObserverManager?.init === 'function') VideoObserverManager.init(true, preserveMiniplayer);
};
const debouncedNavigation = debounce(handleNavigation, 50);
window.addEventListener('yt-navigate-finish', debouncedNavigation);
document.addEventListener('yt-page-data-updated', debouncedNavigation);
// --- Inicializar observadores de video ---
try {
VideoObserverManager.init();
// --- Backup en GitHub (si está habilitado) ---
try {
/** Iniciar verificación de respaldo automático y programar chequeo periódico cada 15 min */
checkGitHubBackup();
setInterval(checkGitHubBackup, 15 * 60 * 1000);
} catch (error) {
logError('initializeGlobal', '❌ Error al verificar backup de GitHub:', error);
}
} catch (error) {
logError('initializeGlobal', '❌ Error al inicializar VideoObserverManager:', error);
}
isGloballyInitialized = true;
initializationPromise = null;
})();
return await initializationPromise;
};
// Función principal de inicialización
const init = async () => {
try {
await initializeGlobal();
logInfo('init', '✨ Script completamente inicializado');
} catch (error) {
logError('init', '❌ Error durante la inicialización:', error);
}
};
init();
})();