Greasy Fork is available in English.
自动保存并恢复 YouTube 视频的播放进度,无需登录。
当前为
// ==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-14
// @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
// @grant GM_addStyle
// @run-at document-end
// @namespace youtube-playback-plox
// @license MIT
// @require https://update.greasyfork.icu/scripts/549881/1814457/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: (c, ...a) => {
if (level >= L.debug) console.log(`%c[${c}]`, S.debug, ...a);
},
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);
},
group: (label) => {
if (level >= L.debug) console.group(`%c[${label}]`, S.debug);
},
groupEnd: () => {
if (level >= L.debug) console.groupEnd();
},
_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('getCascadedVideoInfo')) {
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, group: logGroup, groupEnd: logGroupEnd } = 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-14';
// ------------------------------------------
// MARK: 🛡️ Initialization Guard (SPA Safety)
// ------------------------------------------
// Evita que el script se ejecute más de una vez en la misma página.
window.__YPP__ = window.__YPP__ || {};
if (window.__YPP__?.status === 'initialized' && window.__YPP__?.version === SCRIPT_VERSION) {
logLog('[YPP Initialization]', `Already initialized (v${SCRIPT_VERSION}), skipping bootstrap.`);
return;
}
window.__YPP__.status = 'initialized';
window.__YPP__.version = SCRIPT_VERSION;
window.__YPP__.destroy = () => {
if (typeof cleanup === 'function') cleanup(false);
window.__YPP__.status = 'destroyed';
logLog('[YPP Initialization]', 'Force destroyed.');
};
// NOTA: Nunca almacenar elementos DOM crudos (ej. <video>) como claves o valores en Map/Set estándar.
// Hacerlo crea fugas de memoria silenciosas durante la navegación SPA de YouTube. Usar WeakMap en su lugar.
// ------------------------------------------
// 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)",
"ISO_3166": "us"
},
"es-ES": {
"emoji": "🇪🇸",
"code": "es-ES",
"name": "Español",
"ISO_3166": "es"
},
"fr": {
"emoji": "🇫🇷",
"code": "fr",
"name": "Français",
"ISO_3166": "fr"
}
};
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",
"show": "Show",
"hide": "Hide",
"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.",
"emptyStateSubtitle": "Try clearing your filters or exploring more 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",
"showAlertIcon": "Show icon",
"showAlertText": "Show message",
"showAlertTime": "Show timestamp",
"alertPreview": "Preview",
"alertHidden": "Hidden",
"showHistoryButton": "Show history button in playback bar",
"hideTimestamp": "Hide timestamp",
"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.",
"resumeCompletedFromStart": "Resume completed videos from the start",
"resumeCompletedFromStartTooltip": "If enabled, videos marked as completed will always start from 00:00. If disabled, they will stay at the end to allow YouTube's auto-advance to continue.",
"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",
"clearSelection": "Clear selection",
"hiddenSelectedCurrentResults": "{count} selected items are not visible in the 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",
"itemsDeleted": "{count} items deleted",
"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 ({size}MB, max {limit}MB)",
"fileTooLargeGist": "File is too large for Gist ({size}MB, max {limit}MB)",
"storageUsageVideos": "Videos: {usage}",
"storageUsageTotal": "Total: {usage}",
"storageUsageAvailable": "Available: {usage}",
"storageUsageVideosTooltip": "Space used by your currently saved videos",
"storageUsageTotalTooltip": "All YouTube data in IndexedDB (includes overhead)",
"storageUsageAvailableTooltip": "Total IndexedDB storage space available in browser",
"recalculateStorage": "Recalculate",
"recalculateStorageTooltip": "Recalculate storage usage",
"openInFreeTube": "Open in FreeTube",
"searchInSpotify": "Search in Spotify",
"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",
"playlistLimitReached": "You can only select up to 50 videos for a public playlist",
"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 una posible pérdida de datos debido a un error de migración, se le pedirá que guarde una copia de seguridad en formato JSON.",
"askDownloadBackupPreMigration": "¿Desea descargar la copia de seguridad 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",
"show": "Mostrar",
"hide": "Ocultar",
"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.",
"emptyStateSubtitle": "Intenta borrar tus filtros o explorar más vídeos.",
"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",
"showAlertIcon": "Mostrar icono",
"showAlertText": "Mostrar mensaje",
"showAlertTime": "Mostrar marca de tiempo",
"alertPreview": "Vista previa",
"alertHidden": "Oculto",
"showHistoryButton": "Mostrar botón de historial en la barra de reproducción",
"hideTimestamp": "Ocultar marca de tiempo",
"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.",
"resumeCompletedFromStart": "Reanudar vídeos completados desde el inicio",
"resumeCompletedFromStartTooltip": "Si está activado, los vídeos marcados como completados siempre comenzarán desde 00:00. Si está desactivado, permanecerán al final para permitir que el avance automático de YouTube continúe.",
"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",
"clearSelection": "Borrar selección",
"hiddenSelectedCurrentResults": "{count} elementos seleccionados no son visibles en 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",
"itemsDeleted": "{count} elementos eliminados",
"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 ({size}MB, máx. {limit}MB)",
"fileTooLargeGist": "El archivo es demasiado grande para Gist ({size}MB, máx. {limit}MB)",
"storageUsageVideos": "Vídeos: {usage}",
"storageUsageTotal": "Total: {usage}",
"storageUsageAvailable": "Disponible: {usage}",
"storageUsageVideosTooltip": "Espacio utilizado por tus vídeos guardados actualmente",
"storageUsageTotalTooltip": "Todos los datos de YouTube en IndexedDB (incluye sobrecarga)",
"storageUsageAvailableTooltip": "Espacio total de almacenamiento IndexedDB disponible en el navegador",
"recalculateStorage": "Recalcular",
"recalculateStorageTooltip": "Recalcular el uso de almacenamiento",
"openInFreeTube": "Abrir en FreeTube",
"searchInSpotify": "Buscar en Spotify",
"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",
"playlistLimitReached": "Solo puedes seleccionar hasta 50 vídeos para una lista de reproducción pública",
"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 votre base de données de vidéos enregistrées a été détectée. Pour éviter toute perte de données potentielle due à une erreur de migration, vous serez invité à sauvegarder une copie de sécurité au format JSON.",
"askDownloadBackupPreMigration": "Voulez-vous télécharger la sauvegarde 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",
"show": "Afficher",
"hide": "Masquer",
"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.",
"emptyStateSubtitle": "Essayez de vider vos filtres ou d'explorer plus de vidéos.",
"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",
"showAlertIcon": "Afficher l’icône",
"showAlertText": "Afficher le message",
"showAlertTime": "Afficher l’horodatage",
"alertPreview": "Aperçu",
"alertHidden": "Masqué",
"showHistoryButton": "Afficher le bouton d’historique dans la barre de lecture",
"hideTimestamp": "Masquer l’horodatage",
"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.",
"resumeCompletedFromStart": "Reprendre les vidéos terminées depuis le début",
"resumeCompletedFromStartTooltip": "Si activé, les vidéos marquées comme terminées commenceront toujours à 00:00. Si désactivé, elles resteront à la fin pour permettre la lecture automatique de YouTube.",
"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",
"clearSelection": "Effacer la sélection",
"hiddenSelectedCurrentResults": "{count} éléments sélectionnés ne sont pas visibles dans 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",
"itemsDeleted": "{count} éléments supprimé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 ({size}MB, max {limit}MB)",
"fileTooLargeGist": "Le fichier est trop volumineux pour Gist ({size}MB, max {limit}MB)",
"storageUsageVideos": "Vidéos : {usage}",
"storageUsageTotal": "Total : {usage}",
"storageUsageAvailable": "Disponible : {usage}",
"storageUsageVideosTooltip": "Espace utilisé par vos vidéos actuellement enregistrées",
"storageUsageTotalTooltip": "Toutes les données YouTube dans IndexedDB (inclut les surcharges)",
"storageUsageAvailableTooltip": "Espace total de stockage IndexedDB disponible dans le navigateur",
"recalculateStorage": "Recalculer",
"recalculateStorageTooltip": "Recalculer l'utilisation du stockage",
"openInFreeTube": "Ouvrir dans FreeTube",
"searchInSpotify": "Rechercher dans Spotify",
"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é",
"playlistLimitReached": "Vous pouvez sélectionner jusqu’à 50 vidéos pour une playlist publique",
"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)
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 (_) { }
// 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 tras agotar todas las fuentes.');
return null;
}
// 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 (_) { }
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,
showHistoryButton: true, // Mostrar botón para abrir historial/videos guardados
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
showAlertIcon: true, // Mostrar icono en alertas
showAlertText: true, // Mostrar mensaje en alertas
showAlertTime: true, // Mostrar marca de tiempo en alertas
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)
resumeCompletedFromStart: false, // Reanudar videos completados desde el inicio (default: desactivado)
},
defaultGithubSettings: {
gist: {
token: "",
id: "",
url: "",
autoBackup: false,
interval: 24, // horas
lastSync: 0,
lastSyncAttempt: 0
},
repo: {
token: "",
owner: "",
name: "",
autoBackup: false,
interval: 24, // horas
lastSync: 0,
lastSyncAttempt: 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', () => {
const isWatchPage = getYouTubePageType() === 'watch';
const miniPlayer = isWatchPage ? null : DOMHelpers.getMiniplayerElementActive();
const watchContainer = document.querySelector('ytd-watch-flexy');
let player = null;
if (watchContainer instanceof HTMLElement) {
const moviePlayerInsideFlexy = watchContainer?.querySelector(S.IDS.MOVIE_PLAYER);
if (typeof moviePlayerInsideFlexy?.getPlayerState === 'function') {
if (isVisiblyDisplayed(moviePlayerInsideFlexy)) {
logInfo('DOMHelpers', `✅ Player encontrado en 1er nivel es visible.`);
player = moviePlayerInsideFlexy
} else {
logInfo('DOMHelpers', `❌ Player encontrado en 1er nivel pero no es visible.`);
}
}
}
if (!player) {
const moviePlayers = document.querySelectorAll(S.IDS.MOVIE_PLAYER);
for (const el of moviePlayers) {
if (
typeof el?.getPlayerState === 'function' &&
(!miniPlayer || !miniPlayer.contains(el))
) {
if (isVisiblyDisplayed(el)) {
logInfo('DOMHelpers', `✅ Player encontrado en 2do nivel es visible.`);
player = el;
break;
} else {
logInfo('DOMHelpers', `❌ Player encontrado en 2do nivel pero no es visible.`);
}
}
}
}
if (!player) {
const html5VideoPlayer = document.querySelector(S.CLASSES.HTML5_VIDEO_PLAYER);
if (
typeof html5VideoPlayer?.getPlayerState === 'function' &&
(!miniPlayer || !miniPlayer.contains(html5VideoPlayer))
) {
if (isVisiblyDisplayed(html5VideoPlayer)) {
logInfo('DOMHelpers', `✅ Player encontrado en 3er nivel es visible.`);
player = html5VideoPlayer
} else {
logInfo('DOMHelpers', `❌ Player encontrado en 3er nivel pero no es visible.`);
}
}
}
if (!player) {
const totalVideos = document.querySelectorAll('video').length;
logWarn('DOMHelpers', `❌ No player found (videos in DOM: ${totalVideos})`);
return null;
}
// Doble check a player detectado
if (!(player instanceof HTMLElement)) {
logWarn('DOMHelpers', '⚠️ Player no es un HTMLElement:', player);
return null;
}
if (miniPlayer && miniPlayer.contains(player)) {
logWarn('DOMHelpers', '⚠️ Player está dentro del miniplayer.');
return null;
}
logInfo('DOMHelpers', {
isWatchPage: isWatchPage,
watchContainer: !!watchContainer,
playerInContainer: !!watchContainer?.querySelector(S.IDS.MOVIE_PLAYER),
moviePlayer: !!document.querySelector(S.IDS.MOVIE_PLAYER),
fallbackPlayer: !!document.querySelector(S.CLASSES.HTML5_VIDEO_PLAYER),
videos: document.querySelectorAll('video').length
});
return 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) ?? null),
/**
* 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
),
/**
* Obtiene el elemento contenedor del Miniplayer.
* @returns {Element|null} Elemento <ytd-miniplayer> (o null si no existe).
*/
getMiniplayerElement: () =>
get('miniplayerElement', () =>
document.querySelector(S.ELEMENTS.MINIPLAYER_ELEMENT) ?? 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) ||
DOMHelpers.get('page:app', () => document.querySelector('ytd-app'), 100)?.matches(S.ATTR.MINIPLAYER_ACTIVE_ATTR) ||
isVisiblyDisplayed(miniContainer);
if (isVisible) {
logInfo('DOMHelpers', '📱 Miniplayer detectado como ACTIVO');
}
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 contenedor principal para videos inline preview <ytd-video-preview>.
* @returns {Element|null} Contenedor para videos inline preview.
*/
getInlinePreviewMainContainer: () =>
get('inlinePreviewMainContainer', () =>
document.querySelector(S.IDS.VIDEO_PREVIEW_MAIN_CONTAINER) ?? 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) ?? null),
/**
* 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
/**
* Obtiene el texto traducido para una clave específica.
* Implementa una cascada de búsqueda: Traducción remota (actual) -> Traducción remota (default) -> Local (actual) -> Local (default).
* @param {string} key - Clave de traducción.
* @param {Object|string} [params={}] - Parámetros para reemplazo o texto por defecto si es string.
* @param {string} [defaultText=null] - Texto por defecto si no se encuentra la traducción.
* @returns {string} Texto traducido y sanitizado.
*/
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 = {};
}
const normParams = (typeof actualParams === 'object' && actualParams !== null) ? actualParams : {};
const fallbackMsg = actualDefaultText ?? key;
const lang = currentLanguage;
const defaultLang = CONFIG.defaultSettings.language;
// Cascada de resolución de texto
const text =
TRANSLATIONS?.[lang]?.[key] ?? // 1. Remoto/Caché (Actual)
TRANSLATIONS?.[defaultLang]?.[key] ?? // 2. Remoto/Caché (Default)
FALLBACK_TRANSLATIONS?.[lang]?.[key] ?? // 3. Local Inmutable (Actual)
FALLBACK_TRANSLATIONS?.[defaultLang]?.[key] // 4. Local Inmutable (Default)
?? fallbackMsg; // 5. Hardcoded Fallback
return escapeHTML(replaceParams(text, normParams));
}
// 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;
});
}
/**
* Establece el idioma del script y opcionalmente lo persiste en la configuración.
* @param {string} lang - Código de idioma (ej. 'es-ES', 'en').
* @param {Object} [options={ persist: true }] - Opciones de persistencia.
* @returns {Promise<boolean>}
*/
async function setLanguage(lang, options = { persist: true }) {
if (!lang) return false;
// 1. Normalización rápida: Si ya es el idioma actual, no hacemos nada
if (lang === currentLanguage && !options?.force) return true;
let validLang = lang;
// 2. Búsqueda inteligente de fallback
if (!TRANSLATIONS[validLang]) {
const [primary] = lang.split('-');
// Intentamos coincidencia exacta de la raíz (ej. 'es') o la primera subvariante que empiece por la raíz
validLang = TRANSLATIONS[primary]
? primary
: Object.keys(TRANSLATIONS).find(k => k.startsWith(`${primary}-`))
?? CONFIG.defaultSettings.language;
}
// Si después de la búsqueda sigue siendo el mismo, salimos temprano
if (validLang === currentLanguage && !options?.force) return true;
currentLanguage = validLang;
// 3. Persistencia optimizada
if (options?.persist) {
try {
const settings = await Settings.get();
// Solo guardamos si realmente hay un cambio en el objeto de configuración
if (settings.language !== validLang) {
settings.language = validLang;
await Settings.set(settings);
logInfo('setLanguage', `Idioma persistido: ${validLang}`);
}
} catch (e) {
logError('setLanguage', 'Error persistiendo idioma', e);
}
}
logInfo('setLanguage', `Idioma establecido en: ${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 = */
GM_addStyle`
:root {
/* Colores fijos independiente de tema */
--ypp-bg-time-display: rgba(17, 17, 17, 0.45);
--ypp-white: #ffffff;
--ypp-black: #000000;
/* Variables segun tema */
--ypp-bg: #ffffff;
--ypp-bg-secondary: #dadada;
--ypp-bg-secondary-hover: #919191ff;
--ypp-bg-tertiary: #f5f5f5;
--ypp-muted: #555555;
--ypp-light: #888888;
--ypp-overlay: rgba(0, 0, 0, 0.4); /* Background externo modales */
/* Botones */
--ypp-primary: #2563eb;
--ypp-primary-hover: #1d4ed8;
--ypp-primary-active: #1e40af;
--ypp-secondary: #494949;
--ypp-secondary-hover: #3d3d3d;
--ypp-secondary-active: #272727;
--ypp-danger: #dc2626;
--ypp-danger-hover: #b91c1c;
--ypp-danger-active: #991b1b;
--ypp-warning: #a96500;
--ypp-warning-hover: #8a5200;
--ypp-warning-active: #7c4a00;
--ypp-success: #16a34a;
--ypp-success-hover: #15803d;
--ypp-success-active: #166534;
--ypp-info: #0891b2;
--ypp-info-hover: #0e7490;
--ypp-info-active: #155e75;
--ypp-alert: #E1E200;
--ypp-alert-hover: #C4B300;
--ypp-alert-active: #A79A00;
--ypp-violet: #9561fb;
--ypp-violet-hover: #7c4de8;
--ypp-violet-active: #6338d5;
--ypp-border: #cccccc;
/* Tipografía */
--ypp-text: #1b1b1bff;
--ypp-text-secondary: #393939;
--ypp-font-base: system-ui, -apple-system, BlinkMacSystemFont, "Roboto", "Segoe UI", "Helvetica Neue", 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-secondary-text: #1b1b1b; /* 12.5:1 sobre #fff */
--ypp-danger-text: #991b1b; /* 7.1:1 sobre #fff */
--ypp-warning-text: #7c4a00; /* 7.2:1 sobre #fff */
--ypp-success-text: #166534; /* 7.3:1 sobre #fff */
--ypp-info-text: #0c547a; /* 7.4:1 sobre #fff */
--ypp-alert-text: #A79A00; /* 7.1:1 sobre #fff */
--ypp-violet-text: #6338d5; /* 7.2:1 sobre #fff */
--ypp-danger-warning-text: #993300; /* 7.1:1 sobre #fff - entre danger y warning */
--ypp-warning-success-text: #8B7300; /* 7.1:1 sobre #fff - entre warning y success */
/* Espaciado */
--ypp-spacing-sm: 0.5rem;
--ypp-spacing-md: 1rem;
--ypp-spacing-lg: 1.5rem;
--ypp-padding-sm: 0.25rem;
--ypp-padding-md: 0.5rem;
--ypp-padding-lg: 1rem;
/* Z-index */
--ypp-z-overlay: 9999;
--ypp-z-modal: 10000;
--ypp-z-toast: 10001;
/* Inputs */
--ypp-input: #f5f5f5;
--ypp-input-border: #cccccc;
}
:root[data-theme="dark"] {
--ypp-bg: #0f0f0f;
--ypp-bg-secondary: #1a1a1a;
--ypp-bg-secondary-hover: #303030;
--ypp-bg-tertiary: #2a2a2a;
--ypp-muted: #aaaaaa;
--ypp-light: #251a1aff;
--ypp-overlay: rgba(0, 0, 0, 0.8);
/* Botones */
--ypp-primary: #004683ff;
--ypp-primary-hover: #0d5fa8ff;
--ypp-primary-active: #136fadff;
--ypp-secondary: #4a4a4a;
--ypp-secondary-hover: #5a5a5a;
--ypp-secondary-active: #3a3a3a;
--ypp-danger: #720000ff;
--ypp-danger-hover: #8a1515ff;
--ypp-danger-active: #a81313ff;
--ypp-warning: #e28700;
--ypp-warning-hover: #f59e0b;
--ypp-warning-active: #c47700;
--ypp-success: #15803d;
--ypp-success-hover: #16a34a;
--ypp-success-active: #166534;
--ypp-info: #0e7490;
--ypp-info-hover: #0891b2;
--ypp-info-active: #155e75;
--ypp-alert: #F5D700;
--ypp-alert-hover: #E1C200;
--ypp-alert-active: #CDA800;
--ypp-violet: #a78bfa;
--ypp-violet-hover: #b794f4;
--ypp-violet-active: #c4a7ff;
--ypp-border: #303030;
/* Inputs */
--ypp-input: #1a1a1a;
--ypp-input-border: #303030;
/* Tipografía */
--ypp-text: #ececec;
--ypp-text-secondary: #c0c0c0;
/*
* 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-secondary-text: #989898; /* 15.6:1 sobre #0f0f0f */
--ypp-danger-text: #FD6060; /* 7.1:1 sobre #0f0f0f */
--ypp-warning-text: #C38D00; /* 7.1:1 sobre #0f0f0f */
--ypp-success-text: #00AD3F; /* 7:1 sobre #0f0f0f */
--ypp-info-text: #00A0E7; /* 7.1:1 sobre #0f0f0f */
--ypp-alert-text: #AC9700; /* 7.1:1 sobre #0f0f0f */
--ypp-violet-text: #A87EFE; /* 7:1 sobre #0f0f0f */
--ypp-danger-warning-text: #F56D16; /* 7:1 sobre #0f0f0f - entre danger y warning */
--ypp-warning-success-text: #E3AE00; /* 7:1 sobre #0f0f0f - entre warning y success */
}
.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-link {
color: var(--ypp-primary-text) !important;
text-decoration: none !important;
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 !important;
}
}
.ypp-svg-reset {
display: inline-block;
height: 16px;
width: 16px;
margin: 0;
}
.ypp-fill-currentColor {
fill: currentColor;
}
regular-item.ypp-fill-none {
fill: none !important;
}
.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;
box-shadow:
0 8px 30px rgba(0, 0, 0, 0.15);
-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;
}
:root[data-theme="dark"] .ypp-videosContainer {
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, 0.05),
0 8px 30px rgba(0, 0, 0, 0.4);
}
.ypp-videosContainer > *, .ypp-modalBox > * {
opacity: 0;
-webkit-animation: ypp-stagger-fade-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
animation: ypp-stagger-fade-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.ypp-videosContainer > *:nth-child(1), .ypp-modalBox > *:nth-child(1) { -webkit-animation-delay: 50ms; animation-delay: 50ms; }
.ypp-videosContainer > *:nth-child(2), .ypp-modalBox > *:nth-child(2) { -webkit-animation-delay: 100ms; animation-delay: 100ms; }
.ypp-videosContainer > *:nth-child(3), .ypp-modalBox > *:nth-child(3) { -webkit-animation-delay: 150ms; animation-delay: 150ms; }
.ypp-videosContainer > *:nth-child(4), .ypp-modalBox > *:nth-child(4) { -webkit-animation-delay: 200ms; animation-delay: 200ms; }
.ypp-videosContainer > *:nth-child(5), .ypp-modalBox > *:nth-child(5) { -webkit-animation-delay: 250ms; animation-delay: 250ms; }
@-webkit-keyframes ypp-stagger-fade-in {
from { opacity: 0; -webkit-transform: translateY(15px); }
to { opacity: 1; -webkit-transform: translateY(0); }
}
@keyframes ypp-stagger-fade-in {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
@-webkit-keyframes ypp-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes ypp-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.ypp-skeleton-container {
display: flex;
flex-direction: column;
padding: 0 16px; /* Padding for scrollbar track alignment */
width: 100%;
box-sizing: border-box;
align-items: stretch !important;
justify-content: flex-start !important;
}
.ypp-skeleton-entry {
display: flex;
gap: 16px;
background: transparent;
border-bottom: 1px solid var(--ypp-border);
padding: 12px 16px;
height: 120px;
align-items: center;
}
.ypp-skeleton-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.ypp-skeleton-circle {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--ypp-border);
background: transparent;
-webkit-animation: ypp-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: ypp-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.ypp-skeleton-thumb {
width: 140px;
height: 80px;
background: var(--ypp-border);
border-radius: 6px;
-webkit-animation: ypp-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: ypp-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.ypp-skeleton-lines {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.ypp-skeleton-line {
height: 12px;
background: var(--ypp-border);
border-radius: 4px;
-webkit-animation: ypp-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
animation: ypp-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.ypp-skeleton-line.title { width: 80%; height: 16px; margin-bottom: 4px; }
.ypp-skeleton-line.meta { width: 40%; }
.ypp-skeleton-line.meta-short { width: 60%; }
.ypp-empty-state-composed {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
color: var(--ypp-muted);
svg {
width: 64px;
height: 64px;
margin-bottom: 20px;
opacity: 0.4;
}
h3 {
margin: 0 0 10px 0;
font-size: 1.6rem;
color: var(--ypp-text);
font-weight: 500;
}
p {
margin: 0;
font-size: 1.3rem;
}
}
@-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 */
&: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: 89px;/* 77px; */
left: 14px; /* 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-hover);
}
&:active {
background: var(--ypp-primary-hover);
transform: scale(0.94);
transition: transform 0.1s ease;
}
&:focus-visible {
background: var(--ypp-primary-hover);
outline: transparent;
box-shadow: 0 0 0 2px var(--ypp-bg-time-display), 0 0 0 4px var(--ypp-primary);
}
}
.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 {
position: relative;
z-index: 10;
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 {
display: flex;
align-items: center;
border: 1px solid var(--ypp-border);
border-radius: 24px;
width: 100%;
background: var(--ypp-bg);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.ypp-search-container:focus-within {
border-color: var(--ypp-primary);
box-shadow: 0 0 0 1px var(--ypp-primary);
}
.ypp-input-search-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px 0 16px;
color: var(--ypp-text-secondary);
}
.ypp-searchbar {
flex: 1;
display: flex;
}
.ypp-search-input {
background: transparent;
border: none;
color: var(--ypp-text);
padding: 10px 0;
font-size: 1.3rem;
width: 100%;
outline: none !important;
}
.ypp-search-input::placeholder {
color: var(--ypp-text-secondary);
opacity: 0.7;
}
.ypp-filters-toggle-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 16px 0 12px;
border: none;
border-left: 1px solid var(--ypp-border);
border-radius: 0 24px 24px 0;
background: transparent;
color: var(--ypp-text);
cursor: pointer;
transition: all 0.2s ease;
font-size: 1.3rem;
white-space: nowrap;
align-self: stretch;
&:hover {
background: var(--ypp-bg-secondary);
}
&:active {
transform: scale(0.96);
background: var(--ypp-bg-secondary);
transition: transform 0.1s ease;
}
&:focus-visible {
outline: transparent;
box-shadow: inset 0 0 0 2px var(--ypp-primary);
}
}
.ypp-filters-toggle-btn.active {
background: var(--ypp-primary);
color: var(--ypp-white);
}
.ypp-active-filter-badge {
position: absolute;
top: -10px;
right: -5px;
display: flex;
align-items: center;
justify-content: center;
background: var(--ypp-danger);
color: var(--ypp-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 {
position: relative;
z-index: 5;
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), padding 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
background: var(--ypp-bg-secondary);
border-bottom: 0 solid var(--ypp-border);
display: flex;
flex-direction: column;
/* overflow: hidden; */
max-height: 0;
opacity: 0;
padding: 0 var(--ypp-spacing-lg);
gap: var(--ypp-spacing-sm);
pointer-events: none;
-webkit-animation: none !important;
animation: none !important;
}
.ypp-filters-advanced.expanded {
max-height: 90%;
/* max-height: 200px; */
opacity: 1;
padding: var(--ypp-spacing-md) var(--ypp-spacing-lg);
border-bottom: 1px solid var(--ypp-border);
pointer-events: auto;
}
.ypp-filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--ypp-spacing-md);
}
.ypp-range-filter-section {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
background: var(--ypp-bg); /* Fondo principal sobre el fondo secundario del panel */
padding: var(--ypp-spacing-sm) var(--ypp-spacing-md);
border-radius: 10px;
border: 1px solid var(--ypp-border);
box-sizing: border-box;
transition: transform 0.2s ease, border-color 0.2s ease;
&:hover {
border-color: var(--ypp-primary);
}
/* Estado cuando el filtro tiene valores NO predeterminados */
&.active {
border-color: var(--ypp-primary);
background: color-mix(in srgb, var(--ypp-primary) 8%, var(--ypp-bg));
box-shadow: 0 0 10px rgba(var(--ypp-primary-rgb), 0.1);
.ypp-filter-chip-label {
color: var(--ypp-text);
}
.ypp-dropdown-trigger {
border-color: var(--ypp-primary);
}
}
}
.ypp-range-controls {
display: flex;
gap: 6px;
align-items: center;
flex-direction: row;
width: 100%;
.ypp-custom-dropdown {
flex: 1.5; /* Prioridad al texto del dropdown */
min-width: 120px;
}
}
.ypp-range-inputs-group {
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
/* Input numérico: sin flechas y más ancho */
.ypp-range-input {
width: 75px;
height: 32px;
padding: 2px 6px;
background: var(--ypp-bg-secondary);
border: 1px solid var(--ypp-border);
border-radius: 6px;
color: var(--ypp-text);
font-size: 1.25rem;
text-align: center;
box-sizing: border-box;
transition: all 0.2s ease;
appearance: textfield; /* Quita flechas en Firefox */
/* Quita flechas en Chrome/Safari */
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&:focus {
outline: none;
border-color: var(--ypp-primary);
background: var(--ypp-bg);
box-shadow: 0 0 0 2px rgba(var(--ypp-primary-rgb), 0.2);
}
}
.ypp-range-input-label {
font-size: 1rem;
font-weight: 600;
color: var(--ypp-text-secondary);
margin-right: 2px;
}
.ypp-range-separator {
color: var(--ypp-text-secondary);
font-size: 1.1rem;
font-weight: bold;
opacity: 0.7;
}
/* Chip de etiquetas */
.ypp-filter-chip-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 1.05rem;
font-weight: 700;
color: var(--ypp-primary-text);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 2px;
svg {
width: 14px;
height: 14px;
color: var(--ypp-primary);
}
}
.ypp-range-filters-group {
display: contents; /* integrado directo al grid padre */
}
.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;
}
.ypp-footer-action-menu {
position: relative;
}
.ypp-footer-action-menu-list {
position: absolute;
left: 50%;
bottom: calc(100% + 6px);
transform: translateX(-50%);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column;
gap: 6px;
min-width: 170px;
padding: 8px;
border-radius: 8px;
border: 1px solid var(--ypp-border);
background: var(--ypp-bg-secondary);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3);
z-index: 50;
}
.ypp-footer-action-menu-option {
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: 8px;
width: 100%;
}
.ypp-footer-action-menu-option[disabled],
.ypp-btn[disabled] {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
#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-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;
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-playlistTitle {
margin: 8px 0 4px;
color: var(--ypp-primary-text);
cursor: pointer;
text-decoration: none;
display: block;
font-size: 1.2rem;
font-weight: 500;
&:hover {
color: var(--ypp-primary-hover);
text-decoration: underline;
}
}
.ypp-titleLink {
display: block;
font-weight: 600;
font-size: 1.4rem;
color: var(--ypp-primary-text);
text-decoration: none;
max-height: 40px;
overflow-y: auto;
overflow-x: hidden;
white-space: normal;
line-height: 1.2;
&:hover {
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-timestamp,
.ypp-progressInfo {
font-size: 1.3rem;
font-weight: bold;
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-text);
font-weight: bold;
}
/* =========================
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-timestamp:not(.forced):not(.completed):not(.forced.completed),
.ypp-views,
.ypp-progressInfo {
color: var(--ypp-text);
}
}
.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); */
background: linear-gradient(
to right,
hsl(128.09, 100%, 45.1%) 0%,
hsla(128.09, 100%, 45.1%, 0.813) 0%,
hsla(128.09, 100%, 45.1%, 0.651) 0.2%,
hsla(128.09, 100%, 45.1%, 0.512) 0.8%,
hsla(128.09, 100%, 45.1%, 0.394) 1.9%,
hsla(128.09, 100%, 45.1%, 0.296) 3.7%,
hsla(128.09, 100%, 45.1%, 0.216) 6.4%,
hsla(128.09, 100%, 45.1%, 0.152) 10.2%,
hsla(128.09, 100%, 45.1%, 0.102) 15.2%,
hsla(128.09, 100%, 45.1%, 0.064) 21.6%,
hsla(128.09, 100%, 45.1%, 0.037) 29.6%,
hsla(128.09, 100%, 45.1%, 0.019) 39.4%,
hsla(128.09, 100%, 45.1%, 0.008) 51.2%,
hsla(128.09, 100%, 45.1%, 0.002) 65.1%,
hsla(128.09, 100%, 45.1%, 0) 81.3%,
hsla(128.09, 100%, 45.1%, 0) 100%
);
}
.ypp-protected-item .ypp-thumb-regular,
.ypp-protected-item .ypp-thumb-shorts {
outline: 2px solid var(--ypp-success) !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-primary-text);
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;
&: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;
}
.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-wrapper {
position: relative;
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
margin-right: var(--ypp-spacing-sm);
width: 155px;
height: 85px;
border-radius: var(--ypp-spacing-md);
overflow: hidden;
background: var(--ypp-bg-secondary)
}
.ypp-thumb-regular {
max-width: 155px;
}
.ypp-thumb-shorts {
max-width: 55px;
}
.ypp-thumb {
object-fit: cover;
border-radius: var(--ypp-spacing-md);
margin-right: var(--ypp-spacing-sm);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.3s ease-in;
position: relative;
max-height: 85px;
width: 100%;
height: 100%;
max-width: none;
}
.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);
}
/* =========================
Botones
========================= */
.ypp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 14px;
font-weight: 500;
font-size: 1.4rem;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
border: none;
outline: none;
position: relative;
overflow: hidden;
gap: 8px;
background: var(--btn-bg, var(--ypp-primary));
color: var(--btn-color, var(--ypp-white));
/* min-height: 36px; */
&::before {
content: "";
position: absolute;
inset: 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;
pointer-events: none;
}
&:hover {
background: var(--btn-bg-hover, var(--ypp-primary-hover));
&::before {
opacity: 1;
}
}
&:active {
background: var(--btn-bg-active, var(--ypp-primary-active));
-webkit-transform: scale(0.98);
-ms-transform: scale(0.98);
transform: scale(0.98);
}
&:focus-visible {
outline: 2px solid var(--btn-bg-hover, var(--ypp-primary-hover));
outline-offset: 2px;
}
}
.ypp-btn-circle {
padding: 0; /* Sin padding para botón circular fijo */
width: 36px;
height: 36px;
min-height: 36px;
-ms-flex-negative: 0;
flex-shrink: 0;
border-radius: 50%;
display: -webkit-inline-flex;
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;
line-height: 0; /* Evitar espacios de baseline */
svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
/* =========================
Variantes de color de botones
Uso: .ypp-btn + .ypp-btn-{variant} o .ypp-btn + .ypp-btn-outline-{variant}
Variantes disponibles: primary | secondary | success | danger | warning | info
========================= */
.ypp-btn-primary {
--btn-bg: var(--ypp-primary);
--btn-bg-hover: var(--ypp-primary-hover);
--btn-bg-active: var(--ypp-primary-active);
--btn-color: var(--ypp-white);
}
.ypp-btn-secondary {
--btn-bg: var(--ypp-secondary);
--btn-bg-hover: var(--ypp-secondary-hover);
--btn-bg-active: var(--ypp-secondary-active);
--btn-color: var(--ypp-white);
}
.ypp-btn-success {
--btn-bg: var(--ypp-success);
--btn-bg-hover: var(--ypp-success-hover);
--btn-bg-active: var(--ypp-success-active);
--btn-color: var(--ypp-white);
}
.ypp-btn-danger {
--btn-bg: var(--ypp-danger);
--btn-bg-hover: var(--ypp-danger-hover);
--btn-bg-active: var(--ypp-danger-active);
--btn-color: var(--ypp-white);
}
.ypp-btn-warning {
--btn-bg: var(--ypp-warning);
--btn-bg-hover: var(--ypp-warning-hover);
--btn-bg-active: var(--ypp-warning-active);
--btn-color: var(--ypp-black);
}
.ypp-btn-info {
--btn-bg: var(--ypp-info);
--btn-bg-hover: var(--ypp-info-hover);
--btn-bg-active: var(--ypp-info-active);
--btn-color: var(--ypp-white);
}
.ypp-btn-violet {
--btn-bg: var(--ypp-violet);
--btn-bg-hover: var(--ypp-violet-hover);
--btn-bg-active: var(--ypp-violet-active);
--btn-color: var(--ypp-white);
}
/* Outlines */
.ypp-btn-outline-primary {
--btn-bg: transparent;
--btn-bg-hover: var(--ypp-primary);
--btn-bg-active: var(--ypp-primary-active);
--btn-color: var(--ypp-primary-text);
border: 1px solid var(--ypp-primary);
&:hover {
--btn-color: var(--ypp-white);
}
&:active {
--btn-color: var(--ypp-white);
}
}
.ypp-btn-outline-secondary {
--btn-bg: transparent;
--btn-bg-hover: var(--ypp-secondary);
--btn-bg-active: var(--ypp-secondary-active);
--btn-color: var(--ypp-secondary-text);
border: 1px solid var(--ypp-secondary);
&:hover {
--btn-color: var(--ypp-white);
}
&:active {
--btn-color: var(--ypp-white);
}
}
.ypp-btn-outline-success {
--btn-bg: transparent;
--btn-bg-hover: var(--ypp-success);
--btn-bg-active: var(--ypp-success-active);
--btn-color: var(--ypp-success-text);
border: 1px solid var(--ypp-success);
&:hover {
--btn-color: var(--ypp-white);
}
}
.ypp-btn-outline-danger {
--btn-bg: transparent;
--btn-bg-hover: var(--ypp-danger);
--btn-bg-active: var(--ypp-danger-active);
--btn-color: var(--ypp-danger-text);
border: 1px solid var(--ypp-danger);
&:hover {
--btn-color: var(--ypp-white);
}
&:active {
--btn-color: var(--ypp-white);
}
}
.ypp-btn-outline-warning {
--btn-bg: transparent;
--btn-bg-hover: var(--ypp-warning);
--btn-bg-active: var(--ypp-warning-active);
--btn-color: var(--ypp-warning-text);
border: 1px solid var(--ypp-warning);
&:hover {
--btn-color: var(--ypp-black);
}
&:active {
--btn-color: var(--ypp-black);
}
}
.ypp-btn-outline-info {
--btn-bg: transparent;
--btn-bg-hover: var(--ypp-info);
--btn-bg-active: var(--ypp-info-active);
--btn-color: var(--ypp-info-text);
border: 1px solid var(--ypp-info);
&:hover {
--btn-color: var(--ypp-white);
}
&:active {
--btn-color: var(--ypp-white);
}
}
.ypp-btn-outline-violet {
--btn-bg: transparent;
--btn-bg-hover: var(--ypp-violet);
--btn-bg-active: var(--ypp-violet-active);
--btn-color: var(--ypp-violet-text);
border: 1px solid var(--ypp-violet);
&:hover {
--btn-color: var(--ypp-white);
}
&:active {
--btn-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 var(--ypp-secondary);
&: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 var(--ypp-secondary);
&:hover {
background: #ffffff;
color: #000000;
}
&: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;
}
}
@-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: ypp-spring-toast-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
animation: ypp-spring-toast-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-webkit-transition: opacity 0.2s ease, -webkit-transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition: opacity 0.2s ease, transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-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);
}
@-webkit-keyframes ypp-spring-toast-in {
0% { opacity: 0; -webkit-transform: translateY(20px) scale(0.9); }
100% { opacity: 1; -webkit-transform: translateY(0) scale(1); }
}
@keyframes ypp-spring-toast-in {
0% { opacity: 0; transform: translateY(20px) scale(0.9); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
.ypp-toast.persistent {
position: relative;
}
.ypp-toast-close {
border: none;
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;
&:hover {
background: var(--ypp-danger);
color: var(--ypp-text);
}
}
.ypp-toast-action {
background: var(--ypp-primary);
border: none;
color: var(--ypp-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;
}
.ypp-storage-usage {
display: flex;
align-items: center;
gap: 4px;
background: var(--ypp-bg-secondary);
padding: 2px 6px;
border-radius: var(--ypp-spacing-sm);
}
/* 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-muted);
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-small {
font-size: 1.2rem;
color: var(--ypp-muted);
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.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;
&: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 {
border-radius: 10px;
padding: 2px 10px;
max-width: 45px;
}
.ypp-alert-preview-container {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.ypp-alert-preview-title {
font-size: 1.1rem;
color: var(--ypp-text-secondary);
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ypp-alert-preview-box {
padding: 8px 14px;
background: var(--ypp-bg);
border: 1px solid var(--ypp-border-color);
border-radius: 8px;
min-height: 34px;
display: flex;
align-items: center;
font-size: 1.4rem;
color: var(--ypp-text);
word-break: break-all;
gap: var(--ypp-spacing-sm);
}
/* =========================
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-primary-text);
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-columns: 1fr;
}
.ypp-management-footer-section {
display: flex;
gap: var(--ypp-spacing-sm);
flex-wrap: wrap;
align-items: center;
}
.ypp-management-footer-section[data-section="danger"] {
padding-top: 8px;
border-top: 1px solid var(--ypp-border);
}
.ypp-management-footer-section[data-section="danger"] .ypp-management-footer-item:last-child {
margin-left: auto;
}
.ypp-management-footer-section[data-section="session"] {
justify-content: flex-end;
}
/* =========================
Custom Icon Dropdown
Replaces native <select> to enable SVG icons inside options
========================= */
.ypp-custom-dropdown {
position: relative;
flex: 1;
min-width: 0;
}
.ypp-dropdown-trigger {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 10px;
background: var(--ypp-bg);
border: 1px solid var(--ypp-border);
border-radius: 6px;
color: var(--ypp-text-secondary);
font-size: 1.3rem;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
box-sizing: border-box;
user-select: none;
&:hover {
background: var(--ypp-bg-secondary);
}
&[aria-expanded="true"] {
border-color: var(--ypp-primary);
background: var(--ypp-bg-secondary);
}
}
.ypp-dropdown-trigger-icon {
display: flex;
align-items: center;
flex-shrink: 0;
/* width: 16px;
height: 16px; */
svg {
width: 16px;
height: 16px;
}
}
.ypp-dropdown-trigger-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ypp-dropdown-trigger-chevron {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
width: 18px;
height: 18px;
color: var(--ypp-text-secondary);
transition: transform 0.2s ease;
svg {
width: 18px;
height: 18px;
}
&.open {
transform: rotate(180deg);
}
}
.ypp-dropdown-list {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 100%;
max-height: 280px;
overflow-y: auto;
background: var(--ypp-bg);
border: 1px solid var(--ypp-border);
border-radius: 8px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
z-index: calc(var(--ypp-z-overlay) + 10);
padding: 4px;
box-sizing: border-box;
animation: yppDropdownSlideIn 0.15s ease-out;
&.hidden {
display: none;
}
}
@keyframes yppDropdownSlideIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ypp-dropdown-group-label {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px 4px;
font-size: 1.1rem;
font-weight: 600;
color: var(--ypp-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.7;
pointer-events: none;
border-top: 1px solid var(--ypp-border);
margin-top: 4px;
&:first-child {
border-top: none;
margin-top: 0;
}
svg {
width: 14px;
height: 14px;
}
}
.ypp-dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 1.3rem;
color: var(--ypp-text);
transition: background-color 0.1s ease;
white-space: nowrap;
&:hover {
background: var(--ypp-bg-secondary);
}
&[aria-selected="true"] {
background: var(--ypp-primary);
color: var(--ypp-white);
}
}
.ypp-dropdown-item-icon {
display: flex;
align-items: center;
flex-shrink: 0;
/* width: 16px;
height: 16px; */
svg {
width: 16px;
height: 16px;
}
}
`;
/* 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') ||
DOMHelpers.get('theme:masthead', () => document.querySelector('ytd-masthead'), 100)?.getAttribute('dark') === 'true'
);
}
/**
* Aplica el atributo data-theme basado en el tema de YouTube.
* Esta función debe llamarse durante la inicialización y cuando cambie el tema.
*/
function applyTheme() {
const htmlElement = document.documentElement;
const isDark = isYouTubeDarkTheme();
if (isDark) {
htmlElement.setAttribute('data-theme', 'dark');
} else {
htmlElement.removeAttribute('data-theme');
}
logLog('applyTheme', `Tema aplicado: ${isDark ? 'dark' : 'light'}`);
}
/** @type {MutationObserver|null} Observer de cambios de tema para cleanup */
let themeObserver = null;
/** @type {Array<{target: EventTarget, event: string, handler: Function}>} Referencias a listeners globales para cleanup */
let globalNavigationListeners = [];
/** @type {Function|null} Referencia al handler de YTHelper para cleanup */
let ythelperListener = null;
/** @type {Array<{target: EventTarget, event: string, handler: Function}>} Referencias a listeners del botón flotante para cleanup */
let floatingButtonListeners = [];
/**
* Observa cambios en el atributo 'dark' de YouTube y actualiza data-theme.
*/
function observeThemeChanges() {
const htmlElement = document.documentElement;
themeObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'dark') {
applyTheme();
break;
}
}
});
themeObserver.observe(htmlElement, {
attributes: true,
attributeFilter: ['dark']
});
logLog('observeThemeChanges', 'Observer de tema iniciado');
}
/**
* Desconecta el observer de tema para prevenir memory leaks.
*/
function cleanupThemeObserver() {
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
logLog('cleanupThemeObserver', 'Observer de tema desconectado');
}
}
/**
* Limpia listeners globales para prevenir memory leaks.
*/
function cleanupGlobalListeners() {
// Limpiar listeners de navegación
globalNavigationListeners.forEach(({ target, event, handler }) => {
target.removeEventListener(event, handler);
});
globalNavigationListeners = [];
// Limpiar listener de YTHelper
if (YTHelper && ythelperListener) {
YTHelper.eventTarget.removeEventListener('yt-helper-api-ready', ythelperListener);
ythelperListener = null;
}
// Limpiar listeners del botón flotante
floatingButtonListeners.forEach(({ target, event, handler }) => {
target.removeEventListener(event, handler);
});
floatingButtonListeners = [];
logLog('cleanupGlobalListeners', 'Listeners globales desconectados');
}
// ------------------------------------------
// MARK: 🎨 SVG Icons
// ------------------------------------------
// SVGs como strings para reemplazar emojis
const SVG_ICONS = {
/* iconamoon - CC BY 4.0 ------------------------------------ */
// https://icon-sets.iconify.design/iconamoon/folder-video/
folder: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="2"><path stroke-linecap="round" d="M3 17V5h7l2 2h9v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2"/><path d="m14 13l-3 1.732v-3.464z"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/clock/
timer: '<svg class="ypp-svg-reset ypp-fill-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M11 8v5h5"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/history/
clockRotateLeft: '<svg class="ypp-svg-reset" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M5.636 18.364A9 9 0 1 0 3 12.004V14"/><path d="m1 12l2 2l2-2m6-4v5h5"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/shield/
shield: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m12 3l.394-.92a1 1 0 0 0-.788 0zm0 18l-.496.868a1 1 0 0 0 .992 0zm6.394-15.26L18 6.66zM8.024 18.727l-.497.869zm3.582-16.646L5.212 4.82L6 6.66l6.394-2.74zM4 6.659v6.86h2v-6.86zm3.527 12.937l3.977 2.272l.992-1.736l-3.977-2.273zm4.97 2.272l3.976-2.272l-.992-1.737l-3.977 2.273zM20 13.518V6.66h-2v6.86zm-1.212-8.697l-6.394-2.74l-.788 1.838L18 6.66zM20 6.66a2 2 0 0 0-1.212-1.838L18 6.66zm-3.527 12.937A7 7 0 0 0 20 13.518h-2a5 5 0 0 1-2.52 4.341zM4 13.518a7 7 0 0 0 3.527 6.078l.992-1.737A5 5 0 0 1 6 13.52zm1.212-8.697A2 2 0 0 0 4 6.66h2z"/></svg>',
// https://icon-sets.iconify.design/iconamoon/shield-off/
shieldOff: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path stroke-linejoin="round" d="m5.7 5.7l-.094.04A1 1 0 0 0 5 6.66v6.858a6 6 0 0 0 3.023 5.21L12 21l3.977-2.273a6 6 0 0 0 1.517-1.233M9.66 4.003L12 3l6.394 2.74a1 1 0 0 1 .606.92v6.683"/><path d="m4 4l16 16"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/shield-yes-fill/
shieldYesFill: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M11.606 2.08a1 1 0 0 1 .788 0l6.394 2.741A2 2 0 0 1 20 6.66v6.86a7 7 0 0 1-3.527 6.077l-3.977 2.272a1 1 0 0 1-.992 0l-3.977-2.272A7 7 0 0 1 4 13.518V6.66a2 2 0 0 1 1.212-1.838zm4.101 8.627a1 1 0 0 0-1.414-1.414L11 12.586l-1.293-1.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0z" clip-rule="evenodd"/></svg>',
// https://icon-sets.iconify.design/iconamoon/funnel/
funnel: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="2" d="M20 4H4l5.6 7.467a2 2 0 0 1 .4 1.2V20l4-2v-5.333a2 2 0 0 1 .4-1.2z"/></svg>',
// https://icon-sets.iconify.design/iconamoon/bookmark/
bookmarkOutline: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 3H8a2 2 0 0 0-2 2v16l6-3l6 3V5a2 2 0 0 0-2-2"/></svg>',
// https://icon-sets.iconify.design/iconamoon/settings/
settings: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none"><circle cx="12" cy="12" r="2" stroke="currentColor" stroke-width="2"/><path fill="currentColor" d="m5.399 5.88l.5-.867a1 1 0 0 0-1.234.186zM3.4 9.344l-.956-.295a1 1 0 0 0 .456 1.16zm-.002 5.311l-.5-.866a1 1 0 0 0-.455 1.162zm2 3.464l-.734.68a1 1 0 0 0 1.234.186zm4.6 2.655H9a1 1 0 0 0 .778.975zm4.001.002l.223.975a1 1 0 0 0 .777-.975zM18.6 18.12l-.5.866a1 1 0 0 0 1.233-.186zm1.998-3.466l.956.295a1 1 0 0 0-.456-1.16zm.002-5.311l.5.866a1 1 0 0 0 .455-1.162zm-2-3.465l.734-.679a1 1 0 0 0-1.234-.187zM14 3.225h1a1 1 0 0 0-.777-.975zm-4-.002l-.223-.975A1 1 0 0 0 9 3.223zm4 1.849h-1zm5 8.66l-.5.866zm-2 3.464l-.5.866zM5 13.732l.5.866zm2-6.928l-.5.866zM4.356 9.639a8 8 0 0 1 1.776-3.08L4.665 5.2a10 10 0 0 0-2.22 3.85zM5.072 16a8 8 0 0 1-.718-1.64l-1.91.592c.217.701.515 1.388.896 2.048zm1.06 1.441A8 8 0 0 1 5.073 16L3.34 17c.38.66.827 1.261 1.325 1.8zm7.646 2.361a8 8 0 0 1-3.556-.002l-.445 1.95a10 10 0 0 0 4.446.002zm5.866-5.441a8 8 0 0 1-1.776 3.08l1.467 1.36a10 10 0 0 0 2.22-3.85zM18.928 8c.306.53.545 1.08.718 1.64l1.91-.592A10 10 0 0 0 20.66 7zm-1.06-1.441c.397.43.754.91 1.06 1.441l1.732-1a10 10 0 0 0-1.325-1.8zm-7.646-2.361a8 8 0 0 1 3.556.002l.444-1.95a10 10 0 0 0-4.445-.002zm.778.874v-1.85H9v1.85zm-3.5.866l-1.601-.925l-1 1.732l1.6.925zm-3 6.928l-1.601.924l1 1.732l1.6-.924zm1-3.464l-1.6-.923l-1 1.732l1.6.923zM11 20.775v-1.847H9v1.847zM6.5 16.33l-1.601.925l1 1.732l1.6-.925zm12.601.925L17.5 16.33l-1 1.732l1.601.925zM15 20.777v-1.849h-2v1.85zm5.101-12.3l-1.601.925l1 1.732l1.601-.925zm.998 5.312l-1.599-.923l-1 1.732l1.6.923zM15 5.072V3.225h-2v1.847zm3.101-.059l-1.601.925l1 1.732l1.601-.925zM13 5.072c0 2.31 2.5 3.753 4.5 2.598l-1-1.732a1 1 0 0 1-1.5-.866zm5.5 4.33c-2 1.155-2 4.041 0 5.196l1-1.732a1 1 0 0 1 0-1.732zm-1 6.928c-2-1.154-4.5.289-4.5 2.598h2a1 1 0 0 1 1.5-.866zM11 18.928c0-2.31-2.5-3.753-4.5-2.598l1 1.732a1 1 0 0 1 1.5.866zm-5.5-4.33c2-1.155 2-4.041 0-5.196l-1 1.732a1 1 0 0 1 0 1.732zM9 5.072a1 1 0 0 1-1.5.866l-1 1.732c2 1.154 4.5-.289 4.5-2.598z"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/settings-fill/
settingsFill: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M9 3.223a1 1 0 0 1 .777-.975a10 10 0 0 1 4.445.002a1 1 0 0 1 .778.975v1.847a1 1 0 0 0 1.5.866l1.601-.925a1 1 0 0 1 1.234.187a10.1 10.1 0 0 1 2.221 3.848a1 1 0 0 1-.455 1.162l-1.601.924a1 1 0 0 0 0 1.732l1.6.923a1 1 0 0 1 .455 1.161a10 10 0 0 1-2.22 3.851a1 1 0 0 1-1.234.186l-1.601-.925a1 1 0 0 0-1.5.866v1.85a1 1 0 0 1-.778.974a10 10 0 0 1-4.445-.002A1 1 0 0 1 9 20.775v-1.847a1 1 0 0 0-1.5-.866l-1.601.925a1 1 0 0 1-1.234-.187A10 10 0 0 1 3.34 17a10 10 0 0 1-.896-2.048a1 1 0 0 1 .455-1.162l1.6-.924a1 1 0 0 0 0-1.732l-1.598-.923a1 1 0 0 1-.456-1.161a10 10 0 0 1 2.22-3.85a1 1 0 0 1 1.233-.187l1.602.925A1 1 0 0 0 9 5.072zM12 15a3 3 0 1 0 0-6a3 3 0 0 0 0 6" clip-rule="evenodd"/></svg>',
// https://icon-sets.iconify.design/iconamoon/player-end/
playerEnd: '<svg class="ypp-svg-reset ypp-fill-currentColor" id="svg-player-end" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="2"><path d="M17 10.268c1.333.77 1.333 2.694 0 3.464l-9 5.196c-1.333.77-3-.192-3-1.732V6.804c0-1.54 1.667-2.502 3-1.732z"/><path stroke-linecap="round" d="M20 5v14"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/eye-off/
eyeOff: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path stroke-linejoin="round" d="M10.73 5.073A11 11 0 0 1 12 5c4.664 0 8.4 2.903 10 7a11.6 11.6 0 0 1-1.555 2.788M6.52 6.519C4.48 7.764 2.9 9.693 2 12c1.6 4.097 5.336 7 10 7a10.44 10.44 0 0 0 5.48-1.52m-7.6-7.6a3 3 0 1 0 4.243 4.243"/><path d="m4 4l16 16"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/eye/
eye: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 12a3 3 0 1 1-6 0a3 3 0 0 1 6 0"/><path d="M2 12c1.6-4.097 5.336-7 10-7s8.4 2.903 10 7c-1.6 4.097-5.336 7-10 7s-8.4-2.903-10-7"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/information-circle/
info: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1"><circle cx="12" cy="12" r="9" stroke-linecap="round" stroke-width="2"/><path stroke-width="3" d="M12 8h.01v.01H12z"/><path stroke-linecap="round" stroke-width="2" d="M12 12v4"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/search/
search: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 21l-4.343-4.343m0 0A8 8 0 1 0 5.343 5.343a8 8 0 0 0 11.314 11.314"/></svg>',
// https://icon-sets.iconify.design/iconamoon/link-external/
linkExternal: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4H4v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-5M9 15L20 4m-5 0h5v5"/></svg>',
// https://icon-sets.iconify.design/iconamoon/copy/
copy: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M16 3H4v13"/><path d="M8 7h12v12a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2z"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/cloud-upload-fill/
cloudUpload: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M6.697 6.697a7.5 7.5 0 0 1 12.794 4.927A4.002 4.002 0 0 1 18.5 19.5h-12a5 5 0 0 1-1.667-9.715a7.5 7.5 0 0 1 1.864-3.088m6.01 1.596a1 1 0 0 0-1.414 0l-2 2a1 1 0 1 0 1.414 1.414l.293-.293V15a1 1 0 1 0 2 0v-3.586l.293.293a1 1 0 0 0 1.414-1.414z" clip-rule="evenodd"/></svg>',
// https://icon-sets.iconify.design/iconamoon/close/
close: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m7 7l10 10M7 17L17 7"/></svg>',
// https://icon-sets.iconify.design/iconamoon/trash/
// trash: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 11v6m-4-6v6M6 7v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7M4 7h16M7 7l2-4h6l2 4"/></svg>',
// https://icon-sets.iconify.design/iconamoon/trash-simple-fill/
// trash: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M9 2a1 1 0 0 0-.894.553L6.382 6H4a1 1 0 0 0 0 2h1v11a3 3 0 0 0 3 3h8a3 3 0 0 0 3-3V8h1a1 1 0 1 0 0-2h-2.382l-1.724-3.447A1 1 0 0 0 15 2zm6.382 4l-1-2H9.618l-1 2z" clip-rule="evenodd"/></svg>',
// https://icon-sets.iconify.design/iconamoon/trend-up-bold/
trendUpBold: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"><path d="m3 17l6-6l4 4l8-8"/><path d="M17 7h4v4"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/trend-down-bold/
trendDownBold: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"><path d="m3 7l6 6l4-4l8 8"/><path d="M17 17h4v-4"/></g></svg>',
//https://icon-sets.iconify.design/iconamoon/number-1-circle/
numberOneCircle: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12.5 17V7l-2 2"/><circle cx="12" cy="12" r="9"/></g></svg>',
// https://icon-sets.iconify.design/iconamoon/arrow-down-2-fill/
chevronDown: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M7 9a1 1 0 0 0-.707 1.707l5 5a1 1 0 0 0 1.414 0l5-5A1 1 0 0 0 17 9z" clip-rule="evenodd"/></svg>',
// https://icon-sets.iconify.design/iconamoon/restart/
restart: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 3a9 9 0 1 1-5.657 2"/><path d="M3 4.5h4v4"/></g></svg>',
/* octicon - MIT ------------------------------------ */
// https://icon-sets.iconify.design/octicon/compose-16/
compose: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="m14.515.456l.965.965a1.555 1.555 0 0 1 0 2.2L9.745 9.355a1.55 1.55 0 0 1-.672.396l-2.89.826a.67.67 0 0 1-.828-.474a.66.66 0 0 1 .004-.35l.825-2.89c.073-.254.209-.486.396-.673L12.315.456c.144-.145.316-.259.505-.337a1.54 1.54 0 0 1 1.19 0c.189.078.361.192.505.337m-3.322 3.008l-3.67 3.669a.2.2 0 0 0-.057.096L6.97 8.965l1.736-.496a.2.2 0 0 0 .096-.056l3.67-3.67Zm2.065-2.066L12.135 2.52l1.28 1.28l1.122-1.122a.22.22 0 0 0 .065-.157a.22.22 0 0 0-.065-.157l-.965-.966a.22.22 0 0 0-.157-.065a.23.23 0 0 0-.157.065"/><path fill="currentColor" d="M0 14.25V2.75A1.75 1.75 0 0 1 1.75 1H7a.75.75 0 0 1 0 1.5H1.75a.25.25 0 0 0-.25.25v11.5a.25.25 0 0 0 .25.25h11.5a.25.25 0 0 0 .25-.25V8.5a.75.75 0 0 1 1.5 0v5.75c0 .464-.184.909-.513 1.237A1.75 1.75 0 0 1 13.25 16H1.75A1.75 1.75 0 0 1 0 14.25"/></svg>',
// https://icon-sets.iconify.design/octicon/stopwatch-16/
stopWatch: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 17"><path fill="currentColor" d="M5.75.75A.75.75 0 0 1 6.5 0h3a.75.75 0 0 1 0 1.5h-.75v1l-.001.041a6.7 6.7 0 0 1 3.464 1.435l.007-.006l.75-.75a.749.749 0 0 1 1.275.326a.75.75 0 0 1-.215.734l-.75.75l-.006.007a6.75 6.75 0 1 1-10.548 0L2.72 5.03l-.75-.75a.75.75 0 0 1 .018-1.042a.75.75 0 0 1 1.042-.018l.75.75l.007.006A6.7 6.7 0 0 1 7.25 2.541V1.5H6.5a.75.75 0 0 1-.75-.75M8 14.5a5.25 5.25 0 1 0-.001-10.501A5.25 5.25 0 0 0 8 14.5m.389-6.7l1.33-1.33a.75.75 0 1 1 1.061 1.06L9.45 8.861A1.503 1.503 0 0 1 8 10.75a1.499 1.499 0 1 1 .389-2.95"/></svg>',
// https://icon-sets.iconify.design/octicon/mark-github-16/
github: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656c0-.781.281-1.625.75-2.188c-.203-.515-.172-1.609.063-2.062c.625-.078 1.468.25 1.968.703c.594-.187 1.219-.281 1.985-.281c.765 0 1.39.094 1.953.265c.484-.437 1.344-.765 1.969-.687c.218.422.25 1.515.046 2.047c.5.593.766 1.39.766 2.203c0 1.922-1.453 3.375-3.547 3.64c.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03C16 3.61 12.406 0 7.984 0C3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156c-1.031 0-1.64-.562-2.078-1.609c-.172-.422-.36-.672-.719-.719c-.187-.015-.25-.093-.25-.187c0-.188.313-.328.625-.328c.453 0 .844.281 1.25.86c.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656"/></svg>',
// https://icon-sets.iconify.design/octicon/git-branch-24/
gitBranch: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M15 4.75a3.25 3.25 0 1 1 6.5 0a3.25 3.25 0 0 1-6.5 0M2.5 19.25a3.25 3.25 0 1 1 6.5 0a3.25 3.25 0 0 1-6.5 0m0-14.5a3.25 3.25 0 1 1 6.5 0a3.25 3.25 0 0 1-6.5 0M5.75 6.5a1.75 1.75 0 1 0-.001-3.501A1.75 1.75 0 0 0 5.75 6.5m0 14.5a1.75 1.75 0 1 0-.001-3.501A1.75 1.75 0 0 0 5.75 21m12.5-14.5a1.75 1.75 0 1 0-.001-3.501A1.75 1.75 0 0 0 18.25 6.5"/><path fill="currentColor" d="M5.75 16.75A.75.75 0 0 1 5 16V8a.75.75 0 0 1 1.5 0v8a.75.75 0 0 1-.75.75"/><path fill="currentColor" d="M17.5 8.75v-1H19v1a3.75 3.75 0 0 1-3.75 3.75h-7a1.75 1.75 0 0 0-1.75 1.75H5A3.25 3.25 0 0 1 8.25 11h7a2.25 2.25 0 0 0 2.25-2.25"/></svg>',
// https://icon-sets.iconify.design/octicon/issue-draft-16/
issueDraft: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M14.307 11.655a.75.75 0 0 1 .165 1.048a8 8 0 0 1-1.769 1.77a.75.75 0 0 1-.883-1.214a6.6 6.6 0 0 0 1.44-1.439a.75.75 0 0 1 1.047-.165m-2.652-9.962a.75.75 0 0 1 1.048-.165a8 8 0 0 1 1.77 1.769a.75.75 0 0 1-1.214.883a6.6 6.6 0 0 0-1.439-1.44a.75.75 0 0 1-.165-1.047M6.749.097a8 8 0 0 1 2.502 0a.75.75 0 1 1-.233 1.482a6.6 6.6 0 0 0-2.036 0A.751.751 0 0 1 6.749.097M.955 6.125a.75.75 0 0 1 .624.857a6.6 6.6 0 0 0 0 2.036a.75.75 0 1 1-1.482.233a8 8 0 0 1 0-2.502a.75.75 0 0 1 .858-.624m14.09 0a.75.75 0 0 1 .858.624c.13.829.13 1.673 0 2.502a.75.75 0 1 1-1.482-.233a6.6 6.6 0 0 0 0-2.036a.75.75 0 0 1 .624-.857m-8.92 8.92a.75.75 0 0 1 .857-.624a6.6 6.6 0 0 0 2.036 0a.75.75 0 1 1 .233 1.482c-.829.13-1.673.13-2.502 0a.75.75 0 0 1-.624-.858m-4.432-3.39a.75.75 0 0 1 1.048.165a6.6 6.6 0 0 0 1.439 1.44a.751.751 0 0 1-.883 1.212a8 8 0 0 1-1.77-1.769a.75.75 0 0 1 .166-1.048m2.652-9.962A.75.75 0 0 1 4.18 2.74a6.6 6.6 0 0 0-1.44 1.44a.751.751 0 0 1-1.212-.883a8 8 0 0 1 1.769-1.77a.75.75 0 0 1 1.048.166"/></svg>',
// https://icon-sets.iconify.design/octicon/pin-24/
pin: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m16.114 1.553l6.333 6.333a1.75 1.75 0 0 1-.603 2.869l-1.63.633a5.67 5.67 0 0 0-3.395 3.725l-1.131 3.959a1.75 1.75 0 0 1-2.92.757L9 16.061l-5.595 5.594a.749.749 0 1 1-1.06-1.06L7.939 15l-3.768-3.768a1.75 1.75 0 0 1 .757-2.92l3.959-1.131a5.67 5.67 0 0 0 3.725-3.395l.633-1.63a1.75 1.75 0 0 1 2.869-.603M5.232 10.171l8.597 8.597a.25.25 0 0 0 .417-.108l1.131-3.959A7.17 7.17 0 0 1 19.67 9.99l1.63-.634a.25.25 0 0 0 .086-.409l-6.333-6.333a.25.25 0 0 0-.409.086l-.634 1.63a7.17 7.17 0 0 1-4.711 4.293L5.34 9.754a.25.25 0 0 0-.108.417"/></svg>',
// https://icon-sets.iconify.design/octicon/graph-16/
graph: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M1.5 1.75V13.5h13.75a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75V1.75a.75.75 0 0 1 1.5 0m14.28 2.53l-5.25 5.25a.75.75 0 0 1-1.06 0L7 7.06L4.28 9.78a.75.75 0 0 1-1.042-.018a.75.75 0 0 1-.018-1.042l3.25-3.25a.75.75 0 0 1 1.06 0L10 7.94l4.72-4.72a.75.75 0 0 1 1.042.018a.75.75 0 0 1 .018 1.042"/></svg>',
// https://icon-sets.iconify.design/octicon/download-16/
download: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/><path fill="currentColor" d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06z"/></svg>',
// https://icon-sets.iconify.design/octicon/upload-16/
upload: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/><path fill="currentColor" d="M11.78 4.72a.749.749 0 1 1-1.06 1.06L8.75 3.811V9.5a.75.75 0 0 1-1.5 0V3.811L5.28 5.78a.749.749 0 1 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0z"/></svg>',
// https://icon-sets.iconify.design/octicon/sort-desc-16/
sortDesc: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M0 4.25a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 4.25m0 4a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8.25m0 4a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75M13.5 10h2.25a.25.25 0 0 1 .177.427l-3 3a.25.25 0 0 1-.354 0l-3-3A.25.25 0 0 1 9.75 10H12V3.75a.75.75 0 0 1 1.5 0z"/></svg>',
// https://icon-sets.iconify.design/octicon/sort-asc-16/
sortAsc: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="m12.927 2.573l3 3A.25.25 0 0 1 15.75 6H13.5v6.75a.75.75 0 0 1-1.5 0V6H9.75a.25.25 0 0 1-.177-.427l3-3a.25.25 0 0 1 .354 0M0 12.25a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1-.75-.75m0-4a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8.25m0-4a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 4.25"/></svg>',
// https://icon-sets.iconify.design/octicon/trash-16/
trash: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75M4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.75 1.75 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15M6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25"/></svg>',
// https://icon-sets.iconify.design/octicon/thumbsup-16/
thumbsup: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M8.347.631A.75.75 0 0 1 9.123.26l.238.04a3.25 3.25 0 0 1 2.591 4.098L11.494 6h.665a3.25 3.25 0 0 1 3.118 4.167l-1.135 3.859A2.75 2.75 0 0 1 11.503 16H6.586a3.75 3.75 0 0 1-2.184-.702A1.75 1.75 0 0 1 3 16H1.75A1.75 1.75 0 0 1 0 14.25v-6.5C0 6.784.784 6 1.75 6h3.417a.25.25 0 0 0 .217-.127ZM4.75 13.649l.396.33c.404.337.914.521 1.44.521h4.917a1.25 1.25 0 0 0 1.2-.897l1.135-3.859A1.75 1.75 0 0 0 12.159 7.5H10.5a.75.75 0 0 1-.721-.956l.731-2.558a1.75 1.75 0 0 0-1.127-2.14L6.69 6.611a1.75 1.75 0 0 1-1.523.889H4.75ZM3.25 7.5h-1.5a.25.25 0 0 0-.25.25v6.5c0 .138.112.25.25.25H3a.25.25 0 0 0 .25-.25Z"/></svg>',
// https://icon-sets.iconify.design/octicon/thumbsdown-16/
thumbsdown: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M7.653 15.369a.75.75 0 0 1-.776.371l-.238-.04a3.25 3.25 0 0 1-2.591-4.099L4.506 10h-.665A3.25 3.25 0 0 1 .723 5.833l1.135-3.859A2.75 2.75 0 0 1 4.482 0H9.43c.78.003 1.538.25 2.168.702A1.75 1.75 0 0 1 12.989 0h1.272A1.75 1.75 0 0 1 16 1.75v6.5A1.75 1.75 0 0 1 14.25 10h-3.417a.25.25 0 0 0-.217.127ZM11.25 2.351l-.396-.33a2.25 2.25 0 0 0-1.44-.521H4.496a1.25 1.25 0 0 0-1.199.897L2.162 6.256A1.75 1.75 0 0 0 3.841 8.5H5.5a.75.75 0 0 1 .721.956l-.731 2.558a1.75 1.75 0 0 0 1.127 2.14L9.31 9.389a1.75 1.75 0 0 1 1.523-.889h.417Zm1.5 6.149h1.5a.25.25 0 0 0 .25-.25v-6.5a.25.25 0 0 0-.25-.25H13a.25.25 0 0 0-.25.25Z"/></svg>',
// https://icon-sets.iconify.design/octicon/alert/
warning: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z" fill="currentColor"/></svg>',
// https://icon-sets.iconify.design/octicon/people-16/
people: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M2 5.5a3.5 3.5 0 1 1 5.898 2.549a5.51 5.51 0 0 1 3.034 4.084a.75.75 0 1 1-1.482.235a4 4 0 0 0-7.9 0a.75.75 0 0 1-1.482-.236A5.5 5.5 0 0 1 3.102 8.05A3.5 3.5 0 0 1 2 5.5M11 4a3.001 3.001 0 0 1 2.22 5.018a5 5 0 0 1 2.56 3.012a.749.749 0 0 1-.885.954a.75.75 0 0 1-.549-.514a3.51 3.51 0 0 0-2.522-2.372a.75.75 0 0 1-.574-.73v-.352a.75.75 0 0 1 .416-.672A1.5 1.5 0 0 0 11 5.5A.75.75 0 0 1 11 4m-5.5-.5a2 2 0 1 0-.001 3.999A2 2 0 0 0 5.5 3.5"/></svg>',
// https://icon-sets.iconify.design/octicon/bug-16/
bug: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" d="M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.5 3.5 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332a.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327q0 .15-.025.292c.408.14.764.392 1.029.722l1.968-.787a.75.75 0 0 1 .556 1.392L13 7.258V9h2.25a.75.75 0 0 1 0 1.5H13v.5q-.002.615-.141 1.186l2.17.868a.75.75 0 0 1-.557 1.392l-2.184-.873A5 5 0 0 1 8 16a5 5 0 0 1-4.288-2.427l-2.183.873a.75.75 0 0 1-.558-1.392l2.17-.868A5 5 0 0 1 3 11v-.5H.75a.75.75 0 0 1 0-1.5H3V7.258L.971 6.446a.75.75 0 0 1 .558-1.392l1.967.787c.265-.33.62-.583 1.03-.722a2 2 0 0 1-.026-.292V4.5c0-.951.38-1.814.995-2.444L4.72 1.28a.75.75 0 0 1 0-1.06m.53 6.28a.75.75 0 0 0-.75.75V11a3.5 3.5 0 1 0 7 0V7.25a.75.75 0 0 0-.75-.75ZM6.173 5h3.654A.17.17 0 0 0 10 4.827V4.5a2 2 0 1 0-4 0v.327c0 .096.077.173.173.173"/></svg>',
/* SVG REPO ------------------------------------ */
// https://www.svgrepo.com/svg/154204/world-grid - CC0 License
world: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 64 64"><path d="M32 0C14.327 0 0 14.327 0 32s14.327 32 32 32 32-14.327 32-32S49.673 0 32 0m27.901 20.998h-8.295c-1.6-8.364-5.108-13.617-7.857-16.6a30.17 30.17 0 0 1 16.152 16.6m-9.375 9.572c0 3.899-.356 7.359-.935 10.43H33V22.998h16.963c.354 2.293.563 4.806.563 7.572m-11.539 30.6a30 30 0 0 1-5.987.805V43h16.174c-2.923 12.536-9.617 17.746-10.187 18.17m-12.932.03c-.03-.02-2.927-2.073-5.786-6.846-1.556-2.595-3.282-6.342-4.444-11.354H31v18.975a30 30 0 0 1-4.708-.526 1 1 0 0 0-.237-.249M14.473 30.57c0-2.765.187-5.278.503-7.572H31V41H15.407c-.573-3.05-.934-6.512-.934-10.43M24.53 2.942A30 30 0 0 1 31 2.025v18.973H15.295C17.494 8.886 23.432 3.79 24.53 2.942M33 20.998V2.025c2.059.068 4.065.344 6 .808l.004.004c.095.056 7.89 4.851 10.6 18.161zM20.343 4.358c-2.452 2.968-5.607 8.233-7.042 16.64H4.099a30.17 30.17 0 0 1 16.244-16.64M3.382 22.998h9.615a59 59 0 0 0-.47 7.572c0 3.88.332 7.34.881 10.43H3.381A29.9 29.9 0 0 1 2 32c0-3.135.485-6.159 1.382-9.002M4.098 43h9.708c1.987 9.053 5.864 14.546 8.485 17.379C13.984 57.529 7.315 51.13 4.098 43m39.114 16.818c2.594-2.97 6.119-8.329 7.983-16.818h8.707a30.16 30.16 0 0 1-16.69 16.818M60.62 41h-9.027c.549-3.09.882-6.55.882-10.43 0-2.75-.193-5.268-.528-7.572h8.672A29.9 29.9 0 0 1 62 32c0 3.135-.485 6.157-1.381 9"/></svg>',
// https://www.svgrepo.com/svg/352421/save - CC Attribution License
saveFill: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="-32 0 512 512"><path d="m433.941 129.941-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941M224 416c-35.346 0-64-28.654-64-64s28.654-64 64-64 64 28.654 64 64-28.654 64-64 64m96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A12 12 0 0 1 320 111.48"/></svg>',
// https://www.svgrepo.com/svg/361211/json - MIT
jsonCurlyBrackets: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M6 2.984V2h-.09q-.47 0-.909.185a2.3 2.3 0 0 0-.775.53 2.2 2.2 0 0 0-.493.753v.001a3.5 3.5 0 0 0-.198.83v.002a6 6 0 0 0-.024.863q.018.435.018.869 0 .304-.117.572v.001a1.5 1.5 0 0 1-.765.787 1.4 1.4 0 0 1-.558.115H2v.984h.09q.292 0 .556.121l.001.001q.267.117.455.318l.002.002q.196.195.307.465l.001.002q.117.27.117.566 0 .435-.018.869-.018.443.024.87v.001q.05.425.197.824v.001q.16.41.494.753.335.345.775.53t.91.185H6v-.984h-.09q-.3 0-.563-.115a1.6 1.6 0 0 1-.457-.32 1.7 1.7 0 0 1-.309-.467q-.11-.27-.11-.573 0-.343.011-.672.012-.342 0-.665a5 5 0 0 0-.055-.64 2.7 2.7 0 0 0-.168-.609A2.3 2.3 0 0 0 3.522 8a2.3 2.3 0 0 0 .738-.955q.12-.288.168-.602.05-.315.055-.64.012-.33 0-.666t-.012-.678a1.47 1.47 0 0 1 .877-1.354 1.3 1.3 0 0 1 .563-.121zm4 10.032V14h.09q.47 0 .909-.185t.775-.53.493-.753v-.001q.15-.4.198-.83v-.002q.042-.42.024-.863-.018-.435-.018-.869 0-.304.117-.572v-.001a1.5 1.5 0 0 1 .765-.787 1.4 1.4 0 0 1 .558-.115H14v-.984h-.09q-.293 0-.557-.121l-.001-.001a1.4 1.4 0 0 1-.455-.318l-.002-.002a1.4 1.4 0 0 1-.307-.465v-.002a1.4 1.4 0 0 1-.118-.566q0-.435.018-.869a6 6 0 0 0-.024-.87v-.001a3.5 3.5 0 0 0-.197-.824v-.001a2.2 2.2 0 0 0-.494-.753 2.3 2.3 0 0 0-.775-.53 2.3 2.3 0 0 0-.91-.185H10v.984h.09q.3 0 .562.115.26.123.457.32.19.201.309.467.11.27.11.573 0 .342-.011.672-.012.342 0 .665.006.333.055.64.05.32.168.609a2.3 2.3 0 0 0 .738.955 2.3 2.3 0 0 0-.738.955 2.7 2.7 0 0 0-.168.602q-.05.315-.055.64a9 9 0 0 0 0 .666q.012.336.012.678a1.47 1.47 0 0 1-.877 1.354 1.3 1.3 0 0 1-.563.121z" clip-rule="evenodd"/></svg>',
// https://www.svgrepo.com/svg/510144/progress-0 - MIT
progressZero: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M0 10a5 5 0 0 1 5-5h14a5 5 0 0 1 5 5v4a5 5 0 0 1-5 5H5a5 5 0 0 1-5-5zm5-3a3 3 0 0 0-3 3v4a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-4a3 3 0 0 0-3-3z" clip-rule="evenodd"/></svg>',
// https://www.svgrepo.com/svg/510145/progress-33 - MIT
progressThirtyThree: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M5 5a5 5 0 0 0-5 5v4a5 5 0 0 0 5 5h14a5 5 0 0 0 5-5v-4a5 5 0 0 0-5-5zm-3 5a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v4a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3zm4-1a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2" clip-rule="evenodd"/></svg>',
// https://www.svgrepo.com/svg/510146/progress-66 - MIT
progressSixtySix: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M0 10a5 5 0 0 1 5-5h14a5 5 0 0 1 5 5v4a5 5 0 0 1-5 5H5a5 5 0 0 1-5-5zm5-3a3 3 0 0 0-3 3v4a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-4a3 3 0 0 0-3-3zm5 4a2 2 0 1 1 4 0v2a2 2 0 1 1-4 0zM6 9a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2" clip-rule="evenodd"/></svg>',
// https://www.svgrepo.com/svg/510147/progress-100 - MIT
progressOneHundred: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M5 5a5 5 0 0 0-5 5v4a5 5 0 0 0 5 5h14a5 5 0 0 0 5-5v-4a5 5 0 0 0-5-5zm-3 5a3 3 0 0 1 3-3h14a3 3 0 0 1 3 3v4a3 3 0 0 1-3 3H5a3 3 0 0 1-3-3zm16-1a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2m-8 2a2 2 0 1 1 4 0v2a2 2 0 1 1-4 0zM6 9a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0v-2a2 2 0 0 0-2-2" clip-rule="evenodd"/></svg>',
// https://www.svgrepo.com/svg/489146/smartphone-01 - PD License
smartphone: '<svg class="ypp-svg-reset" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.012M6 5v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2"/></svg>',
// https://www.svgrepo.com/svg/345233/translate - MIT
translate: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M4.545 6.714 4.11 8H3l1.862-5h1.284L8 8H6.833l-.435-1.286zm1.634-.736L5.5 3.956h-.049l-.679 2.022z"/><path d="M0 2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v3h3a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-3H2a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm7.138 9.995q.289.451.63.846c-.748.575-1.673 1.001-2.768 1.292.178.217.451.635.555.867 1.125-.359 2.08-.844 2.886-1.494.777.665 1.739 1.165 2.93 1.472.133-.254.414-.673.629-.89-1.125-.253-2.057-.694-2.82-1.284.681-.747 1.222-1.651 1.621-2.757H14V8h-3v1.047h.765c-.318.844-.74 1.546-1.272 2.13a6 6 0 0 1-.415-.492 2 2 0 0 1-.94.31"/></svg>',
// https://www.svgrepo.com/svg/512899/spotify-162 - PD License
spotifyIconFill: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="currentColor" fill-rule="evenodd" d="M15.915 8.865c-3.223-1.914-8.54-2.09-11.618-1.156a.935.935 0 0 1-.543-1.79c3.533-1.073 9.405-.866 13.116 1.337a.935.935 0 0 1-.955 1.609M15.81 11.7a.78.78 0 0 1-1.073.257c-2.687-1.652-6.785-2.13-9.964-1.165A.78.78 0 0 1 4.32 9.3c3.631-1.102 8.146-.568 11.233 1.329a.78.78 0 0 1 .257 1.071m-1.224 2.723a.623.623 0 0 1-.857.207c-2.348-1.435-5.304-1.759-8.785-.964a.622.622 0 1 1-.277-1.215c3.809-.871 7.076-.496 9.712 1.115.294.18.387.563.207.857M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523.001 10 .001z"/></svg>',
/* OTROS */
// https://svgicons.com/icon/285913/freetube - CC0 1.0
freetubeIconFill: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4.707 0c.9 0 1.629.73 1.629 1.63V24H4.099a4.1 4.1 0 0 1-2.898-1.2A4.1 4.1 0 0 1 0 19.9V1.63C0 .73.73 0 1.63 0ZM24 0v1.94a4.395 4.395 0 0 1-4.395 4.396h-10.6a1.613 1.613 0 0 1-1.613-1.613v-3.11C7.392.723 8.114 0 9.005 0Zm-6.782 11.734a.618.618 0 0 1 0 1.108l-8.902 4.412a.64.64 0 0 1-.924-.573V7.895a.64.64 0 0 1 .924-.573Z"/></svg>',
// https://icon-sets.iconify.design/majesticons/users-line/ - MIT
// people: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="9" cy="9" r="4"/><path d="M16 19c0-3.314-3.134-6-7-6s-7 2.686-7 6m13-6a4 4 0 1 0-3-6.646"/><path d="M22 19c0-3.314-3.134-6-7-6c-.807 0-2.103-.293-3-1.235"/></g></svg>',
// https://icon-sets.iconify.design/svg-spinners/blocks-wave/ - MIT https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE
spinner: '<svg class="ypp-svg-reset ypp-fill-currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="7.33" height="7.33" x="1" y="1" fill="currentColor"><animate id="SVGzjrPLenI" attributeName="x" begin="0;SVGXAURnSRI.end+0.2s" dur="0.6s" values="1;4;1"/><animate attributeName="y" begin="0;SVGXAURnSRI.end+0.2s" dur="0.6s" values="1;4;1"/><animate attributeName="width" begin="0;SVGXAURnSRI.end+0.2s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="0;SVGXAURnSRI.end+0.2s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="8.33" y="1" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="1;4;1"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="1" y="8.33" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="1;4;1"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.1s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="15.66" y="1" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="1;4;1"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="8.33" y="8.33" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="1" y="15.66" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="1;4;1"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.2s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="15.66" y="8.33" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="8.33" y="15.66" fill="currentColor"><animate attributeName="x" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="8.33;11.33;8.33"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.3s" dur="0.6s" values="7.33;1.33;7.33"/></rect><rect width="7.33" height="7.33" x="15.66" y="15.66" fill="currentColor"><animate id="SVGXAURnSRI" attributeName="x" begin="SVGzjrPLenI.begin+0.4s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="y" begin="SVGzjrPLenI.begin+0.4s" dur="0.6s" values="15.66;18.66;15.66"/><animate attributeName="width" begin="SVGzjrPLenI.begin+0.4s" dur="0.6s" values="7.33;1.33;7.33"/><animate attributeName="height" begin="SVGzjrPLenI.begin+0.4s" dur="0.6s" values="7.33;1.33;7.33"/></rect></svg>',
/* PERSONALIZADOS - Sin licencia */
live: '<svg class="ypp-svg-reset ypp-fill-currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="red" opacity="0.2"/><circle cx="12" cy="12" r="5" fill="red"/></svg>',
check: '<svg class="ypp-svg-reset ypp-fill-currentColor" 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>',
bookmarkFill: '<svg class="ypp-svg-reset ypp-fill-none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><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>',
playlist: '<svg class="ypp-svg-reset ypp-fill-currentColor" viewBox="0 0 24 24"><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-svg-reset ypp-fill-currentColor" viewBox="0 0 20 20" 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>',
error: '<svg class="ypp-svg-reset ypp-fill-currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><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>',
video: '<svg class="ypp-svg-reset ypp-fill-currentColor" viewBox="0 0 24 24"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>',
calendar: '<svg class="ypp-svg-reset ypp-fill-currentColor" viewBox="0 0 24 24"><path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>',
user: '<svg class="ypp-svg-reset ypp-fill-currentColor" viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>',
hourglass: '<svg class="ypp-svg-reset ypp-fill-currentColor" viewBox="0 0 24 24"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2h-12zm10 14.5V20H8v-3.5l4-4 4 4zm-4-5l-4-4V4h8v3.5l-4 4z"/></svg>',
fire: '<svg class="ypp-svg-reset ypp-fill-currentColor" viewBox="0 0 24 24"><path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/></svg>',
ice: '<svg class="ypp-svg-reset ypp-fill-currentColor" viewBox="0 0 24 24"><path d="M22 11h-4.17l3.24-3.24-1.41-1.41L15 11h-2V9l4.65-4.65-1.41-1.41L13 6.17V2h-2v4.17L7.76 2.93 6.35 4.34 11 9v2H9L4.35 6.35 2.94 7.76 6.17 11H2v2h4.17l-3.24 3.24 1.41 1.41L9 13h2v2l-4.65 4.65 1.41 1.41L11 17.83V22h2v-4.17l3.24 3.24 1.41-1.41L13 15v-2h2l4.65 4.65 1.41-1.41L17.83 13H22v-2z"/></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', '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 `rgb(${COLOR_CACHE.dark.keyPoints[0].r}, ${COLOR_CACHE.dark.keyPoints[0].g}, ${COLOR_CACHE.dark.keyPoints[0].b})`;
if (percent >= (cachedSettings?.staticFinishPercent ?? 100)) return 'var(--ypp-success)';
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})`;
}
function getProgressColorForText(percent) {
// Usar variables CSS del sistema para mantener contraste AAA (≥7:1)
if (percent <= 0) return 'var(--ypp-danger-text)';
if (percent >= (cachedSettings?.staticFinishPercent ?? 100)) return 'var(--ypp-success-text)';
// Transición con 5 rangos para coincidir mejor con getProgressColor (0%, 33%, 66%, 95%)
if (percent <= 20) return 'var(--ypp-danger-text)';
else if (percent <= 40) return 'var(--ypp-danger-warning-text)';
else if (percent <= 60) return 'var(--ypp-warning-text)';
else if (percent <= 80) return 'var(--ypp-warning-success-text)';
else return 'var(--ypp-success-text)';
}
// ------------------------------------------
// 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é.
* 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...');
if (navigator.storage && navigator.storage.persist) {
try {
const isPersisted = await navigator.storage.persist();
logInfo(`Almacenamiento persistente: ${isPersisted ? 'CONCEDIDO' : 'DENEGADO'}`);
} catch (persistErr) {
logWarn('Error al solicitar almacenamiento persistente:', persistErr);
}
}
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 durante inicializacion
let currentPageType = null; // Tipo de página actual (home, watch, shorts, playlist, etc.)
let lastHandledVideoId = null; // Último video ID procesado en navegación
let lastHandledPageType = null; // Último tipo de página procesado en navegación
let cachedSettings = null; // Configuración del usuario (obtenida de GM_getValue)
// ------------------------------------------
// MARK: 📢 Ad Selectors
// ------------------------------------------
const AdSelectors = Object.freeze({
// Estas clases se aplican al elemento <video> o a su contenedor directo cuando hay un anuncio reproduciéndose.
// --- Clases del Player (Watch / Miniplayer) ---
// 'ad-created' NO es confiable en estos player porque se mantiene en el DOM incluso cuando no hay anuncio.
playerAdClasses: ['ad-showing', 'ad-interrupting'],
// --- Clases de Previews / Grid ---
previewAdClasses: ['ad-showing', 'ad-interrupting', 'ad-created'],
// --- Clases de Shorts ---
shortsAdClasses: ['ad-created', 'ad-showing', 'ad-interrupting'],
// --- 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)
'video-display-full-layout-view-model', // Anuncio en el grid (homepage)
'feed-ad-metadata-view-model', // Metadatos de anuncio en grid (homepage)
],
// --- Elementos Específicos del Anuncio ---
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 (ES)
'[aria-label*="My Ad Center"]', // Atributo para botón de info (EN)
'ad-badge-view-model', // Badge de anuncio moderno
'[role="link"][class*="ad"]', // Links con clase ad
],
// --- UI Activa (Detección rápida, solo elementos internos del player) ---
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 - eliminado: es un elemento de página, .ytd-in-feed-ad-layout-renderer tambien. Ambos pueden existir en DOM sin presencia de anuncios activos
],
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
'.ytp-ad-badge__text--clean-player', // Badge "Patrocinado" detectable rápido
]
});
const AdSelectorText = Object.freeze({
inPlayerAdContainers: AdSelectors.inPlayerAdContainers.join(', '),
inFeedAdContainers: AdSelectors.inFeedAdContainers.join(', '),
activeAdUi: AdSelectors.activeAdUi.join(','),
clickableAdBadgesWithinRichItem: AdSelectors.clickableAdBadgesWithinRichItem.join(', '),
});
// Caches globales para reducir impacto en el hilo principal
/** @type {WeakMap<Element, { val: boolean, ts: number }>} Cache para deteccion de anuncios por nodo */
const _adContainerCache = new WeakMap();
// Sincronización en pipeline de render (Frame Ticking Memoization)
let _adDetectorFrameId = 0;
const _adDetectorTick = () => {
_adDetectorFrameId++;
requestAnimationFrame(_adDetectorTick);
};
requestAnimationFrame(_adDetectorTick);
let _lastAdRoot = null;
let _lastAdResult = null;
let _lastAdRootFrame = -1;
/** @type {Map<string, { info: any, ts: number }>} Cache global de metadatos (TTL 5 min) */
const _videoMetadataCache = new Map();
const _MAX_VIDEO_METADATA_CACHE_SIZE = 100;
// MARK: 📢 Ad Detector
const _adIdCache = new Map();
const _MAX_AD_ID_CACHE_SIZE = 50;
const AdDetector = Object.freeze({
/**
* @param {Element|null|undefined} node
* @returns {boolean}
*/
isNodeWithinAdContainer(node) {
try {
if (!node || typeof node.closest !== 'function') return false;
// --- 0. Verificación PRIORITARIA de clases de player (sin cache) ---
// Las clases de anuncio en players (ad-created, ad-showing) pueden aparecer
// DINÁMICAMENTE después de que el video inicia. No usar cache para estas.
// 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;
if (this.findVisibleAdUi(moviePlayer)) return true;
}
// Reproductor de Shorts
const shortsPlayer = DOMHelpers.closestComposed(node, `#${IDs.SHORTS_PLAYER}`);
if (shortsPlayer?.classList) {
if (AdSelectors.shortsAdClasses.some(c => shortsPlayer.classList.contains(c))) return true;
// Refuerzo para Shorts: Revisar el contenedor principal
const reelRenderer = DOMHelpers.closestComposed(shortsPlayer, 'ytd-reel-video-renderer');
if (reelRenderer) {
if (reelRenderer.hasAttribute('is-ads-overlay')) return true;
if (reelRenderer.querySelector('ytd-ad-slot-renderer')) return true;
}
if (this.findVisibleAdUi(shortsPlayer)) 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;
if (this.findVisibleAdUi(inlinePlayer)) return true;
try {
const videoId = getPlayerVideoId(inlinePlayer);
if (videoId && this.isVideoIdAnAd(videoId)) return true;
} catch (_) { }
}
// --- 1. Cache para contenedores In-Feed (estáticos, no cambian dinámicamente) ---
const now = Date.now();
const cached = _adContainerCache.get(node);
if (cached && (now - cached.ts < 250)) return cached.val;
const check = () => {
// Contenedores de Anuncios In-Feed / Layouts / Homes
// Solo usamos inFeedAdContainers (no inAnyAdContainers).
// inPlayerAdContainers (.ytp-ad-module, .video-ads, #player-ads) son nodos PERMANENTES
// en el DOM del player — están presentes aunque no haya ningún anuncio activo.
// Usarlos en closestComposed causa FALSE POSITIVES para cualquier <video> dentro del player.
if (AdSelectorText.inFeedAdContainers) {
if (DOMHelpers.closestComposed(node, AdSelectorText.inFeedAdContainers)) return true;
}
return false;
};
const result = check();
// Cache solo para in-feed containers (no para players que cambian dinámicamente)
if (node.isConnected) {
_adContainerCache.set(node, { val: result, ts: now });
}
if (result) {
logWarn('AdDetector', `🚫 Nodo bloqueado: detectado en contenedor de anuncios.`);
return true;
}
// --- 2. 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;
// Hit de Memoización: Evita que el mismo root se re-evalúe repetidas veces
if (root === _lastAdRoot && _adDetectorFrameId === _lastAdRootFrame) return _lastAdResult;
_lastAdRoot = root;
_lastAdRootFrame = _adDetectorFrameId;
// Consulta nativa múltiple agrupada + post-validación en layout
const els = root.querySelectorAll(AdSelectorText.activeAdUi);
for (const el of els) {
if (isVisiblyDisplayed(el)) {
// Validación de contenido para contenedores genéricos de módulos de anuncios
if (el.matches('.video-ads, .ytp-ad-module')) {
if (el.children.length > 0) {
_lastAdResult = el;
return el;
}
continue;
}
_lastAdResult = el;
return el;
}
}
_lastAdResult = null;
return null;
} catch (_) {
return null;
}
},
/**
* Comprueba si un videoId específico está vinculado a un anuncio en la página actual.
* Útil para detectar anuncios en previsualizaciones (previews) que están aisladas del DOM del grid.
* @param {string} videoId
* @returns {boolean}
*/
isVideoIdAnAd(videoId) {
if (!videoId) return false;
// Caché de resultados (2 segundos) para evitar spam de querySelectorAll en el DOM
const now = Date.now();
const cached = _adIdCache.get(videoId);
if (cached && (now - cached.ts < 2000)) {
// LRU: mover al final al acceder (re-insertar para mantener orden)
_adIdCache.delete(videoId);
_adIdCache.set(videoId, cached);
return cached.val;
}
try {
// R-Search (Reverse Search): limitamos el scope dramáticamente para no iterar el DOM entero con queries complejas (O(k))
const adContainers = DOMHelpers.get('ad:containers', () => document.querySelectorAll('.ytd-in-feed-ad-layout-renderer, .video-ads, ytd-display-ad-renderer, #masthead-ad'), 500);
for (const adCont of adContainers) {
if (adCont.querySelector(`a[href*="${videoId}"], ytd-thumbnail[video-id="${videoId}"], [id="thumbnail"][href*="${videoId}"]`)) {
// LRU Eviction guard
if (_adIdCache.size >= _MAX_AD_ID_CACHE_SIZE) {
const oldestKey = _adIdCache.keys().next().value;
_adIdCache.delete(oldestKey);
}
_adIdCache.set(videoId, { val: true, ts: now });
return true;
}
}
} catch (_) { }
// LRU eviction para resultado negativo
if (_adIdCache.size >= _MAX_AD_ID_CACHE_SIZE) {
const oldestKey = _adIdCache.keys().next().value;
_adIdCache.delete(oldestKey);
}
_adIdCache.set(videoId, { val: false, ts: now });
return 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 instanceof 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;
}
/**
* Aplica validación y restricción numérica automática a un campo de entrada.
* Sanitiza caracteres no permitidos, controla rangos y formatea ceros a la izquierda.
*
* @param {HTMLInputElement} el - Elemento input a proteger.
* @param {Object} [options] - Opciones de configuración.
* @param {number} [options.min=0] - Valor mínimo permitido.
* @param {number} [options.max] - Valor máximo permitido.
* @param {() => void} [options.onInput] - Callback opcional tras cada cambio válido.
*/
function applyNumericClamping(el, { min = 0, max, onInput } = {}) {
if (!(el instanceof Element)) return;
const clamp = () => {
// Sanitizar: Solo dígitos
let sanitized = el.value.replace(/\D/g, '');
if (el.value !== sanitized) {
el.value = sanitized;
}
let val = parseInt(el.value, 10);
if (isNaN(val)) return;
// Aplicar límites
if (val < min) val = min;
if (max !== undefined && val > max) val = max;
// Formateo visual (evitar ceros a la izquierda como "020")
const formatted = String(val);
if (el.value !== formatted) {
el.value = formatted;
}
if (onInput) onInput();
};
el.addEventListener('input', clamp);
}
// MARK: 🔧 UI Helpers
/**
* Crea un botón con submenú desplegable hacia arriba (para acciones del footer).
* Soporta cierre por click fuera y garantiza un solo menú abierto a la vez.
*
* @param {{
* label: string,
* icon: string,
* options: Array<{ label: string, icon: string, action: () => Promise<void> | void }>,
* triggerClassName?: string,
* triggerId?: string,
* optionButtonClassName?: string,
* getCurrentlyOpen: () => (null | (() => void)),
* setCurrentlyOpen: (fn: null | (() => void)) => void
* }} config - Configuración del menú.
* @returns {HTMLElement}
*/
function createFooterActionMenu(config) {
const {
label,
icon,
options,
triggerClassName = 'ypp-btn ypp-btn-secondary ypp-shadow-md',
triggerId = '',
optionButtonClassName = 'ypp-btn ypp-btn-outline-secondary ypp-footer-action-menu-option',
getCurrentlyOpen,
setCurrentlyOpen
} = config;
const wrapper = createElement('div', { className: 'ypp-footer-action-menu' });
const trigger = createElement('button', {
className: triggerClassName,
id: triggerId,
html: `${icon} ${label}`,
atribute: { type: 'button', 'aria-expanded': 'false' }
});
const list = createElement('div', {
className: 'ypp-footer-action-menu-list ypp-d-none',
atribute: { role: 'menu' }
});
let isOpen = false;
const onOutsideClick = (event) => {
if (!wrapper.contains(event.target)) closeMenu();
};
const closeMenu = () => {
if (!isOpen) return;
isOpen = false;
list.classList.add('ypp-d-none');
trigger.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', onOutsideClick);
if (getCurrentlyOpen() === closeMenu) setCurrentlyOpen(null);
};
const openMenu = () => {
const current = getCurrentlyOpen();
if (typeof current === 'function') current();
isOpen = true;
list.classList.remove('ypp-d-none');
trigger.setAttribute('aria-expanded', 'true');
setCurrentlyOpen(closeMenu);
// Defer para que el click actual no lo cierre inmediatamente
requestAnimationFrame(() => document.addEventListener('click', onOutsideClick, { once: true }));
};
options.forEach((option) => {
const optionButton = createElement('button', {
className: optionButtonClassName,
html: `${option.icon} ${option.label}`,
atribute: { type: 'button', role: 'menuitem' }
});
optionButton.addEventListener('click', async (event) => {
event.stopPropagation();
closeMenu();
await option.action();
});
list.appendChild(optionButton);
});
trigger.addEventListener('click', (event) => {
event.stopPropagation();
isOpen ? closeMenu() : openMenu();
});
wrapper.append(trigger, list);
return wrapper;
}
// 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 requestAnimationFrame para evitar lag (60+fps sin lock de render)
let ticking = false;
this.scrollHandler = () => {
if (!ticking) {
ticking = true;
window.requestAnimationFrame(() => {
this._onScroll();
ticking = false;
});
}
};
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 jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const fileSizeMB = blob.size / (1024 * 1024);
// Validar tamaño del archivo (GitHub límite: 100MB via API, 25MB via navegador)
// Usamos 50MB como límite conservador para evitar advertencias de Git
// https://docs.github.com/en/repositories/working-with-files/managing-large-files/about-large-files-on-github
if (fileSizeMB > 50) {
logWarn('exportDataToFile', `Archivo demasiado grande: ${fileSizeMB.toFixed(2)}MB (límite recomendado: 50MB)`);
showFloatingToast(`${SVG_ICONS.warning} ${t('fileTooLarge', { size: fileSizeMB.toFixed(2), limit: 50 })}`);
return;
}
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 (${fileSizeMB.toFixed(2)}MB) 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 backupToGitHubGist = async (data, initialModeSettings, isManual) => {
// Fresh pull from storage to prevent stale gistId/token drift
const fullSettings = await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings);
const modeSettings = { ...fullSettings.gist, ...(initialModeSettings || {}) };
return new Promise((resolve) => {
const jsonString = JSON.stringify(data, null, 2);
const fileSizeMB = jsonString.length / (1024 * 1024);
// Validar tamaño del archivo (Gist límite: 10MB)
if (fileSizeMB > 10) {
logWarn('backupToGitHubGist', `Archivo demasiado grande para Gist: ${fileSizeMB.toFixed(2)}MB (límite: 10MB)`);
showFloatingToast(`${SVG_ICONS.warning} ${t('fileTooLargeGist', { size: fileSizeMB.toFixed(2), limit: 10 })}`);
resolve(false);
return;
}
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: jsonString
}
}
};
const gistId = modeSettings.id;
const cleanToken = (modeSettings.token || '').trim().replace(/[^\x00-\xFF]/g, '');
const isValidToken = /^[a-zA-Z0-9_]+$/.test(cleanToken);
if (!isValidToken) {
logError('backupToGitHubGist', 'Token contiene caracteres inválidos');
showFloatingToast(`${SVG_ICONS.warning} ${t('githubInvalidToken')}`);
return resolve(false);
}
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 ${cleanToken}`,
'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
// 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 (fullSettings.autoDeleteToken) {
storedSettings.gist.token = '';
}
await GM_setValue(CONFIG.STORAGE_KEYS.github, storedSettings);
logInfo('backupToGitHubGist', 'Respaldo en GitHub exitoso:', result.id.slice(0, 10) + '...');
resolve(true);
} else {
logError('backupToGitHubGist', '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('backupToGitHubGist', 'Error de red en respaldo GitHub:', err);
resolve(false);
}
});
});
};
/**
* Realiza un respaldo de los datos en un repositorio privado de GitHub.
*/
const backupToGithubRepository = async (data, initialModeSettings, isManual) => {
// Fresh pull from storage to prevent token drift
const fullSettings = await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings);
const modeSettings = { ...fullSettings.repo, ...(initialModeSettings || {}) };
return new Promise((resolve) => {
const { owner: repoOwner, name: repoName, token } = modeSettings;
if (!repoOwner || !repoName) {
if (isManual) showFloatingToast(`${SVG_ICONS.warning} ${t('githubBackupError')}`);
return resolve(false);
}
const jsonString = JSON.stringify(data, null, 2);
const fileSizeMB = jsonString.length / (1024 * 1024);
// Validar tamaño del archivo (GitHub límite: 100MB via API, 25MB via navegador)
// Usamos 50MB como límite conservador para evitar advertencias de Git
if (fileSizeMB > 50) {
logWarn('backupToGithubRepository', `Archivo demasiado grande para repositorio: ${fileSizeMB.toFixed(2)}MB (límite recomendado: 50MB)`);
if (isManual) {
showFloatingToast(`${SVG_ICONS.warning} ${t('fileTooLarge', { size: fileSizeMB.toFixed(2), limit: 50 })}`);
}
resolve(false);
return;
}
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(`${SVG_ICONS.spinner} ${t('githubRepoCheck')}...`);
// 1. Verificar privacidad del repositorio
GM_xmlhttpRequest({
method: 'GET',
url: baseUrl,
headers: headers,
onload: (repoResponse) => {
if (repoResponse.status !== 200) {
logError('backupToGithubRepository', '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('backupToGithubRepository', '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 jsonString = JSON.stringify(data, null, 2);
const encoder = new TextEncoder();
const uint8Array = encoder.encode(jsonString);
let binaryString = '';
for (let i = 0; i < uint8Array.length; i++) {
binaryString += String.fromCharCode(uint8Array[i]);
}
const contentBase64 = btoa(binaryString);
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 (fullSettings.autoDeleteToken) {
storedSettings.repo.token = '';
}
await GM_setValue(CONFIG.STORAGE_KEYS.github, storedSettings);
logInfo('backupToGithubRepository', 'Respaldo en repositorio exitoso');
resolve(true);
} else {
logError('backupToGithubRepository', 'Error al subir archivo:', putResponse.status);
if (isManual) showFloatingToast(`${SVG_ICONS.error} ${t('githubBackupError')} (${putResponse.status})`);
resolve(false);
}
},
onerror: (err) => {
logError('backupToGithubRepository', 'Error de red:', err);
resolve(false);
}
});
},
onerror: (err) => {
logError('backupToGithubRepository', 'Error obteniendo SHA:', err);
resolve(false);
}
});
},
onerror: (err) => {
logError('backupToGithubRepository', '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
? { ...(githubSettings[type] || {}), ...settingsOverride }
: (githubSettings[type] || CONFIG.defaultGithubSettings[type] || {});
if (!modeSettings.token) {
showFloatingToast(`${SVG_ICONS.warning} ${t('githubTokenRequired')}`);
return false;
}
const cleanToken = (modeSettings.token || '').trim().replace(/[^\x00-\xFF]/g, '');
const isValidToken = /^[a-zA-Z0-9_]+$/.test(cleanToken);
if (!isValidToken) {
logError('performRemoteBackup', 'Token contiene caracteres inválidos');
showFloatingToast(`${SVG_ICONS.warning} ${t('githubInvalidToken')}`);
return false;
}
const data = await getSyncData(isManual ? 'manual' : 'auto');
if (!data) {
showFloatingToast(`${SVG_ICONS.warning} ${t('noSavedVideos')}`);
return false;
}
showFloatingToast(`${SVG_ICONS.spinner} ${t('githubBackupNow')} (${type})...`);
let success = false;
if (type === 'repo') {
success = await backupToGithubRepository(data, modeSettings, isManual);
} else {
success = await backupToGitHubGist(data, modeSettings, isManual);
}
if (success) {
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.linkExternal} ${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 () => {
logLog('checkGitHubBackup', 'Verificando respaldo automático...');
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) {
logWarn('checkGitHubBackup', `${type} omitido: autoBackup desactivado`);
continue;
}
if (!s.token) {
logWarn('checkGitHubBackup', `${type} omitido: falta token`);
continue;
}
// Concurrency Lock: Evitar que múltiples pestañas disparen el respaldo simultáneamente
const lastAttempt = s.lastSyncAttempt || 0;
const CONCURRENCY_LOCK_MS = 4 * 60 * 1000; // Bloqueo de 4 minutos (menor que el intervalo de 5m de chequeo)
if (now - lastAttempt < CONCURRENCY_LOCK_MS) {
logLog('checkGitHubBackup', `Omitiendo: Otro tab inició sincronización (${type}) recientemente`);
continue;
}
logLog('checkGitHubBackup', `Auto backup (${type}): ${s.autoBackup}`);
const intervalMs = (s.interval || 24) * 60 * 60 * 1000;
logLog('checkGitHubBackup', `Intervalo de respaldo (${type}): ${intervalMs}ms`);
const timeSinceLastSync = now - s.lastSync;
logLog('checkGitHubBackup', `Tiempo desde último respaldo (${type}): ${timeSinceLastSync}ms`);
if (timeSinceLastSync >= intervalMs) {
try {
logInfo('checkGitHubBackup', `Iniciando respaldo automático (${type})...`);
// Adquirir bloqueo inmediatamente en el storage
const fresh = await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings);
fresh[type] = fresh[type] || {};
fresh[type].lastSyncAttempt = now;
await GM_setValue(CONFIG.STORAGE_KEYS.github, fresh);
await performRemoteBackup(type, false);
logInfo('checkGitHubBackup', `Respaldo (${type}) completado correctamente`);
} catch (err) {
logError('checkGitHubBackup', `Error en respaldo (${type})`, err);
}
} 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 > 50 * 1024 * 1024) { // 50MB 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 = data.completionHistory;
// Migración silenciosa de seguridad: si es un array o indefinido, inicializa la estructura analítica O(1).
if (!resolvedCompletionHistory || Array.isArray(resolvedCompletionHistory)) {
const arr = Array.isArray(resolvedCompletionHistory) ? resolvedCompletionHistory : [];
resolvedCompletionHistory = {
events: [...arr],
daily: {},
total: arr.length
};
}
// Regenerar daily si está vacío pero hay eventos (caso de datos migrados)
if (resolvedCompletionHistory.events.length > 0 && Object.keys(resolvedCompletionHistory.daily).length === 0) {
resolvedCompletionHistory.daily = {};
for (const ts of resolvedCompletionHistory.events) {
const day = new Date(ts).toISOString().slice(0, 10);
resolvedCompletionHistory.daily[day] = (resolvedCompletionHistory.daily[day] || 0) + 1;
}
}
// Siembra idempotente de historial
if (resolvedIsCompleted && resolvedCompletionHistory.events.length === 0 && resolvedTimeWatched) {
resolvedCompletionHistory.events = [resolvedTimeWatched];
resolvedCompletionHistory.total = 1;
}
if (resolvedCompletionHistory.events.length > 0) {
resolvedCompletionHistory.events = resolvedCompletionHistory.events
.filter(ts => Number.isFinite(ts) && ts > 0 && ts < Date.now() + 60000)
.sort((a, b) => a - b)
}
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: 🔄 Insert Completion Event
/**
* Helper encargado de inyectar el evento de completado respetando
* segregación SQRS {events, daily, total} para mapas de calor.
* @param {Object} history - Histórico actual.
* @param {number} now - Timestamp exacto actual.
* @param {number} duration - Duración original del video.
*/
function insertCompletionEvent(history, now, duration = 0) {
const base = (!history || Array.isArray(history))
? {
events: Array.isArray(history) ? [...history] : [],
daily: {},
total: Array.isArray(history) ? history.length : 0
}
: {
events: [...(history.events || [])],
daily: { ...(history.daily || {}) },
total: history.total || 0
};
const last = base.events.at(-1);
const debounceThreshold =
duration > 0
? Math.max(1000, Math.min(5000, duration * 0.1 * 1000))
: 2000;
if (!last || now - last > debounceThreshold) {
const day = new Date(now).toISOString().slice(0, 10);
base.events.push(now);
base.daily[day] = (base.daily[day] || 0) + 1;
base.total += 1;
// const MAX_EVENTS = 1000;
// if (base.events.length > MAX_EVENTS) {
// base.events.shift(); // FIFO: elimina el más antiguo si supera el límite
// }
}
return base;
}
// 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(player, 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 progress = duration > 0 ? (currentTime / duration) : 0;
const defaultPercent = (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent) / 100;
const isFinished = duration > 0 && (
progress >= defaultPercent ||
(duration <= 60 && currentTime >= duration - 0.75)
);
// Si tiene tiempo fijo, no sobreescribir
if (sourceData && sourceData.forceResumeTime > 0) {
if (isFinished) {
logLog(logContext, `Video ${videoId} completado, manteniendo tiempo fijo`);
const session = activeProcessingSessions.get(videoEl);
let history = sourceData.completionHistory;
if (session && !session.hasLoggedCompletion) {
history = insertCompletionEvent(history, now, duration);
session.hasLoggedCompletion = true;
logInfo(logContext, `Registro de finalización añadido para video con tiempo fijo: ${videoId}`);
}
// Normalizar para asegurar Format A y marcar como completado, preservando el historial
const base = normalizeVideoData({
...sourceData,
isCompleted: true,
watchProgress: 0,
completionHistory: history
});
await Storage.set(videoId, base);
} else if (currentTime < 1) {
// Detectar si el usuario pulsó "Replay" (el tiempo saltó a < 1s en una sesión activa tras completarse)
const session = activeProcessingSessions.get(videoEl);
if (session && session.hasLoggedCompletion) {
logLog(logContext, `Replay detectado para video con tiempo fijo (${videoId}). Re-aplicando seek a ${sourceData.forceResumeTime}s`);
// Resetear bandera para permitir registrar la siguiente finalización
session.hasLoggedCompletion = false;
// Re-aplicar el resume (que se encarga de re-notificar el seek)
PlaybackController.resume(player, videoId, videoEl, sourceData, session.type, session);
return { success: false, reason: 'replay_fixed_time_reapplied' };
}
}
return { success: false, reason: 'fixed_time_no_overwrite' };
}
let finalIsCompleted = isFinished;
let finalWatchProgress = currentTime;
// Protección contra el reset automático de YouTube al finalizar un video.
// Cuando YouTube termina un video, puede reiniciar el player a 0 antes de pasar al siguiente.
// Si el dato guardado ya dice isCompleted=true y el tiempo actual es muy bajo (<5s),
// es casi seguro un reset automático -> preservamos el estado completado.
// NOTA: Una vez que el video cruce la marca de los 5s, el escudo se desactivará automáticamente.
// El script entenderá que realmente estás re-viendo el video por voluntad propia y comenzará a guardar nuevo progreso (5s, 6s, 7s...),
// retirando la marca de "completado" hasta que vuelvas a terminarlo.
if (!isFinished && sourceData?.isCompleted && currentTime < 5) {
logLog(logContext, `🛡️ Shield auto-reset detectado para video completado (${videoId}) en ${currentTime}s. Preservando estado.`);
finalIsCompleted = true;
finalWatchProgress = sourceData.watchProgress;
}
const videoData = {
...sourceData,
...Object.fromEntries(Object.entries(videoInfo).filter(([k, v]) => v !== null || k.startsWith('lastViewed') || k === 'playlistTitle')),
watchProgress: finalWatchProgress,
timeWatched: now,
type: 'video',
isCompleted: finalIsCompleted,
completionHistory: sourceData?.completionHistory
};
const session = activeProcessingSessions.get(videoEl);
if (isFinished && session && !session.hasLoggedCompletion) {
videoData.completionHistory = insertCompletionEvent(videoData.completionHistory, now, duration);
session.hasLoggedCompletion = true;
} else if (session && session.hasLoggedCompletion && !cachedSettings.countOncePerSession) {
const prev = sourceData?.watchProgress || 0;
const delta = prev - currentTime;
const isNearStart = currentTime < Math.max(1, duration * 0.2);
const wasNearEnd = prev > (duration * 0.75);
const wasLoop = delta > (duration * 0.3) || (isNearStart && wasNearEnd);
if (wasLoop) {
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(player, currentTime, videoInfo, videoEl, options = {}) {
return await internalSaveRegularVideo(player, 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(player, currentTime, videoInfo, videoEl, options = {}) {
return await internalSaveRegularVideo(player, 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(player, 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 progress = duration > 0 ? (currentTime / duration) : 0;
const defaultPercent = (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent) / 100;
const isFinished = duration > 0 && (
progress >= defaultPercent ||
(duration <= 60 && currentTime >= duration - 0.75)
);
// Si tiene tiempo fijo, no sobreescribir
if (sourceData && sourceData.forceResumeTime > 0) {
if (isFinished) {
logLog('saveShortsVideo', `Short ${videoId} completado, manteniendo tiempo fijo`);
const session = activeProcessingSessions.get(videoEl);
let history = sourceData.completionHistory;
if (session && !session.hasLoggedCompletion) {
history = insertCompletionEvent(history, now, duration);
session.hasLoggedCompletion = true;
logInfo('saveShortsVideo', `Registro de finalización añadido para short con tiempo fijo: ${videoId}`);
}
// Normalizar para asegurar Format A y marcar como completado, preservando el historial
const base = normalizeVideoData({
...sourceData,
isCompleted: true,
watchProgress: 0,
completionHistory: history
});
await Storage.set(videoId, base);
} else if (currentTime < 1) {
// Detectar si el usuario pulsó "Replay" (el tiempo saltó a < 1s en una sesión activa tras completarse)
const session = activeProcessingSessions.get(videoEl);
if (session && session.hasLoggedCompletion) {
logLog('saveShortsVideo', `Replay detectado para short con tiempo fijo (${videoId}). Re-aplicando seek a ${sourceData.forceResumeTime}s`);
// Resetear bandera para permitir registrar la siguiente finalización
session.hasLoggedCompletion = false;
// Re-aplicar el resume
PlaybackController.resume(player, videoId, videoEl, sourceData, session.type, session);
return { success: false, reason: 'replay_fixed_time_reapplied' };
}
}
return { success: false, reason: 'fixed_time_no_overwrite' };
}
let finalIsCompleted = isFinished;
let finalWatchProgress = currentTime;
// Protección Shorts: si ya estaba completado y el tiempo actual es bajo, es un reset automático de YouTube.
if (!isFinished && sourceData?.isCompleted && currentTime < 2) {
logLog('saveShortsVideo', `🛡️ Shield auto-reset detectado para Short completado (${videoId}) en ${currentTime}s. Preservando estado.`);
finalIsCompleted = true;
finalWatchProgress = sourceData.watchProgress;
}
const videoData = {
...sourceData,
...Object.fromEntries(Object.entries(videoInfo).filter(([k, v]) => v !== null || k.startsWith('lastViewed') || k === 'playlistTitle')),
watchProgress: finalWatchProgress,
timeWatched: now,
type: 'shorts',
isCompleted: finalIsCompleted,
completionHistory: sourceData?.completionHistory
};
const session = activeProcessingSessions.get(videoEl);
if (isFinished && session && !session.hasLoggedCompletion) {
videoData.completionHistory = insertCompletionEvent(videoData.completionHistory, now, duration);
session.hasLoggedCompletion = true;
} else if (session && session.hasLoggedCompletion && !cachedSettings.countOncePerSession) {
const prev = sourceData?.watchProgress || 0;
const delta = prev - currentTime;
const isNearStart = currentTime < Math.max(1, duration * 0.2);
const wasNearEnd = prev > (duration * 0.75);
const wasLoop = delta > (duration * 0.3) || (isNearStart && wasNearEnd);
if (wasLoop) {
session.hasLoggedCompletion = false;
}
}
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(player, currentTime, videoInfo, videoEl, previewType, options = {}) {
const { videoId, lengthSeconds: duration, lastViewedPlaylistId: playlistId } = videoInfo;
// Previews suelen ser auto-saves, pero respetamos el modo manual
if (cachedSettings.manualSaveMode && !options.isManual) {
return { success: false, reason: 'manual_save_mode_active' };
}
logLog('savePreview', `Guardando preview ${previewType} para ${videoId} en ${currentTime}s`);
const sourceData = await getSavedVideoData(videoId, playlistId);
const now = Date.now();
const progress = duration > 0 ? (currentTime / duration) : 0;
const defaultPercent = (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent) / 100;
const isFinished = duration > 0 && (
progress >= defaultPercent ||
(duration <= 60 && currentTime >= duration - 0.75)
);
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 = insertCompletionEvent(videoData.completionHistory, now, duration);
session.hasLoggedCompletion = true;
}
} else {
const session = activeProcessingSessions.get(videoEl);
if (session && session.hasLoggedCompletion && !cachedSettings.countOncePerSession) {
const prev = sourceData?.watchProgress || 0;
const delta = prev - currentTime;
const isNearStart = currentTime < Math.max(1, duration * 0.2);
const wasNearEnd = prev > (duration * 0.75);
const wasLoop = delta > (duration * 0.3) || (isNearStart && wasNearEnd);
if (wasLoop) {
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(player, 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 guardados para el video`);
return null;
}
// MARK: 📺 Get Player Video ID
// Cache para IDs de video por elemento player (TTL corto para evitar stale data en SPA)
const playerVideoIdCache = new WeakMap();
/**
* 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 (!player) return null;
const now = Date.now();
// 1. Verificación de Cache (High-Performance Path)
// Usamos un TTL de 500ms para evitar stale data en navegación SPA rápida (menos que el intervalo de 1s)
const cached = playerVideoIdCache.get(player);
if (cached && (now - cached.timestamp < 500)) {
return cached.id;
}
let id = null;
try {
if (typeof player.getPlayerResponse === 'function') {
const resp = player.getPlayerResponse();
id = resp?.videoDetails?.videoId
|| resp?.microformat?.playerMicroformatRenderer?.externalVideoId;
}
} catch (e) {
logWarn('getPlayerVideoId', 'playerResponse falló', e);
}
if (!id && typeof player.getVideoData === 'function') {
id = player.getVideoData()?.video_id;
}
// Fallback a URL
if (!id) {
const videoId = extractYouTubeVideoIdFromUrl(window.location.href);
if (videoId) {
logLog('getPlayerVideoId', `ID detectado (Fallback): ${videoId}`);
id = videoId;
}
}
// Actualizar cache antes de retornar
if (id) {
playerVideoIdCache.set(player, { id, timestamp: now });
logLog('getPlayerVideoId', `ID detectado (Cache Miss): ${id}`);
} else {
logWarn('getPlayerVideoId', '❌ no se pudo obtener videoId');
}
return id;
}
// 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(path) {
const manager = DOMHelpers.get('page:manager', () => document.querySelector('ytd-page-manager'), 100);
if (!manager) return null;
const subtype = manager.getAttribute('page-subtype');
const type = manager.getAttribute('page-type');
if (subtype === 'watch') return 'watch';
if (subtype === 'shorts') return 'shorts';
if (type === 'search') return 'search';
// "browse" cubre home, channel, etc.
if (type === 'browse') {
if (subtype) return subtype;
if (path.startsWith('/@')) {
return 'channel';
}
if (path.startsWith('/channel/')) {
const channelId = path.split('/')[2];
return CHANNEL_SPECIAL[channelId] || 'channel';
}
if (path.startsWith('/c/')) {
const customName = path.split('/')[2];
return CHANNEL_SPECIAL[customName] || 'channel';
}
return 'browse';
}
return null;
}
function getTypeFromYtApp() {
const app = DOMHelpers.get('page:app', () => document.querySelector('ytd-app'), 100);
if (!app) return null;
try {
const data = app.data ?? app.__dataHost?.__data ?? app.__data;
const page = data?.page;
switch (page) {
case 'watch': return 'watch';
case 'browse': return 'browse';
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/')) {
const channelId = path.split('/')[2];
return CHANNEL_SPECIAL[channelId] || 'channel';
}
if (path.startsWith('/c/')) {
const customName = path.split('/')[2];
return CHANNEL_SPECIAL[customName] || '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';
}
break;
case 114: // r
if (path === '/reporthistory') {
return 'reporthistory';
}
break;
}
// 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.endsWith('/live') || 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;
// 1. Detección rápida por URL para tipos inequívocos y evitar DOM stale en SPA
const definitiveUrlType = detectFromURL(path);
if (definitiveUrlType === 'watch' || definitiveUrlType === 'shorts' || definitiveUrlType === 'home') {
return cachePageType(path, definitiveUrlType);
}
// 2. Intentar desde page-manager (más fiable iterando en otros casos)
const managerType = getTypeFromPageManager(path);
if (managerType) return cachePageType(path, managerType);
// 3. Intentar desde datos internos de ytd-app
const appType = getTypeFromYtApp();
if (appType) return cachePageType(path, appType);
// 4. Fallback final completo por URL
return cachePageType(path, definitiveUrlType);
}
// ------------------------------------------
// MARK: 📺 YouTube Resource URL Parser
// ------------------------------------------
/**
* Parsea una URL de YouTube o un ID directo y devuelve un recurso normalizado.
*
* Soporta:
* - URLs de video (`watch?v=`, `youtu.be`, `embed`, `shorts`, `live`)
* - Playlists (`playlist?list=`)
* - Canales (`/channel/`, `/@handle`)
* - IDs directos de video
*
* @param {string} input - URL de YouTube o ID de video.
*
* @returns {(
* | {
* type: "video" | "shorts" | "live";
* id: string;
* context?: {
* playlistId?: string;
* playlistType?: "playlist" | "mix" | "mix_multi" | "album" | "channel_uploads" | "liked" | "watch_later" | "unknown";
* start?: number | string;
* };
* }
* | {
* type: "playlist";
* id: string;
* context?: {
* playlistType?: "playlist" | "mix" | "mix_multi" | "album" | "channel_uploads" | "liked" | "watch_later" | "unknown";
* };
* }
* | {
* type: "channel";
* id: string;
* isHandle?: boolean;
* }
* ) | null}
*
* @example
* parseYouTubeResource("dQw4w9WgXcQ")
* // { type: "video", id: "dQw4w9WgXcQ" }
*
* @example
* parseYouTubeResource("https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ")
* // {
* // type: "video",
* // id: "dQw4w9WgXcQ",
* // context: { playlistId: "RDdQw4w9WgXcQ", playlistType: "mix" }
* // }
*
* @example
* parseYouTubeResource("https://www.youtube.com/playlist?list=PLxxx")
* // { type: "playlist", id: "PLxxx", context: { playlistType: "playlist" } }
*
* @example
* parseYouTubeResource("https://www.youtube.com/@YouTube")
* // { type: "channel", id: "YouTube", isHandle: true }
*/
function parseYouTubeResource(input) {
if (!input || typeof input !== 'string') return null;
const trimmed = input.trim();
const videoIdRegex = /^[A-Za-z0-9_-]{11}$/;
// --- ID directo ---
if (videoIdRegex.test(trimmed)) {
return { type: "video", id: trimmed };
}
let url;
try {
url = new URL(trimmed);
} catch {
return null;
}
const host = url.hostname.toLowerCase();
const path = url.pathname;
const params = url.searchParams;
const isYouTubeHost =
/(^|\.)youtube\.com$/.test(host) ||
/(^|\.)youtube-nocookie\.com$/.test(host) ||
/^youtu\.be$/.test(host);
if (!isYouTubeHost) return null;
const listParam = params.get("list");
const vParam = params.get("v");
// --- helpers ---
const buildContext = () => {
const ctx = {};
if (listParam && listParam.length > 5) {
ctx.playlistId = listParam;
ctx.playlistType = classifyPlaylist(listParam);
}
const t = params.get("t") || params.get("start");
if (t) ctx.start = t;
return Object.keys(ctx).length ? ctx : undefined;
};
// --- PLAYLIST ---
if (path.includes("/playlist") && listParam && listParam.length > 5) {
return {
type: "playlist",
id: listParam,
context: {
playlistType: classifyPlaylist(listParam)
}
};
}
// --- VIDEO (watch?v=) ---
if (vParam && videoIdRegex.test(vParam)) {
logLog("parseYouTubeResource", "watch?v=", vParam);
return {
type: "video",
id: vParam,
context: buildContext()
};
}
// --- watch/ID ---
const watchMatch = path.match(/\/watch\/([A-Za-z0-9_-]{11})/);
if (watchMatch) {
logLog("parseYouTubeResource", "watch/ID", watchMatch[1]);
return {
type: "video",
id: watchMatch[1],
context: buildContext()
};
}
// --- shorts ---
const shortsMatch = path.match(/\/shorts\/([A-Za-z0-9_-]{11})/);
if (shortsMatch) {
logLog("parseYouTubeResource", "shorts", shortsMatch[1]);
return {
type: "shorts",
id: shortsMatch[1],
context: buildContext()
};
}
// --- embed ---
const embedMatch = path.match(/\/embed\/([A-Za-z0-9_-]{11})/);
if (embedMatch) {
logLog("parseYouTubeResource", "embed", embedMatch[1]);
return {
type: "video",
id: embedMatch[1],
context: buildContext()
};
}
// --- legacy /v/ ---
const legacyMatch = path.match(/\/v\/([A-Za-z0-9_-]{11})/);
if (legacyMatch) {
logLog("parseYouTubeResource", "legacy /v/", legacyMatch[1]);
return {
type: "video",
id: legacyMatch[1],
context: buildContext()
};
}
// --- live ---
const liveMatch = path.match(/\/live\/([A-Za-z0-9_-]{11})/);
if (liveMatch) {
logLog("parseYouTubeResource", "live", liveMatch[1]);
return {
type: "live",
id: liveMatch[1],
context: buildContext()
};
}
// --- youtu.be ---
if (host === "youtu.be") {
const shortId = path.slice(1);
if (videoIdRegex.test(shortId)) {
logLog("parseYouTubeResource", "youtu.be", shortId);
return {
type: "video",
id: shortId,
context: buildContext()
};
}
}
// --- channel ---
const channelMatch = path.match(/\/channel\/([A-Za-z0-9_-]+)/);
if (channelMatch) {
logLog("parseYouTubeResource", "channel", channelMatch[1]);
return { type: "channel", id: channelMatch[1] };
}
// --- handle (@user) ---
const handleMatch = path.match(/\/@([A-Za-z0-9._-]+)/);
if (handleMatch) {
logLog("parseYouTubeResource", "handle", handleMatch[1]);
return { type: "channel", id: handleMatch[1], isHandle: true };
}
return null;
}
// MARK: 📺 Get YouTube Video ID from URL
function extractYouTubeVideoIdFromUrl(url) {
const result = parseYouTubeResource(url);
if (!result) return null;
if (result.type === "video" || result.type === "shorts" || result.type === "live") {
return result.id;
}
return null;
}
// MARK: 📺 Get YouTube Video Context from URL
function getYouTubeVideoContextFromUrl(url) {
const result = parseYouTubeResource(url);
if (!result) {
return { videoId: null, playlistId: null };
}
return {
videoId: (["video", "shorts", "live"].includes(result.type)) ? result.id : null,
playlistId: result.context?.playlistId || null,
playlistType: result.context?.playlistType || null
};
}
// MARK: 📺 Get YouTube Playlist ID from URL
function extractYouTubePlaylistIdFromUrl(url) {
const result = parseYouTubeResource(url);
if (!result) return null;
if (result.type === "playlist") {
return result.id;
}
return result.context?.playlistId || null;
}
// MARK: 📺 Get YouTube Playlist URL Type
function classifyPlaylist(listId) {
if (!listId) return "unknown";
if (listId.startsWith("RDMM")) return "mix_multi"; // Mix generado desde una playlist o múltiples señales (YouTube Music)
if (listId.startsWith("RD")) return "mix"; // Mix basado en un video específico (Radio/autoplay)
if (listId.startsWith("OLAK5uy_")) return "album"; // Album
if (listId.startsWith("UU")) return "channel_uploads"; // Uploads del canal
if (listId.startsWith("LL")) return "liked"; // Liked videos
if (listId.startsWith("WL")) return "watch_later"; // Watch later
return "playlist";
}
// 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 currentPlaylistId = extractYouTubePlaylistIdFromUrl(window.location.href);
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 (e) {
logError('getPlaylistName', 'Error al parsear ytInitialData para metadatos de playlist:', e);
}
}
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) {
logError('getPlaylistName', 'Error al procesar respuesta de playlist:', e);
resolve(playlistId);
}
},
onerror: (err) => {
logError('getPlaylistName', 'Error de red al obtener nombre de playlist:', err);
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();
/**
* WeakMap para rastrear listeners de play que limpian mensajes de seek.
* Permite garbage collection automática cuando el elemento se desconecta.
* @type {WeakMap<HTMLVideoElement, Function>}
*/
const seekPlayListeners = new WeakMap();
/**
* 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 instanceof Element)) return;
const messageEl = displayEl.querySelector('.ypp-time-display-message');
// logLog('showDisplayMessage', `displayEl existe: ${!!displayEl}, messageEl existe: ${!!messageEl}, mensaje: "${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 instanceof Element)) 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';
// Extraer preferencias de configuración con fallbacks
const {
showAlertIcon = CONFIG.defaultSettings.showAlertIcon,
showAlertText = CONFIG.defaultSettings.showAlertText,
showAlertTime = CONFIG.defaultSettings.showAlertTime
} = cachedSettings;
const timeStr = formatTime(normalizeSeconds(timeValue));
const icon = `${SVG_ICONS.stopWatch}${SVG_ICONS.pin}`;
const baseText = t('alwaysStartFrom');
let message = "";
if (showAlertIcon) message += icon + " ";
if (showAlertText) message += baseText;
if (showAlertTime) {
if (showAlertText) message += ": " + timeStr;
else message += timeStr;
}
message = message.trim();
// Si el usuario tiene todo desactivado, forzar mostrar *algo* en este caso crítico
if (!message) message = icon;
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);
}
}
}
}
};
if (currentPageType === 'watch' && watchTimeDisplay) {
syncDisplay(watchTimeDisplay, DOMHelpers.getWatchPlayer());
}
if (currentPageType === 'shorts' && shortsTimeDisplay) {
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${cachedSettings.showHistoryButton === false ? ' ypp-d-none' : ''}`,
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 instanceof Element)) 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}`);
// Recuperar info de la sesion activa para evitar pasar null y causar crash por cooldown
const session = activeProcessingSessions.get(video);
const result = await PlaybackController.saveStatus(player, video, contextType, videoId, session?.videoInfo, { isManual: true });
if (result?.videoInfo && session) {
session.videoInfo = result.videoInfo;
}
}
});
// 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) {
// 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 o no es un elemento válido, no hacer nada
if (!(playerContainer instanceof Element)) return;
// Si ya existe pero no tiene la estructura v2, limpiarlo para re-crear
if (watchTimeDisplay) {
try { watchTimeDisplay.remove(); } catch (_) { }
watchTimeDisplay = null;
}
// Soporte para el rediseño "Delhi": el contenedor es un pill wrapper (.ytp-time-wrapper)
// Fallback: .ytp-left-controls si el wrapper de tiempo no existe (común en algunas cuentas logueadas)
const timeWrapper = DOMHelpers.get('player:timeWrapper', () =>
playerContainer.querySelector('.ytp-time-wrapper')
?? playerContainer.querySelector('.ytp-left-controls')
?? playerContainer.querySelector('.ytp-chrome-controls')
?? document.querySelector('.ytp-time-wrapper')
?? document.querySelector('.ytp-left-controls')
?? document.querySelector('.ytp-chrome-bottom'), 100);
if (!timeWrapper) {
logWarn('initTimeDisplay', '⚠️ No se encontró un contenedor válido para la inyección de UI en la barra de reproducción.');
return;
}
logLog('initTimeDisplay', '✅ Contenedor de UI 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 {
// Optimizacion: Rect-first (evita getComputedStyle si el rect esta vacio)
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return false;
const style = window.getComputedStyle(el);
const visible = 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
if (currentPageType !== 'shorts') return null;
// 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 instanceof Element) {
const redundant = shortsPlayerControls.querySelectorAll('.ypp-shorts-time-display');
redundant.forEach(el => {
if (el !== shortsTimeDisplay) el.remove();
});
}
// 3. Re-anclaje (Re-anchoring)
const target = (shortsPlayerControls instanceof Element && shortsPlayerControls) ||
(overlayRoot instanceof Element && 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 instanceof Element));
if (shortsPlayerControls instanceof Element) {
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 sincronizado a Render Pipeline (rAF ticking)
let isShortsPanelTicking = false;
shortsPanelObserver = new MutationObserver(() => {
if (!isShortsPanelTicking) {
isShortsPanelTicking = true;
requestAnimationFrame(() => {
const panel = getActiveShortsControlsContainer();
if (panel && (shortsTimeDisplay?.parentElement !== panel || shortsTimeDisplay?.classList.contains('ypp-floating'))) {
logInfo('startShortsPanelObserver', '✅ Metapanel detectado por MutationObserver, re-anclando...');
initShortsTimeDisplay();
}
isShortsPanelTicking = false;
});
}
});
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';
logLog('updateWatchPlaybackBarMessage', `🔍 Estado:
videoPaused=${isVideoPaused},
isSeek=${isSeek},
isFixed=${isFixedTime},
isManual=${isManual}
`);
if (!isSeek && !isFixedTime && isVideoPaused && (hasActiveSeek || hasFixedTime) && !isManual) {
return; // Preservar el mensaje importante (excepto en modo manual)
}
// Si es guardado manual, limpiar el listener de play que pueda estar activo
if (isManual) {
const handlePlay = seekPlayListeners.get(videoEl);
if (handlePlay) {
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
}
// Limpiar también el dataset de seek activo para que el SVG no persista
delete watchTimeDisplay.dataset.activeSeek;
// Limpiar completamente el contenido del mensaje para asegurar que el SVG seek se elimine
const messageEl = watchTimeDisplay.querySelector('.ypp-time-display-message');
if (messageEl) {
setInnerHTML(messageEl, '');
}
}
// Actualizar contenido y visibilidad usando helper compartido
// logLog('updateWatchPlaybackBarMessage', `Mensaje a mostrar: "${message}" - watchTimeDisplay.isConnected: ${watchTimeDisplay?.isConnected}`)
showDisplayMessage(watchTimeDisplay, message);
// Actualizar metadatos de estado
if (isSeek) watchTimeDisplay.dataset.activeSeek = 'true';
else if (!isVideoPaused) delete watchTimeDisplay.dataset.activeSeek;
// No limpiar si es fixed (permanente)
if (isFixedTime) {
watchTimeDisplay.dataset.isFixedTime = 'true';
return;
}
// No limpiar si está pausado y es un seek
if (isSeek && isVideoPaused) {
// Agregar listener de play para limpiar cuando reproduzca
const handlePlay = () => {
clearPlaybackBarMessage();
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
};
videoEl.addEventListener('play', handlePlay);
seekPlayListeners.set(videoEl, handlePlay);
return;
}
// Si no es seek/fixed y está pausado, limpiar inmediatamente (excepto guardados manuales)
if (!isSeek && !isFixedTime && isVideoPaused && !isManual) {
clearPlaybackBarMessage();
return;
}
scheduleDisplayClear('watch', clearPlaybackBarMessage, 1600);
}
function clearPlaybackBarMessage() {
if (watchTimeDisplay) {
restoreDisplayButtons(watchTimeDisplay);
delete watchTimeDisplay.dataset.activeSeek;
delete watchTimeDisplay.dataset.isFixedTime;
}
// Limpiar listener de play si existe
const videoEl = DOMHelpers.getWatchPlayerVideo();
if (videoEl) {
const handlePlay = seekPlayListeners.get(videoEl);
if (handlePlay) {
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
}
}
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;
}
// Si es guardado manual, limpiar el listener de play que pueda estar activo
if (isManual) {
const handlePlay = seekPlayListeners.get(videoEl);
if (handlePlay) {
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
}
// Limpiar también el dataset de seek activo para que el SVG no persista
delete shortsTimeDisplay.dataset.activeSeek;
// Limpiar completamente el contenido del mensaje para asegurar que el SVG seek se elimine
const msgEl = shortsTimeDisplay.querySelector('.ypp-time-display-message');
if (msgEl) {
setInnerHTML(msgEl, '');
}
}
// 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;
// Si está pausado y es un seek, no programar limpieza automática inmediata.
// Se programa para cuando el video comience a reproducirse.
if (isSeek && isVideoPaused) {
// Agregar listener de play para limpiar cuando reproduzca
const handlePlay = () => {
clearShortsMessage();
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
};
videoEl.addEventListener('play', handlePlay);
seekPlayListeners.set(videoEl, handlePlay);
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;
}
// Limpiar listener de play si existe
const videoEl = DOMHelpers.getShortsPlayerVideo();
if (videoEl) {
const handlePlay = seekPlayListeners.get(videoEl);
if (handlePlay) {
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
}
}
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 instanceof Element)) 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') ||
playerContainer.querySelector('.ytp-left-controls') ||
document.querySelector('ytd-miniplayer-player-container .ytp-time-wrapper') ||
document.querySelector('ytd-miniplayer-player-container .ytp-left-controls');
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;
}
// Si es guardado manual, limpiar el listener de play que pueda estar activo
if (isManual) {
const handlePlay = seekPlayListeners.get(videoEl);
if (handlePlay) {
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
}
// Limpiar también el dataset de seek activo para que el SVG no persista
delete miniplayerTimeDisplay.dataset.activeSeek;
// Limpiar completamente el contenido del mensaje para asegurar que el SVG seek se elimine
const messageEl = miniplayerTimeDisplay.querySelector('.ypp-time-display-message');
if (messageEl) {
setInnerHTML(messageEl, '');
}
}
// 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;
// Si está pausado y es un seek, no programar limpieza automática inmediata.
// Se programa para cuando el video comience a reproducirse.
if (isSeek && isVideoPaused) {
// Agregar listener de play para limpiar cuando reproduzca
const handlePlay = () => {
clearMiniplayerMessage();
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
};
videoEl.addEventListener('play', handlePlay);
seekPlayListeners.set(videoEl, handlePlay);
return;
}
scheduleDisplayClear('mini', clearMiniplayerMessage, 1600);
}
function clearMiniplayerMessage() {
if (miniplayerTimeDisplay) {
restoreDisplayButtons(miniplayerTimeDisplay);
delete miniplayerTimeDisplay.dataset.activeSeek;
delete miniplayerTimeDisplay.dataset.isFixedTime;
}
// Limpiar listener de play si existe
const videoEl = DOMHelpers.getMiniplayerPlayerVideo();
if (videoEl) {
const handlePlay = seekPlayListeners.get(videoEl);
if (handlePlay) {
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
}
}
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 instanceof Element)) 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;
}
// Si es guardado manual, limpiar el listener de play que pueda estar activo
if (isManual) {
const handlePlay = seekPlayListeners.get(videoEl);
if (handlePlay) {
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
}
// Limpiar también el dataset de seek activo para que el SVG no persista
delete inlinePreviewTimeDisplay.dataset.activeSeek;
// Limpiar completamente el contenido del mensaje para asegurar que el SVG seek se elimine
const messageEl = inlinePreviewTimeDisplay.querySelector('.ypp-time-display-message');
if (messageEl) {
setInnerHTML(messageEl, '');
}
}
// 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;
// Si está pausado y es un seek, no programar limpieza automática inmediata.
// Se programa para cuando el video comience a reproducirse.
if (isSeek && isVideoPaused) {
// Agregar listener de play para limpiar cuando reproduzca
const handlePlay = () => {
clearInlinePreviewMessage();
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
};
videoEl.addEventListener('play', handlePlay);
seekPlayListeners.set(videoEl, handlePlay);
return;
}
scheduleDisplayClear('preview', clearInlinePreviewMessage, 1600);
}
function clearInlinePreviewMessage() {
if (inlinePreviewTimeDisplay) {
restoreDisplayButtons(inlinePreviewTimeDisplay);
delete inlinePreviewTimeDisplay.dataset.activeSeek;
delete inlinePreviewTimeDisplay.dataset.isFixedTime;
}
// Limpiar listener de play si existe
const videoEl = DOMHelpers.getInlinePreviewPlayerVideo();
if (videoEl) {
const handlePlay = seekPlayListeners.get(videoEl);
if (handlePlay) {
videoEl.removeEventListener('play', handlePlay);
seekPlayListeners.delete(videoEl);
}
}
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');
}
/**
* Limpia mensajes de forma contextual para no borrar notificaciones de otros players activos.
* @param {'watch'|'shorts'|'miniplayer'|'preview'} type
*/
function clearPlaybackMessagesForType(type) {
if (type === 'preview') {
clearInlinePreviewMessage();
return;
}
if (type === 'miniplayer') {
clearMiniplayerMessage();
return;
}
if (type === 'shorts') {
clearShortsMessage();
return;
}
clearAllPlaybackMessages();
}
// ------------------------------------------
// 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 options = sortedLanguages.map(([code, lang]) => ({
value: code,
label: escapeHTML(lang.name),
icon: LANGUAGE_FLAGS[code]?.ISO_3166 ? `
<img
src="https://flagcdn.com/${LANGUAGE_FLAGS[code].ISO_3166}.svg"
width="30"
title="${lang.name}"
alt="${lang.name}">` : SVG_ICONS.world
}));
const wrapper = createElement('div', { className: 'ypp-settings-section' });
const label = createElement('label', { className: 'ypp-label ypp-label-language', styles: { display: 'flex', alignItems: 'center', gap: '10px' } });
const span = createElement('span', { html: `${SVG_ICONS.translate} ${t('language')}: ` });
const dropdown = createCustomDropdown({
id: 'language-dropdown',
initialValue: currentLang,
options: options,
onChange: () => { }
});
label.appendChild(span);
label.appendChild(dropdown);
wrapper.appendChild(label);
return wrapper;
};
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="showHistoryButton" ${settings.showHistoryButton !== false ? 'checked' : ''}>
<span>${t('showHistoryButton')} ${SVG_ICONS.clockRotateLeft}</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="text" inputmode="numeric" pattern="[0-9]*" class="ypp-input-small" name="minSecondsBetweenSaves" value="${settings.minSecondsBetweenSaves}" min="1" max="9999">
<span class="ypp-label-small">${SVG_ICONS.info} ${t('maxLimit')}: 3600</span>
</label>
</div>
</div>
`;
const renderNotificationSettingsSection = (settings) => {
return `
<div class="ypp-settings-second-level-section">
<h3 class="ypp-section-title">${t('alertStyle')}:</h3>
<div class="ypp-d-flex" style="flex-direction: column; gap: 10px;">
<label class="ypp-label-checkbox">
<input type="checkbox" name="showAlertIcon" ${settings.showAlertIcon !== false ? 'checked' : ''}>
<span>${t('showAlertIcon')}</span>
</label>
<label class="ypp-label-checkbox">
<input type="checkbox" name="showAlertText" ${settings.showAlertText !== false ? 'checked' : ''}>
<span>${t('showAlertText')}</span>
</label>
<label class="ypp-label-checkbox">
<input type="checkbox" name="showAlertTime" ${settings.showAlertTime !== false ? 'checked' : ''}>
<span>${t('showAlertTime')}</span>
</label>
</div>
<div class="ypp-alert-preview-container">
<div class="ypp-alert-preview-title">${t('alertPreview')}</div>
<div class="ypp-alert-preview-box" id="ypp-alert-preview-content"></div>
</div>
</div>
<div class="ypp-settings-second-level-section">
<label class="ypp-label">
<span>${t('staticFinishPercent')}: </span>
<input type="text" inputmode="numeric" pattern="[0-9]*" 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 class="ypp-settings-third-level-section" style="margin-top: 10px;">
<div class="ypp-d-flex">
<input type="checkbox" name="resumeCompletedFromStart" ${settings.resumeCompletedFromStart ? 'checked' : ''}>
<span>${t('resumeCompletedFromStart')}</span>
</div>
<i class="ypp-tooltip">${SVG_ICONS.info} ${t('resumeCompletedFromStartTooltip')}</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="text" inputmode="numeric" pattern="[0-9]*" class="ypp-input-small ypp-interval-input" name="${type}_interval" value="${s.interval || 24}" min="1" max="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-circle ypp-btn-outline-info 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.cloudUpload} ${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.linkExternal}
</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>${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.gitBranch} Gist
</div>
<div class="ypp-github-tab ${lastViewedType === 'repo' ? 'active' : ''}" data-type="repo">
${SVG_ICONS.github} 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-circle ypp-btn-outline-danger" id="btn-close-settings" aria-label="${t('close')}" title="${t('close')}" type="button">
${SVG_ICONS.close}
</button>
`);
header.querySelector('#btn-close-settings').addEventListener('click', closeModal);
// Body
const settingsHTML = `
<div class="ypp-settingsContent">
<div id="ypp-language-section-container" style="display: contents;"></div>
${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.bug} ${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.copy} ${t('copyLogsBtn')}
</button>
<button class="ypp-btn ypp-btn-outline-info ypp-create-issue-btn" type="button" style="margin-top: 10px; margin-left: 10px;">
${SVG_ICONS.issueDraft} ${t('reportIssue')} ${SVG_ICONS.linkExternal}
</button>
</div>
</div>
`;
const bodyModalSettings = createElement('div', {
className: 'ypp-modalBody',
html: settingsHTML
});
const langContainer = bodyModalSettings.querySelector('#ypp-language-section-container');
if (langContainer) {
langContainer.replaceWith(renderLanguageSection(settings.language));
}
// Lógica de Previsualización de Alertas
const alertPreviewContent = bodyModalSettings.querySelector('#ypp-alert-preview-content');
const updateAlertPreview = () => {
if (!alertPreviewContent) return;
const showIcon = bodyModalSettings.querySelector('[name="showAlertIcon"]')?.checked;
const showText = bodyModalSettings.querySelector('[name="showAlertText"]')?.checked;
const showTime = bodyModalSettings.querySelector('[name="showAlertTime"]')?.checked;
if (!showIcon && !showText && !showTime) {
setInnerHTML(alertPreviewContent, `<span style="color: var(--ypp-text-secondary); font-style: italic;">${t('alertHidden')}</span>`);
return;
}
const icon = SVG_ICONS.saveFill;
const baseText = t('progressSaved');
const timeStr = "1:23:45";
let previewHTML = "";
if (showIcon) previewHTML += icon + " ";
if (showText) previewHTML += baseText;
if (showTime) {
if (showText) previewHTML += ": " + timeStr;
else previewHTML += timeStr;
}
setInnerHTML(alertPreviewContent, previewHTML.trim());
};
['showAlertIcon', 'showAlertText', 'showAlertTime'].forEach(name => {
bodyModalSettings.querySelector(`[name="${name}"]`)?.addEventListener('change', updateAlertPreview);
});
// Aplicar clamping numérico a inputs específicos de settings
applyNumericClamping(bodyModalSettings.querySelector('[name="minSecondsBetweenSaves"]'), { min: 1, max: 3600 });
applyNumericClamping(bodyModalSettings.querySelector('[name="staticFinishPercent"]'), { min: 1, max: 99 });
bodyModalSettings.querySelectorAll('.ypp-interval-input').forEach(input => {
applyNumericClamping(input, { min: 1, max: 24 });
});
// Inicializar preview
updateAlertPreview();
// Footer
const footer = createElement('footer', { className: 'ypp-settings-footer' });
const repositoryBtn = createElement('button', {
className: 'ypp-btn ypp-btn-dark ypp-shadow-md',
// html: `${SVG_ICONS.github} ${t('youtubePlaybackPlox')} ${SVG_ICONS.linkExternal}`,
html: `${SVG_ICONS.github} ${SVG_ICONS.linkExternal}`,
onClickEvent: () => { window.open('https://github.com/Alplox/Youtube-Playback-Plox/', '_blank'); }
});
const viewBtn = createElement('button', {
className: 'ypp-btn ypp-btn-outline-primary ypp-shadow-md',
html: `${SVG_ICONS.clockRotateLeft} ${t('savedVideos')}`,
onClickEvent: async () => { overlay.remove(); await showSavedVideosList(); }
});
const saveBtn = createElement('button', {
className: 'ypp-btn ypp-btn-success ypp-shadow-md',
html: `${SVG_ICONS.saveFill} ${t('save')}`,
onClickEvent: async () => {
const getVal = (name) => bodyModalSettings.querySelector(`[name="${name}"]`)?.value ?? bodyModalSettings.querySelector(`[id="${name}-dropdown"]`)?.dataset.value;
const isChecked = (name) => bodyModalSettings.querySelector(`[name="${name}"]`)?.checked;
const newSettings = {
minSecondsBetweenSaves: Math.max(1, parseInt(getVal('minSecondsBetweenSaves'), 10) || 1),
showFloatingButtons: isChecked('showFloatingButtons'),
showHistoryButton: isChecked('showHistoryButton'),
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'),
resumeCompletedFromStart: isChecked('resumeCompletedFromStart'),
language: getVal('language'),
showAlertIcon: isChecked('showAlertIcon'),
showAlertText: isChecked('showAlertText'),
showAlertTime: isChecked('showAlertTime'),
};
const rawGistToken = getVal('gist_token') || '';
let safeGistToken = rawGistToken
.trim()
.replace(/[^\x00-\x7F]/g, '');
// Validación
const isValid = /^gh[pus]_[A-Za-z0-9_]+$/.test(safeGistToken);
if (!isValid && safeGistToken !== '') {
showFloatingToast(`${SVG_ICONS.warning} ${t('githubInvalidToken')}`);
safeGistToken = '';
return
}
const rawRepoToken = getVal('repo_token') || '';
let safeRepoToken = rawRepoToken
.trim()
.replace(/[^\x00-\x7F]/g, '');
// Validación
const isValidRepoToken = /^gh[pus]_[A-Za-z0-9_]+$/.test(safeRepoToken);
if (!isValidRepoToken && safeRepoToken !== '') {
showFloatingToast(`${SVG_ICONS.warning} ${t('githubInvalidToken')}`);
safeRepoToken = '';
return
}
const newGithubSettings = {
gist: {
token: safeGistToken,
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: safeRepoToken,
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)
bodyModalSettings.querySelectorAll('.ypp-github-backup-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const type = e.currentTarget.getAttribute('data-type');
const getVal = (name) => bodyModalSettings.querySelector(`[name="${name}"]`)?.value;
const isChecked = (name) => bodyModalSettings.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 = bodyModalSettings.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}`,
`Current Settings: ${JSON.stringify(cachedSettings)}`,
`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 = bodyModalSettings.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
bodyModalSettings.querySelectorAll('.ypp-github-tab').forEach(tab => {
tab.addEventListener('click', () => {
const type = tab.getAttribute('data-type');
// Actualizar clases de tabs
bodyModalSettings.querySelectorAll('.ypp-github-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Actualizar visibilidad de contenidos
bodyModalSettings.querySelectorAll('.ypp-github-tab-content').forEach(c => c.style.display = 'none');
const activeContent = bodyModalSettings.querySelector(`#ypp-github-${type}-content`);
if (activeContent) activeContent.style.display = 'flex';
// Actualizar campo oculto de último tipo visualizado
const lastViewedInput = bodyModalSettings.querySelector('input[name="githubLastViewedType"]');
if (lastViewedInput) lastViewedInput.value = type;
// Actualizar visibilidad de ayuda y advertencias
const helpGist = bodyModalSettings.querySelector('#ypp-github-help-gist');
const helpRepo = bodyModalSettings.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
bodyModalSettings.querySelector('#ypp-github-help-toggle')?.addEventListener('click', (e) => {
e.currentTarget.classList.toggle('active');
});
const btnCloseSettings = createElement('button', {
className: 'ypp-btn ypp-btn-secondary',
html: `${SVG_ICONS.close} ${t('close')}`,
atribute: { 'aria-label': t('close') },
onClickEvent: closeModal
});
footer.appendChild(repositoryBtn);
footer.appendChild(viewBtn);
footer.appendChild(saveBtn);
footer.appendChild(btnCloseSettings);
modal.appendChild(header);
modal.appendChild(bodyModalSettings);
modal.appendChild(footer);
overlay.appendChild(modal);
// Toggle para mostrar/ocultar gist_id
bodyModalSettings.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';
setInnerHTML(btn, isHidden ? SVG_ICONS.eyeOff : SVG_ICONS.eye);
btn.title = isHidden ? t('hide') : t('show');
});
});
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
}
// ------------------------------------------
// MARK: 📢 Notify Seek or Progress
// ------------------------------------------
const handlers = {
watch: updateWatchPlaybackBarMessage,
embed: updateWatchPlaybackBarMessage,
live: updateWatchPlaybackBarMessage,
shorts: updateShortsMessage,
miniplayer: updateMiniplayerMessage,
preview: updateInlinePreviewMessage
};
async function notifySeekOrProgress(time, context = 'progress', options = {}) {
const {
showAlertIcon = CONFIG.defaultSettings.showAlertIcon,
showAlertText = CONFIG.defaultSettings.showAlertText,
showAlertTime = CONFIG.defaultSettings.showAlertTime
} = cachedSettings;
if (!showAlertIcon && !showAlertText && !showAlertTime) 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;
logLog('notifySeekOrProgress', `video en pausa: ${videoEl?.paused} - pillo svg: ${display?.querySelector('#svg-player-end')} - saveResult.isManual: ${saveResult.isManual} - saveResult.success: ${saveResult.success}`)
if (videoEl?.paused && display?.querySelector('#svg-player-end') && !saveResult.isManual) return;
}
const timeStr = formatTime(normalizeSeconds(time));
const canShowTime = isSeek || saveResult.success;
const icon = isSeek
? (isForced ? `${SVG_ICONS.stopWatch}${SVG_ICONS.pin}` : SVG_ICONS.playerEnd)
: SVG_ICONS.saveFill;
const baseText = isSeek
? t(isForced ? 'alwaysStartFrom' : 'resumedAt')
: (saveResult.success ? t('progressSaved') : t('errorSaving'));
let message = "";
if (showAlertIcon) message += icon + " ";
if (showAlertText) message += baseText;
if (showAlertTime && canShowTime) {
if (showAlertText) message += ": " + timeStr;
else message += timeStr;
}
message = message.trim();
// logLog('notifySeekOrProgress', `Mensaje construido: "${message}" - showAlertIcon: ${showAlertIcon}, showAlertText: ${showAlertText}, showAlertTime: ${showAlertTime}, canShowTime: ${canShowTime}, timeStr: ${timeStr}`)
if (!message) return;
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 modalVideosFooterSecondRow = null; // Botones eliminar todo, crear playlist y configuraciones
/**
* 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
modalVideosFooterSecondRow?.classList.add('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-area')?.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 selectionSection = createElement('div', {
className: 'ypp-management-footer-section',
atribute: { 'data-section': 'selection' }
});
const dataSection = createElement('div', {
className: 'ypp-management-footer-section',
atribute: { 'data-section': 'data' }
});
const dangerSection = createElement('div', {
className: 'ypp-management-footer-section',
atribute: { 'data-section': 'danger' }
});
/* const sessionSection = createElement('div', {
className: 'ypp-management-footer-section',
atribute: { 'data-section': 'session' }
}); */
/**
* Referencia al cierre del menú desplegable actualmente abierto.
* Se usa para garantizar que solo exista un menú de acciones abierto a la vez.
* @type {(() => void) | null}
*/
let currentlyOpenFooterMenu = null;
/**
* Obtiene el callback de cierre del menú abierto actualmente.
* @returns {(() => void) | null}
*/
const getCurrentlyOpenFooterMenu = () => currentlyOpenFooterMenu;
/**
* Actualiza el callback de cierre del menú activo.
* @param {(() => void) | null} fn - Callback de cierre o null si no hay menú abierto.
* @returns {void}
*/
const setCurrentlyOpenFooterMenu = (fn) => { currentlyOpenFooterMenu = fn; };
const btnSelectAll = createElement('button', {
className: 'ypp-btn ypp-btn-outline-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 btnClearSelection = createElement('button', {
id: 'ypp-clear-selection-btn',
className: 'ypp-btn ypp-btn-outline-warning ypp-shadow-md ypp-management-footer-item',
html: `${SVG_ICONS.close} ${t('clearSelection')}`,
onClickEvent: () => {
if (selectedVideos.size === 0) return;
selectedVideos.clear();
document.querySelectorAll('.ypp-video-checkbox').forEach(checkbox => {
checkbox.checked = false;
});
updateManagementFooterState();
}
});
const importMenu = createFooterActionMenu({
label: t('import'),
icon: SVG_ICONS.download,
triggerClassName: 'ypp-btn ypp-btn-outline-info ypp-shadow-md ypp-management-footer-item',
getCurrentlyOpen: getCurrentlyOpenFooterMenu,
setCurrentlyOpen: setCurrentlyOpenFooterMenu,
options: [
{ label: 'JSON', icon: SVG_ICONS.jsonCurlyBrackets, action: async () => await importDataFromFile() },
{ label: 'FreeTube', icon: SVG_ICONS.freetubeIconFill, action: async () => await importFromFreeTube() }
]
});
const exportAllMenu = createFooterActionMenu({
label: `${t('export')} (${t('all')})`,
icon: SVG_ICONS.upload,
triggerClassName: 'ypp-btn ypp-btn-outline-success ypp-shadow-md ypp-management-footer-item',
getCurrentlyOpen: getCurrentlyOpenFooterMenu,
setCurrentlyOpen: setCurrentlyOpenFooterMenu,
options: [
{ label: 'JSON', icon: SVG_ICONS.jsonCurlyBrackets, action: async () => await exportDataToFile() },
{ label: 'FreeTube', icon: SVG_ICONS.freetubeIconFill, action: async () => await exportToFreeTube() }
]
});
const exportSelectedMenu = createFooterActionMenu({
label: `${t('exportSelected')} (${selectedVideos.size})`,
icon: SVG_ICONS.upload,
triggerClassName: 'ypp-btn ypp-btn-success ypp-shadow-md ypp-management-footer-item',
triggerId: 'ypp-export-selected-menu-btn',
getCurrentlyOpen: getCurrentlyOpenFooterMenu,
setCurrentlyOpen: setCurrentlyOpenFooterMenu,
options: [
{
label: 'JSON',
icon: SVG_ICONS.jsonCurlyBrackets,
action: async () => {
if (selectedVideos.size === 0) { alert(t('selectAtLeastOne')); return; }
await exportDataToFile(Array.from(selectedVideos));
}
},
{
label: 'FreeTube',
icon: SVG_ICONS.freetubeIconFill,
action: async () => {
if (selectedVideos.size === 0) { alert(t('selectAtLeastOne')); return; }
await exportToFreeTube(Array.from(selectedVideos));
}
}
]
});
const exportSelectedMenuBtn = exportSelectedMenu.querySelector('#ypp-export-selected-menu-btn');
if (exportSelectedMenuBtn) exportSelectedMenuBtn.disabled = selectedVideos.size === 0;
const btnDeleteSelected = createElement('button', {
id: 'ypp-delete-selected-btn',
className: 'ypp-btn ypp-btn-danger ypp-shadow-md ypp-management-footer-item',
html: `${SVG_ICONS.trash} ${t('deleteSelected')} (${selectedVideos.size})`,
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();
updateManagementFooterState();
await updateVideoList();
const deletedCount = rollbackData.length;
if (deletedCount > 0) {
// Toast con opción Deshacer visible por 10 segundos
showFloatingToast(`🚮 ${t('itemsDeleted', { count: deletedCount })}${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 })}`);
}
}
});
btnDeleteSelected.disabled = selectedVideos.size === 0;
const btnClearAll = createElement('button', {
className: 'ypp-btn ypp-btn-outline-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);
selectionSection.append(btnSelectAll, btnClearSelection);
dataSection.append(importMenu, exportAllMenu, exportSelectedMenu);
dangerSection.append(btnDeleteSelected, btnClearAll);
dangerSection.append(cancelBtn);
btnGroup.append(selectionSection, dataSection, dangerSection/* , sessionSection */);
managementModeContainer.append(btnGroup);
managementModeFragment.append(managementModeContainer);
modalFooter.append(managementModeFragment);
updateManagementFooterState();
} else if (isPlaylistCreationMode) {
modalVideosFooterSecondRow?.classList.add('ypp-d-none');
// Remueve contenedor de botones management mode
modalFooter.querySelector('#ypp-management-footer-container')?.remove();
modalFooter.querySelector('#ypp-playlist-area')?.remove();
const playlistFragment = document.createDocumentFragment();
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', {
text: `${t('selectedVideos')}: 0 / 50 ${t('maxLimit')}`,
styles: { marginBottom: '10px', fontWeight: 'bold' }
});
const playlistTextarea = createElement('textarea', {
className: 'ypp-playlist-textarea',
atribute: {
readonly: true,
rows: 2,
placeholder: t('playlistLinkGenerated'),
value: ''
}
});
const playlistActions = createElement('div', { className: 'ypp-playlist-actions' });
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 btnSelectAll = createElement('button', {
className: 'ypp-btn ypp-btn-outline-secondary ypp-shadow-md',
id: 'ypp-playlist-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) {
if (!selectedVideos.has(v.info.videoId)) {
if (selectedVideos.size >= 50) {
alert(t('playlistLimitReached'));
break;
}
selectedVideos.add(v.info.videoId);
}
}
}
document.querySelectorAll('.ypp-video-checkbox').forEach(checkbox => {
const vid = checkbox.getAttribute('data-video-id');
if (vid) checkbox.checked = selectedVideos.has(vid);
});
refreshPlaylistState();
}
});
const btnClearSelection = createElement('button', {
id: 'ypp-clear-selection-btn',
className: 'ypp-btn ypp-btn-outline-warning ypp-shadow-md ypp-management-footer-item',
html: `${SVG_ICONS.close} ${t('clearSelection')}`,
onClickEvent: () => {
if (selectedVideos.size === 0) return;
selectedVideos.clear();
document.querySelectorAll('.ypp-video-checkbox').forEach(checkbox => {
checkbox.checked = false;
});
refreshPlaylistState();
}
});
const refreshPlaylistState = () => {
const size = selectedVideos.size;
const items = virtualScroller && virtualScroller.items ? virtualScroller.items.filter(i => i.info) : [];
const allSelected = items.length > 0 && items.every(v => selectedVideos.has(v.info.videoId));
playlistInfo.textContent = `${t('selectedVideos')}: ${size} / 50 ${t('maxLimit')}`;
playlistTextarea.value = size > 0
? `https://www.youtube.com/watch_videos?video_ids=${Array.from(selectedVideos).join(',')}`
: '';
setInnerHTML(btnSelectAll, allSelected
? `${SVG_ICONS.close} ${t('deselectAllResults')}`
: `${SVG_ICONS.check} ${t('selectAllResults')}`);
btnClearSelection.disabled = size === 0;
const copyBtn = document.getElementById('ypp-playlist-copy-link-btn');
const openBtn = document.getElementById('ypp-playlist-open-link-btn');
if (copyBtn) copyBtn.disabled = size === 0;
if (openBtn) openBtn.disabled = size === 0;
};
playlistRefreshHandler = refreshPlaylistState;
playlistAreaElement = playlistArea;
playlistArea.addEventListener('ypp-selection-changed', refreshPlaylistState);
const copyBtn = createElement('button', {
id: 'ypp-playlist-copy-link-btn',
className: 'ypp-btn ypp-btn-info ypp-shadow-md',
html: `${SVG_ICONS.copy} ${t('copyLink')}`,
onClickEvent: () => {
if (!playlistTextarea.value) {
alert(t('selectAtLeastOne'));
return;
}
if (playlistTextarea.value) copyToClipboard(playlistTextarea.value, copyBtn);
}
});
const openBtn = createElement('button', {
id: 'ypp-playlist-open-link-btn',
className: 'ypp-btn ypp-btn-outline-primary ypp-shadow-md',
html: `${t('openPlaylist')} ${SVG_ICONS.linkExternal}`,
onClickEvent: () => {
if (!playlistTextarea.value) {
alert(t('selectAtLeastOne'));
return;
}
if (playlistTextarea.value) window.open(playlistTextarea.value, '_blank');
}
});
const cancelBtn = createElement('button', {
className: 'ypp-btn ypp-btn-secondary ypp-shadow-md',
html: `${SVG_ICONS.close} ${t('cancel')}`,
onClickEvent: async () => { await togglePlaylistCreationMode(); }
});
playlistActions.appendChild(btnSelectAll);
playlistActions.appendChild(btnClearSelection);
playlistActions.appendChild(copyBtn);
playlistActions.appendChild(openBtn);
playlistActions.appendChild(cancelBtn);
playlistArea.appendChild(playlistInfo);
playlistArea.appendChild(playlistTitle);
playlistArea.appendChild(playlistTextarea);
playlistArea.appendChild(playlistActions);
playlistFragment.appendChild(playlistArea);
modalFooter.appendChild(playlistFragment);
refreshPlaylistState();
} else {
modalVideosFooterSecondRow?.classList.remove('ypp-d-none');
// Remueve contenedor de botones management mode
modalFooter.querySelector('#ypp-management-footer-container')?.remove();
modalFooter.querySelector('#ypp-playlist-area')?.remove();
}
}
/**
* Actualiza el estado visual de los botones del footer de gestión dinámicamente sin destruir el DOM
*/
function updateManagementFooterState() {
if (!isManagementMode) return;
const selectedCount = selectedVideos.size;
const currentItems = virtualScroller && virtualScroller.items ? virtualScroller.items.filter(i => i.info) : [];
const selectedInCurrentResults = currentItems.reduce((count, item) => (
count + (selectedVideos.has(item.info.videoId) ? 1 : 0)
), 0);
const hiddenSelectedCount = Math.max(0, selectedCount - selectedInCurrentResults);
const btnSelectAll = document.getElementById('ypp-select-all-btn');
if (btnSelectAll) {
const allSelected = currentItems.length > 0 && currentItems.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) {
const hiddenInfo = hiddenSelectedCount > 0
? ` ${SVG_ICONS.warning} ${t('hiddenSelectedCurrentResults', { count: hiddenSelectedCount })}`
: '';
setInnerHTML(selectionInfo, `<strong>${t('selectedVideos')}:</strong> ${selectedVideos.size}${hiddenInfo}`);
} 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';
const hiddenInfo = hiddenSelectedCount > 0
? ` ${SVG_ICONS.warning} ${t('hiddenSelectedCurrentResults', { count: hiddenSelectedCount })}`
: '';
setInnerHTML(secondRowItem.parentElement, `<strong>${t('selectedVideos')}:</strong> ${selectedVideos.size}${hiddenInfo}`);
}
}
const clearSelectionBtn = document.getElementById('ypp-clear-selection-btn');
if (clearSelectionBtn) {
clearSelectionBtn.disabled = selectedCount === 0;
}
const exportSelectedBtn = document.getElementById('ypp-export-selected-menu-btn');
if (exportSelectedBtn) {
exportSelectedBtn.disabled = selectedCount === 0;
setInnerHTML(exportSelectedBtn, `${SVG_ICONS.upload} ${t('exportSelected')} (${selectedCount})`);
}
const deleteSelectedBtn = document.getElementById('ypp-delete-selected-btn');
if (deleteSelectedBtn) {
deleteSelectedBtn.disabled = selectedCount === 0;
setInnerHTML(deleteSelectedBtn, `${SVG_ICONS.trash} ${t('deleteSelected')} (${selectedCount})`);
}
}
/**
* Activa/desactiva el modo de selección de videos
*/
async function togglePlaylistCreationMode() {
isPlaylistCreationMode = !isPlaylistCreationMode;
if (isPlaylistCreationMode) isManagementMode = false;
selectedVideos.clear();
updateFooterButtons();
await updateVideoList();
logLog('togglePlaylistCreationMode', `Modo de selección: ${isPlaylistCreationMode ? 'ACTIVADO' : 'DESACTIVADO'}`);
}
/**
* Copia texto al portapapeles usando Clipboard API
* @param {string} text - Texto a copiar
* @param {HTMLElement} button - Botón que muestra feedback visual
*/
async function copyToClipboard(text, button) {
if (!text || !button) {
logWarn('copyToClipboard', 'Parámetros inválidos: text o button son undefined');
return;
}
const originalHTML = button.innerHTML;
const originalClassName = button.className;
// Helper para mostrar estado de éxito temporalmente
const showSuccess = (restoreIcon = SVG_ICONS.copy) => {
setInnerHTML(button, `${SVG_ICONS.check} ${t('linkCopied')}`);
button.className = 'ypp-btn ypp-btn-success';
setTimeout(() => {
setInnerHTML(button, `${restoreIcon} ${t('copyLink')}`);
button.className = originalClassName;
}, 2000);
};
try {
// Verificar si Clipboard API está disponible
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API no disponible');
}
await navigator.clipboard.writeText(text);
showSuccess();
logLog('copyToClipboard', 'Enlace copiado al portapapeles');
} catch (err) {
logError('copyToClipboard', 'Error con Clipboard API, usando fallback:', err);
// Fallback para navegadores antiguos o sin permisos
try {
const textarea = createElement('textarea', {
value: text,
style: 'position: fixed; opacity: 0; pointer-events: none;'
});
document.body.appendChild(textarea);
textarea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
if (successful) {
showSuccess();
logLog('copyToClipboard', 'Enlace copiado vía fallback execCommand');
} else {
throw new Error('execCommand("copy") falló');
}
} catch (fallbackErr) {
logError('copyToClipboard', 'Fallback también falló:', fallbackErr);
// Mostrar error visual al usuario
setInnerHTML(button, `${SVG_ICONS.x} ${t('error')}`);
button.className = 'ypp-btn ypp-btn-danger';
setTimeout(() => {
setInnerHTML(button, originalHTML);
button.className = originalClassName;
}, 2000);
}
}
}
/**
* Alterna la selección de un video
*/
function toggleVideoSelection(videoId) {
if (selectedVideos.has(videoId)) {
selectedVideos.delete(videoId);
logLog('toggleVideoSelection', `Video ${videoId} deseleccionado`);
} else {
// Límite de 50 videos si se encuentra en PlaylistCreationMode
if (isPlaylistCreationMode && selectedVideos.size >= 50) {
alert(t('playlistLimitReached'));
return;
}
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);
}
// Refrescar estado en UIs dinámicas a través de un CustomEvent
const selectionEvent = new CustomEvent('ypp-selection-changed');
const playlistArea = document.getElementById('ypp-playlist-area');
if (playlistArea) playlistArea.dispatchEvent(selectionEvent);
// Refrescar estado de botones en modo gestión
if (isManagementMode) {
updateManagementFooterState();
}
}
// ------------------------------------------
// MARK: 📺 Video Observer & Processing Manager
// ------------------------------------------
/**
* Resuelve el contexto real de un video y aplica bloqueo estricto para evitar contaminación.
*/
const RouteContextResolver = (() => {
const isMiniplayerBlockingPreview = () => {
const miniPlayer = DOMHelpers.getMiniplayerPlayer();
if (!miniPlayer) return false;
const miniVideo = DOMHelpers.getMiniplayerPlayerVideo();
// Miniplayer pausado no debe bloquear previews inline por hover.
if (miniVideo && miniVideo.paused) return false;
return true;
};
const CONTEXTS = Object.freeze(['watch', 'shorts', 'miniplayer', 'preview']);
const SCORE_STICKINESS_MS = 220;
const SCORE_DELTA_THRESHOLD = 2;
let activeSelection = null;
const getContextRoot = (videoEl, context) => {
if (!videoEl) return null;
if (context === 'watch') return videoEl.closest(S.IDS.MOVIE_PLAYER);
if (context === 'shorts') return videoEl.closest(S.IDS.SHORTS_PLAYER);
if (context === 'miniplayer') return videoEl.closest(S.ELEMENTS.MINIPLAYER_ELEMENT);
if (context === 'preview') return videoEl.closest(S.IDS.INLINE_PREVIEW_PLAYER) || videoEl.closest(S.IDS.VIDEO_PREVIEW_CONTAINER);
return null;
};
const computeContextScore = (videoEl, context) => {
if (!videoEl || !videoEl.isConnected) return -999;
const root = getContextRoot(videoEl, context);
if (!root) return -999;
let score = 0;
score += 20;
if (isVisiblyDisplayed(videoEl)) score += 10;
if (!videoEl.paused) score += 8;
if ((videoEl.readyState || 0) >= 2) score += 6;
if ((videoEl.currentSrc || videoEl.src || '').length > 0) score += 4;
if (context === 'preview' && DOMHelpers.getMiniplayerPlayer()) score -= 50;
if (context === 'watch' && currentPageType !== 'watch') score -= 12;
if (context === 'shorts' && currentPageType !== 'shorts') score -= 12;
return score;
};
const resolveContext = (videoEl, preferredContext = null) => {
if (!videoEl || !videoEl.isConnected) return null;
const candidates = [];
for (const context of CONTEXTS) {
const score = computeContextScore(videoEl, context);
if (score > -999) candidates.push({ context, score });
}
if (!candidates.length) return null;
candidates.sort((a, b) => b.score - a.score);
let winner = candidates[0];
if (preferredContext) {
const preferred = candidates.find(c => c.context === preferredContext);
if (preferred && (winner.score - preferred.score) <= 1) winner = preferred;
}
const now = Date.now();
if (
activeSelection &&
activeSelection.videoEl === videoEl &&
activeSelection.context !== winner.context &&
(now - activeSelection.ts) <= SCORE_STICKINESS_MS &&
(winner.score - activeSelection.score) < SCORE_DELTA_THRESHOLD
) {
winner = activeSelection;
} else {
activeSelection = { ...winner, videoEl, ts: now };
}
return winner.context;
};
const getIneligibilityReason = (videoEl, context) => {
if (!videoEl) return 'no_video_element';
if (!context) return 'no_context_provided';
if (!videoEl.isConnected) return 'video_not_connected_to_dom';
if (AdDetector.isNodeWithinAdContainer(videoEl)) return 'within_ad_container';
if (context === 'preview') {
if (currentPageType === 'watch' || currentPageType === 'shorts') return `page_type_mismatch:${currentPageType}_blocks_preview`;
if (isMiniplayerBlockingPreview()) return 'miniplayer_blocking_preview';
}
if (context === 'watch' && currentPageType !== 'watch') return `page_type_mismatch:not_on_watch_page(current:${currentPageType})`;
if (context === 'shorts' && currentPageType !== 'shorts') return `page_type_mismatch:not_on_shorts_page(current:${currentPageType})`;
if (context === 'miniplayer' && !DOMHelpers.getMiniplayerPlayer()) return 'miniplayer_not_found';
if (!getContextRoot(videoEl, context)) return `root_not_found_for_context:${context}`;
return null;
};
const canProcessContext = (videoEl, context) => !getIneligibilityReason(videoEl, context);
const isContextLocked = (videoEl, expectedContext) => {
if (!expectedContext) return false;
const resolvedContext = resolveContext(videoEl, expectedContext);
return resolvedContext === expectedContext && canProcessContext(videoEl, expectedContext);
};
return {
resolveContext,
canProcessContext,
getIneligibilityReason,
isContextLocked
};
})();
const SessionTelemetry = (() => {
let safeModeState = 'off';
const emit = (event, payload = {}) => {
logInfo('sessionTelemetry', `[${event}]`, {
safeModeState,
...payload
});
};
return {
emit,
setSafeModeState: (state) => { safeModeState = state === 'safe' ? 'safe' : 'off'; }
};
})();
const EventPreFilter = {
shouldDrop(videoEl) {
if (!videoEl) return true;
if (!(videoEl instanceof HTMLVideoElement)) return true;
if (!videoEl.isConnected) return true;
const src = videoEl.currentSrc || videoEl.src || '';
if (!src) return true;
return false;
}
};
const FailSafeManager = (() => {
const counters = {
invalidTransition: [],
duplicateSession: [],
invariantViolation: []
};
let safeModeStart = 0;
const WINDOW_MS = 20_000;
const ENTER_THRESHOLD = 7;
const STABLE_EXIT_MS = 45_000;
const prune = (arr) => {
const now = Date.now();
while (arr.length && now - arr[0] > WINDOW_MS) arr.shift();
};
const getTotal = () => {
Object.values(counters).forEach(prune);
return counters.invalidTransition.length + counters.duplicateSession.length + counters.invariantViolation.length;
};
const track = (type, reason) => {
if (!counters[type]) return;
counters[type].push(Date.now());
prune(counters[type]);
const total = getTotal();
if (total >= ENTER_THRESHOLD && !safeModeStart) {
safeModeStart = Date.now();
SessionTelemetry.setSafeModeState('safe');
SessionTelemetry.emit('safeModeEntered', { reason: reason || type, triggerCount: total });
}
};
const maybeExit = () => {
if (!safeModeStart) return;
if (getTotal() === 0 && (Date.now() - safeModeStart) > STABLE_EXIT_MS) {
const duration = Date.now() - safeModeStart;
safeModeStart = 0;
SessionTelemetry.setSafeModeState('off');
SessionTelemetry.emit('safeModeExited', { safeModeDurationMs: duration });
}
};
return {
isSafeMode: () => !!safeModeStart,
track,
maybeExit
};
})();
const SessionFallbackManager = (() => {
const fallbackTimers = new WeakMap();
const clear = (videoEl) => {
const state = fallbackTimers.get(videoEl);
if (!state) return;
if (state.retryTimeoutId) clearTimeout(state.retryTimeoutId);
if (state.watchdogId) clearInterval(state.watchdogId);
fallbackTimers.delete(videoEl);
};
const ensureForSession = (session, source) => {
if (!session?.videoEl || !session.sessionToken || session.isFinalized) return;
clear(session.videoEl);
const previewTtlMs = 2500;
const defaultTtlMs = 5000;
const ttlMs = session.type === 'preview' ? previewTtlMs : defaultTtlMs;
const watchdogMs = session.type === 'preview' ? 700 : 1100;
const retries = session.type === 'preview' ? 1 : 2;
let tries = 0;
const retryTimeoutId = setTimeout(() => {
if (session.isFinalized || session.sessionToken !== (activeProcessingSessions.get(session.videoEl)?.sessionToken)) return;
if (!RouteContextResolver.isContextLocked(session.videoEl, session.type)) return;
SessionTelemetry.emit('fallbackRetry', { source, context: session.type, sessionId: session.sessionId, transitionToken: session.transitionToken });
try {
VideoObserverManager.enqueueWithResolver(session.videoEl, session.type, 'fallbackRetry');
} catch (_) { }
tries++;
}, 180);
const startedAt = Date.now();
const watchdogId = setInterval(() => {
if (session.isFinalized) return clear(session.videoEl);
if (session.sessionToken !== (activeProcessingSessions.get(session.videoEl)?.sessionToken)) return clear(session.videoEl);
if (!RouteContextResolver.isContextLocked(session.videoEl, session.type)) return clear(session.videoEl);
if ((Date.now() - startedAt) > ttlMs || tries >= retries) return clear(session.videoEl);
tries++;
SessionTelemetry.emit('fallbackWatchdog', { source, context: session.type, sessionId: session.sessionId, transitionToken: session.transitionToken });
try {
VideoObserverManager.enqueueWithResolver(session.videoEl, session.type, 'fallbackWatchdog');
} catch (_) { }
}, watchdogMs);
fallbackTimers.set(session.videoEl, { retryTimeoutId, watchdogId });
};
return {
ensureForSession,
clear
};
})();
/**
* 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;
let previewWatchdogId = null;
/** @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();
/** @description Marca videos del miniplayer en transición de src para handoff de sesión */
const miniplayerTransitions = new Set();
/**
* 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 cachedType = videoTypeCache.get(video);
const type = RouteContextResolver.resolveContext(video, cachedType || null);
if (!type || !RouteContextResolver.canProcessContext(video, 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;
}
};
/**
* Garantiza que previews inline activos se re-encolen aunque MutationObserver
* no reporte cambio de src/DOM (caso común en reutilización de player interno).
*/
const ensurePreviewWatchdog = () => {
if (previewWatchdogId) return;
previewWatchdogId = setInterval(() => {
try {
if (currentPageType === 'watch' || currentPageType === 'shorts') return;
if (DOMHelpers.getMiniplayerPlayer()) return;
const previewVideo = DOMHelpers.getInlinePreviewPlayerVideo();
if (!previewVideo || !previewVideo.isConnected) return;
const isActivePreview = isVisiblyDisplayed(previewVideo) || !previewVideo.paused;
if (!isActivePreview) return;
const currentSession = activeProcessingSessions.get(previewVideo);
if (currentSession?.type === 'preview') return;
enqueueVideo(previewVideo, 'preview');
} catch (_) { }
}, 700);
};
// Estado para observador de espera de player
let watchPlayerWaitState = { observer: null, timeoutId: null, resolved: false };
/**
* Espera reactiva a que aparezca el #movie_player en página watch.
* Usa MutationObserver para detectar inmediatamente + safety timeout.
* @param {boolean} force - Si es true, fuerza re-procesamiento ignorando cache
*/
const waitForWatchPlayerReactive = (force = false) => {
// Limpiar estado previo si existe
if (watchPlayerWaitState.observer) {
watchPlayerWaitState.observer.disconnect();
watchPlayerWaitState.observer = null;
}
if (watchPlayerWaitState.timeoutId) {
clearTimeout(watchPlayerWaitState.timeoutId);
watchPlayerWaitState.timeoutId = null;
}
watchPlayerWaitState.resolved = false;
// Verificar si existe
const existingVideo = DOMHelpers.getWatchPlayerVideo();
if (existingVideo) {
if (existingVideo.closest(S.ELEMENTS.MINIPLAYER_ELEMENT)) {
logLog('VideoObserverManager', '⏭️ Video encontrado está en miniplayer, omitiendo bootstrap de watch');
} else {
if (force) videoTypeCache.delete(existingVideo);
enqueueVideo(existingVideo, 'watch');
}
return;
}
const tryProcess = () => {
if (watchPlayerWaitState.resolved) return false;
if (currentPageType !== 'watch') {
watchPlayerWaitState.resolved = true;
return false;
}
const video = DOMHelpers.getWatchPlayerVideo();
if (video) {
watchPlayerWaitState.resolved = true;
if (watchPlayerWaitState.observer) {
watchPlayerWaitState.observer.disconnect();
watchPlayerWaitState.observer = null;
}
if (watchPlayerWaitState.timeoutId) {
clearTimeout(watchPlayerWaitState.timeoutId);
watchPlayerWaitState.timeoutId = null;
}
logInfo('VideoObserverManager', '✅ Video en watch player detectado en MutationObserver');
if (video.closest(S.ELEMENTS.MINIPLAYER_ELEMENT)) {
logLog('VideoObserverManager', '⏭️ Video encontrado está en miniplayer, omitiendo bootstrap de watch');
} else {
if (force) videoTypeCache.delete(video);
enqueueVideo(video, 'watch');
}
return true;
}
return false;
};
// Intentar una vez más antes de iniciar observer
if (tryProcess()) return;
// Crear MutationObserver para detectar cuando aparece el player
watchPlayerWaitState.observer = new MutationObserver(() => {
tryProcess();
});
// Observar todo el documento buscando aparición de #movie_player
watchPlayerWaitState.observer.observe(document.body, {
childList: true,
subtree: true
});
// Safety timeout: desconectar observer después de 4 segundos
watchPlayerWaitState.timeoutId = setTimeout(() => {
if (!watchPlayerWaitState.resolved) {
watchPlayerWaitState.resolved = true;
if (watchPlayerWaitState.observer) {
watchPlayerWaitState.observer.disconnect();
watchPlayerWaitState.observer = null;
}
logWarn('VideoObserverManager', '⏱️ Timeout esperando #movie_player (4s). Posible interferencia persistente.');
}
}, 4000);
};
/**
* 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') {
waitForWatchPlayerReactive(force);
}
// 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)
// Solo cuando no estamos en watch/shorts y no hay miniplayer activo.
if (currentPageType !== 'shorts' && currentPageType !== 'watch' && !DOMHelpers.getMiniplayerPlayer()) {
const previewVideo = DOMHelpers.getInlinePreviewPlayerVideo();
if (previewVideo) {
if (force) videoTypeCache.delete(previewVideo);
enqueueVideo(previewVideo, 'preview');
}
}
};
/**
* Registra un monitor de recuperación para re-encolar videos al salir de estado de anuncio.
* Se activa tanto para anuncios reales como para falsos positivos temporales de detección.
* @param {HTMLVideoElement} videoElement
* @param {string} type
*/
const scheduleAdRecovery = (videoElement, type) => {
if (!videoElement || activeAdWaiters.has(videoElement)) return;
activeAdWaiters.add(videoElement);
let lastCheck = 0;
let intervalId = null;
const cleanup = () => {
videoElement.removeEventListener('timeupdate', onAdWait);
videoElement.removeEventListener('play', onAdWait);
if (intervalId) clearInterval(intervalId);
activeAdWaiters.delete(videoElement);
};
const onAdWait = () => {
// Throttling: Solo comprobar anuncios cada 2000ms para reducir carga sin perder reactividad
const now = Date.now();
if (now - lastCheck < 2000) return;
lastCheck = now;
if (!document.contains(videoElement)) {
cleanup();
return;
}
if (!AdDetector.isNodeWithinAdContainer(videoElement)) {
cleanup();
logInfo('VideoObserverManager', `✅ Video [${type}] liberado de anuncios, re-evaluando...`);
enqueueVideo(videoElement, type);
}
};
videoElement.addEventListener('timeupdate', onAdWait);
videoElement.addEventListener('play', onAdWait);
// Fallback: cubrir casos donde no hay eventos (ej. pausa/buffering tras falso positivo)
intervalId = setInterval(onAdWait, 2000);
};
/**
* 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, triggerSource = 'observer') => {
if (!videoElement) return;
if (EventPreFilter.shouldDrop(videoElement)) return;
const canProcess = RouteContextResolver.canProcessContext(videoElement, type);
if (!canProcess) {
const reason = RouteContextResolver.getIneligibilityReason(videoElement, type);
logWarn('VideoObserverManager', `🚫 enqueueVideo: rejected [${type}]. Reason: ${reason}`);
return;
}
// Protección: No encolar videos que son detectados como anuncios
if (AdDetector.isNodeWithinAdContainer(videoElement)) {
// Log explícito y estandarizado para facilitar búsqueda en logs ("Anuncio detectado").
logWarn('process', `⚠️ Anuncio detectado [${type}] antes de iniciar sesión. Activando espera de recuperación.`);
logInfo('VideoObserverManager', `🚫 Omitiendo video [${type}] detectado como anuncio por AdDetector`);
scheduleAdRecovery(videoElement, type);
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})`);
SessionTelemetry.emit('routingDecision', {
decision: 'enqueue',
reason: triggerSource,
context: type
});
videoTypeCache.set(videoElement, type);
pendingVideos.add(videoElement);
if (!isBatchProcessing) {
setTimeout(processBatch, 0);
}
};
const enqueueWithResolver = (videoElement, preferredType = null, triggerSource = 'observer') => {
if (!videoElement || !videoElement.isConnected) return;
const resolvedContext = RouteContextResolver.resolveContext(videoElement, preferredType);
if (!resolvedContext || !RouteContextResolver.canProcessContext(videoElement, resolvedContext)) return;
enqueueVideo(videoElement, resolvedContext, triggerSource);
};
/**
* Fuerza re-encolado de un video miniplayer tras transición de ID/src.
* @param {HTMLVideoElement} videoElement
*/
const requeueMiniplayer = (videoElement) => {
if (!videoElement) return;
try {
videoTypeCache.delete(videoElement);
} catch (_) { }
enqueueVideo(videoElement, 'miniplayer', 'miniplayerTransition');
};
// Instancias de observadores
let observers = {
watch: null,
shorts: null,
miniplayer: null,
preview: null
};
const initObservers = (forceBootstrap = false, preserveMiniplayer = false, skipCleanup = false) => {
if (!skipCleanup) cleanup(preserveMiniplayer);
// Si skipCleanup es true y los observadores ya están inicializados, no recrearlos
if (skipCleanup && observers.watch && observers.shorts && observers.miniplayer && observers.preview) {
logLog('VideoObserverManager', '⏭️ Observadores ya inicializados, omitiendo recreación');
bootstrap(forceBootstrap);
return;
}
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)
) {
// Invalidar caché de playerVideoId para evitar stale data en navegación SPA
const player = videoEl.closest(S.IDS.MOVIE_PLAYER);
if (player) playerVideoIdCache.delete(player);
// Obtiene el videoId actual después del cambio de src
const currentVideoId = player ? getPlayerVideoId(player) : null;
// Obtiene la sesión de procesamiento activa asociada a este video
const prevSession = activeProcessingSessions.get(videoEl);
// Si hay una sesión activa y el videoId es el mismo,
// es un cambio de configuraciones (calidad o mejoras de audio)
if (prevSession && currentVideoId && prevSession.lastVideoId === currentVideoId) {
logLog(
'VideoObserverManager',
`🎬 Watch: cambio de configuración en player detectado (mismo videoId: ${currentVideoId}), marcando sesión`
);
// Marcar la sesión como cambio de configuraciones para omitir el seek
prevSession.isPlayerSettingsChange = true;
// No reiniciar la sesión, solo marcar el flag
return;
}
// 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);
// 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) => {
// Safeguard: solo procesar como "shorts" si estamos realmente en la página de shorts
if (currentPageType !== 'shorts') return;
// 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');
// Invalidar caché de playerVideoId para evitar stale data
const player = videoEl.closest(S.IDS.SHORTS_PLAYER);
if (player) playerVideoIdCache.delete(player);
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}`) ||
DOMHelpers.get('page:app', () => document.querySelector('ytd-app'), 100)?.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');
// Invalidar caché de playerVideoId para evitar stale data
const player = videoEl.closest(S.ELEMENTS.MINIPLAYER_ELEMENT);
if (player) playerVideoIdCache.delete(player);
miniplayerTransitions.add(videoEl);
// 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) => {
// Safeguard: solo procesar como "preview" si no estamos en la página de shorts o watch
if (currentPageType === 'shorts' || currentPageType === 'watch') return;
// 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');
// Invalidar caché de playerVideoId para evitar stale data
const player = isPreview ? (videoEl.closest(S.IDS.INLINE_PREVIEW_PLAYER) || videoEl.closest(S.IDS.VIDEO_PREVIEW_CONTAINER)) : null;
if (player) playerVideoIdCache.delete(player);
// Invalidar caché de detección de Shorts para evitar sub-etiquetado incorrecto
delete videoEl.dataset.yppShortsPreview;
const currentPreviewId = player ? getPlayerVideoId(player) : null;
const existingSession = activeProcessingSessions.get(videoEl);
if (
existingSession &&
existingSession.type === 'preview' &&
existingSession.lastVideoId &&
currentPreviewId &&
existingSession.lastVideoId === currentPreviewId
) {
return;
}
// Marcar como en transición para evitar que el intervalo termine la sesión por cambio de ID
previewTransitions.add(videoEl);
// 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)
// Usamos DOMHelpers.getWatchPlayer() que ya excluye el movie_player si está dentro del miniplayer
const playerContainer = DOMHelpers.getWatchPlayer();
if (playerContainer) {
observers.watch.observe(playerContainer, config);
logInfo('VideoObserverManager', '✅ Observador de Watch inicializado');
} else {
logWarn('VideoObserverManager', '⚠️ No se encontró el contenedor de Watch (movie_player). No se pudo inicializar su observador.');
}
// Observar el contenedor principal de Shorts
const shorts = DOMHelpers.getShortsPlayer();
if (shorts) {
observers.shorts.observe(shorts, config);
logInfo('VideoObserverManager', '✅ Observador de Shorts inicializado');
} else {
logWarn('VideoObserverManager', 'ℹ️ Contenedor de Shorts no encontrado (normal si no se está en /shorts)');
}
// Observar el contenedor principal de previews
const previewEl = DOMHelpers.getInlinePreviewMainContainer();
if (previewEl) {
observers.preview.observe(previewEl, config);
logInfo('VideoObserverManager', '✅ Observador de Previews inicializado');
}
// Observar el contenedor principal del miniplayer
const miniContainer = DOMHelpers.getMiniplayerElement();
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 = DOMHelpers.get('page:app', () => document.querySelector('ytd-app'), 100);
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);
ensurePreviewWatchdog();
} 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();
// No invalidar sesiones en vuelo si estamos preservando el miniplayer,
// ya que una sesión miniplayer puede estar arrancando justo ahora.
if (!preserveMiniplayer) {
globalNavigationId++;
}
stopAllSessions(preserveMiniplayer);
if (previewWatchdogId) {
clearInterval(previewWatchdogId);
previewWatchdogId = null;
}
logLog('VideoObserverManager', `🧹 Observadores desconectados (PreserveMiniplayer: ${preserveMiniplayer})`);
// Full Teardown SPA: Limpiar timers y romper closures
displayClearTimeouts.forEach((v, k) => clearTimeout(v));
displayClearTimeouts.clear();
// Limpiar observador de espera de Watch player
if (watchPlayerWaitState.observer) {
watchPlayerWaitState.observer.disconnect();
watchPlayerWaitState.observer = null;
}
if (watchPlayerWaitState.timeoutId) {
clearTimeout(watchPlayerWaitState.timeoutId);
watchPlayerWaitState.timeoutId = null;
}
watchPlayerWaitState.resolved = true;
logLog('VideoObserverManager', '🧹 Timers liberados (Teardown parcial SPA)');
};
const clearCache = () => {
logLog('VideoObserverManager', '🧹 Reiniciando cache de tipos de video');
videoTypeCache = new WeakMap();
pendingVideos.clear();
};
return {
init: initObservers,
cleanup,
bootstrap,
clearCache,
waitForAdClear: scheduleAdRecovery,
requeueMiniplayer,
enqueueWithResolver,
hasMiniplayerTransition: (videoElement) => miniplayerTransitions.has(videoElement),
clearMiniplayerTransition: (videoElement) => miniplayerTransitions.delete(videoElement)
};
})();
// ------------------------------------------
// 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();
const previewTransitions = new Set(); // Marca elementos en transición para evitar condiciones de carrera
/** @type {WeakMap<HTMLVideoElement, { videoId: string, ts: number }>} */
const recentResumeAttempts = new WeakMap();
let sessionIdCounter = 0;
let transitionTokenCounter = 0;
const SessionOrchestrator = (() => {
const VALID_STATES = new Set(['idle', 'starting', 'active', 'inAd', 'transitioning', 'stopping', 'finalized']);
const transitions = new Map([
['idle', new Set(['starting'])],
['starting', new Set(['active', 'inAd', 'stopping', 'finalized'])],
['active', new Set(['inAd', 'transitioning', 'stopping', 'finalized'])],
['inAd', new Set(['active', 'stopping', 'finalized'])],
['transitioning', new Set(['active', 'stopping', 'finalized'])],
['stopping', new Set(['finalized'])],
['finalized', new Set([])]
]);
const dedupeByKey = new Map();
const DEDUPE_MS = 450;
const buildSessionId = (videoEl, context, videoId) => {
sessionIdCounter += 1;
return `${context}:${videoId || 'unknown'}:${sessionIdCounter}`;
};
const buildIdentityKey = (videoEl, context, videoId) => {
const src = videoEl?.currentSrc || videoEl?.src || 'no-src';
const ready = Number(videoEl?.readyState || 0);
const duration = Number(videoEl?.duration || 0);
return `${context}|${videoId || 'unknown'}|${src}|${ready}|${Math.round(duration)}`;
};
const canTransition = (session, nextState) => {
const from = session?.state || 'idle';
if (!VALID_STATES.has(from) || !VALID_STATES.has(nextState)) return false;
return transitions.get(from)?.has(nextState) || false;
};
const transitionState = (session, nextState, reason = 'stateChange') => {
if (!session) return false;
if (!canTransition(session, nextState)) {
FailSafeManager.track('invalidTransition', reason);
SessionTelemetry.emit('invalidTransition', {
sessionId: session.sessionId,
context: session.type,
transitionToken: session.transitionToken,
fromState: session.state,
toState: nextState,
reason
});
return false;
}
session.state = nextState;
return true;
};
const startSession = (videoEl, context, videoId, player, source = 'observer') => {
const now = Date.now();
const identityKey = buildIdentityKey(videoEl, context, videoId);
const dedupeKey = `${identityKey}|${source}`;
const dedupeTs = dedupeByKey.get(dedupeKey) || 0;
if ((now - dedupeTs) < DEDUPE_MS) {
SessionTelemetry.emit('routingDecision', {
decision: 'dedupe',
reason: source,
context,
videoId
});
return { accepted: false, reason: 'dedupe' };
}
dedupeByKey.set(dedupeKey, now);
const existing = activeProcessingSessions.get(videoEl);
if (existing && existing.type === context && existing.lastVideoId === videoId && existing.state !== 'finalized') {
FailSafeManager.track('duplicateSession', 'sameIdentity');
return { accepted: false, reason: 'alreadyActive', session: existing };
}
const activeByContext = Array.from(activeProcessingSessions.values()).filter(s => s.type === context && !s.isFinalized).length;
const maxByContext = context === 'preview' ? 2 : 1;
if (activeByContext >= maxByContext) {
FailSafeManager.track('invariantViolation', `maxSessions:${context}`);
}
transitionTokenCounter += 1;
const transitionToken = `${context}-${transitionTokenCounter}`;
const sessionToken = `${identityKey}::${transitionToken}`;
const abortController = new AbortController();
const session = {
sessionId: buildSessionId(videoEl, context, videoId),
sessionToken,
transitionToken,
identityKey,
weakMediaFingerprint: {
src: videoEl?.currentSrc || videoEl?.src || '',
readyState: Number(videoEl?.readyState || 0),
duration: Number(videoEl?.duration || 0)
},
state: 'starting',
startedAt: now,
type: context,
lastVideoId: videoId,
player,
videoEl,
isFinalized: false,
isPlayerSettingsChange: false,
abortController
};
activeProcessingSessions.set(videoEl, session);
SessionTelemetry.emit('routingDecision', {
decision: 'start',
reason: source,
context,
sessionId: session.sessionId,
transitionToken
});
return { accepted: true, session };
};
const finalizeSession = (videoEl, reason = 'stop') => {
const session = activeProcessingSessions.get(videoEl);
if (!session || session.isFinalized) return;
transitionState(session, 'stopping', reason);
session.isFinalized = true;
session.state = 'finalized';
if (session.intervalId) clearInterval(session.intervalId);
if (session.abortController) session.abortController.abort();
SessionFallbackManager.clear(videoEl);
activeProcessingSessions.delete(videoEl);
SessionTelemetry.emit('routingDecision', {
decision: 'stop',
reason,
context: session.type,
sessionId: session.sessionId,
transitionToken: session.transitionToken
});
};
const handoffSession = (videoEl, toVideoId, reason = 'srcChanged', handoffMode = 'intraNodeHandoff') => {
const prev = activeProcessingSessions.get(videoEl);
if (!prev || prev.isFinalized) return null;
transitionState(prev, 'transitioning', reason);
SessionTelemetry.emit('routingDecision', {
decision: 'handoff',
reason,
handoffMode,
context: prev.type,
sessionId: prev.sessionId,
transitionToken: prev.transitionToken
});
finalizeSession(videoEl, `${reason}:${handoffMode}`);
return startSession(videoEl, prev.type, toVideoId, prev.player, handoffMode).session || null;
};
return {
startSession,
finalizeSession,
handoffSession,
transitionState
};
})();
/**
* Evita re-seeks redundantes cuando una sesión se reinicia durante navegación SPA
* pero el video ya está reproduciéndose cerca del progreso guardado.
* @param {HTMLVideoElement} videoEl
* @param {string} type
* @param {string} videoId
* @param {{watchProgress?: number, forceResumeTime?: number}|null} savedData
* @returns {boolean}
*/
const shouldSkipResumeForActivePlayback = (videoEl, type, videoId, savedData) => {
if (!videoEl || !savedData || type !== 'miniplayer') return false;
if ((savedData.forceResumeTime || 0) > 0) return false;
// Anti re-seek cooldown
const now = Date.now();
const lastResume = recentResumeAttempts.get(videoEl);
if (lastResume && lastResume.videoId === videoId && (now - lastResume.ts) < 5000) {
return true; // Salta el re-seek
}
// Si ya está reproduciendo y muy cerca del progreso objetivo, no volver a hacer seek.
const target = Number(savedData.watchProgress || 0);
const current = Number(videoEl.currentTime || 0);
const isPlaying = !videoEl.paused && !videoEl.ended && videoEl.readyState >= 2;
if (isPlaying && target > 1 && Math.abs(current - target) <= 3) {
return true;
}
return false;
};
/**
* Determina si el progreso de reanudación cae dentro de la zona de "video terminado".
* Se usa para evitar contabilizar una visualización completa fantasma cuando el seek
* inicial aterriza directamente en el final del video.
* @param {{watchProgress?: number, forceResumeTime?: number}|null} savedData
* @param {{lengthSeconds?: number}|null} cachedVideoInfo
* @returns {boolean}
*/
const isResumeAtCompletionZone = (savedData, cachedVideoInfo) => {
if (!savedData) return false;
// Si ya está marcado como completado en DB y NO vamos a reanudar desde el inicio,
// consideramos que ya estamos en zona de completado para evitar doble log al saltar al final.
if (savedData.isCompleted && !cachedSettings.resumeCompletedFromStart) return true;
const target = Number(savedData.forceResumeTime > 0 ? savedData.forceResumeTime : savedData.watchProgress || 0);
const duration = Number(cachedVideoInfo?.lengthSeconds || 0);
if (!isFinite(target) || !isFinite(duration) || target <= 0 || duration <= 0) return false;
const finishPercent = (cachedSettings?.staticFinishPercent || CONFIG.defaultSettings.staticFinishPercent) / 100;
const thresholdByPercent = duration * finishPercent;
const thresholdByShortVideo = duration <= 60 ? (duration - 0.75) : Number.POSITIVE_INFINITY;
const completionThreshold = Math.min(thresholdByPercent, thresholdByShortVideo);
return target >= Math.max(0, completionThreshold);
};
/**
* 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()) {
// Protección crítica para Miniplayer:
// Si preserveMiniplayer es true, no solo evitamos cerrar sesiones tipo 'miniplayer',
// sino también cualquier sesión cuyo video esté FÍSICAMENTE dentro del contenedor del miniplayer.
// Esto cubre el gap de transición donde el video de 'watch' se mueve al miniplayer pero
// el script aún no ha procesado el cambio de tipo a 'miniplayer'.
const isPhysicallyInMiniplayer = !!videoEl.closest(S.ELEMENTS.MINIPLAYER_ELEMENT);
if (preserveMiniplayer && (session.type === 'miniplayer' || isPhysicallyInMiniplayer)) {
logLog('process', `⏭️ Preservando sesión en transición [${session.type}] para ${session.lastVideoId}`);
continue;
}
SessionOrchestrator.finalizeSession(videoEl, 'stopAllSessions');
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;
FailSafeManager.maybeExit();
if (!RouteContextResolver.isContextLocked(videoEl, type)) {
SessionTelemetry.emit('routingDecision', {
decision: 'reject',
reason: 'contextMismatch',
context: type,
videoId
});
return;
}
// Si ya hay una sesión para este mismo video, no hacer nada
const currentSession = activeProcessingSessions.get(videoEl);
if (currentSession?.lastVideoId === videoId && currentSession?.type === type && currentSession?.state !== 'finalized') return;
// Si hay una sesión vieja para un video diferente en el mismo elemento, limpiar
if (currentSession && currentSession.lastVideoId !== videoId) {
SessionOrchestrator.finalizeSession(videoEl, 'preStartCleanup');
}
// Limpiar proactivamente cualquier mensaje o estado visual previo (zombies de SPA)
clearPlaybackMessagesForType(type);
const startResult = SessionOrchestrator.startSession(videoEl, type, videoId, player, 'startProcessingSession');
if (!startResult.accepted) return;
const sessionRef = startResult.session;
logInfo('process', `🚀 Iniciando sesión de seguimiento para [${type}] - ${videoId}`);
// Limpiamos efímeros del DOM para que no se filtren a videos subsiguientes si YT reusa el tag <video>
// Esto es especialmente útil en Shorts donde el DOM se reutiliza agresivamente.
videoEl.dataset.sessionStartTime = Date.now().toString();
videoEl.dataset.lastSavedTime = '-1';
videoEl.dataset.lastResumedTime = '0';
delete videoEl.dataset.lastSavedTime; // Más seguro
delete videoEl.dataset.lastResumedTime;
// 1. Intentar reanudar lo más pronto posible (Fast-Path)
// No bloqueamos el inicio de la reanudación esperando el waterfall completo de metadatos.
// Extraemos un playlistId rápido (URL o API sincrónica) para la consulta inicial.
const fastPlaylistId = (typeof player?.getPlaylistId === 'function' ? player.getPlaylistId() : null) ||
(type === 'watch' ? extractYouTubePlaylistIdFromUrl(window.location.href) : null);
// Inicializar metadatos básicos inmediatamente en la sesión para que el primer guardado
// tenga contexto incluso si el waterfall de metadatos pesados aún no termina.
sessionRef.videoInfo = {
videoId: videoId,
lastViewedPlaylistId: fastPlaylistId || null,
title: null,
author: null,
isLive: false,
viewCount: 0,
lengthSeconds: player?.getDuration?.() || videoEl.duration || 0
};
sessionRef.isResumePending = true;
getSavedVideoData(videoId, fastPlaylistId).then(async savedData => {
// Verificar que la sesión no haya sido finalizada durante la lectura asíncrona de storage
if (savedData && activeProcessingSessions.get(videoEl) === sessionRef && !sessionRef.isFinalized) {
syncManualSaveUI(videoId, true, !!savedData.forceResumeTime);
// Blindaje anti doble-completado usando la duración ya disponible en videoInfo
if (isResumeAtCompletionZone(savedData, sessionRef.videoInfo)) {
sessionRef.hasLoggedCompletion = true;
logLog('process', `🛡️ Blindaje anti doble-completado activado (fast-path) para ${videoId}`);
}
// Guardar referencia a savedData en la sesión para acceso posterior desde el intervalo
sessionRef.savedData = savedData;
if (sessionRef.isPlayerSettingsChange) {
logLog('process', `⏭️ Resume omitido: cambio de configuraciones detectado`);
sessionRef.isPlayerSettingsChange = false;
sessionRef.isResumePending = false;
return;
}
if (savedData.watchProgress > 1 || savedData.forceResumeTime > 0 || savedData.isCompleted) {
if (shouldSkipResumeForActivePlayback(videoEl, type, videoId, savedData)) {
logLog('process', `⏭️ Resume omitido: reproducción ya sincronizada`);
sessionRef.isResumePending = false;
return;
}
recentResumeAttempts.set(videoEl, { videoId, ts: Date.now() });
// PlaybackController.resume maneja internamente la espera a que el video esté listo (isReady)
// Pasamos la sesión para permitir que el resume se aborte si la sesión cambia o finaliza.
await PlaybackController.resume(player, videoId, videoEl, savedData, type, sessionRef);
}
} else if (!savedData) {
syncManualSaveUI(videoId, false);
}
sessionRef.isResumePending = false;
});
// 2. Obtener metadatos base completos (Waterfall asíncrono pesado)
// Esto puede tardar segundos si requiere retries de miniplayer o fetches de títulos de playlist.
// Lo ejecutamos en paralelo para no bloquear el resume, pero lo awaitamos antes de iniciar el intervalo.
try {
const freshInfo = await getCascadedVideoInfo(player, videoId, videoEl, type);
// Protección crítica post-await: Si hubo navegación o finalización durante el waterfall, abortar.
const sessionStillValid = activeProcessingSessions.get(videoEl) === sessionRef && !sessionRef.isFinalized;
if (!sessionStillValid || (navIdAtStart !== globalNavigationId && type !== 'miniplayer')) {
logWarn('process', `🛑 Sesión [${type}] invalidada durante fetch de metadatos, abortando inicio de intervalo.`);
if (sessionRef.intervalId) clearInterval(sessionRef.intervalId);
return;
}
// Fusionar metadatos frescos en la sesión, preservando el playlistId del fast-path si el nuevo es inválido.
if (freshInfo) {
if (!freshInfo.lastViewedPlaylistId && sessionRef.videoInfo.lastViewedPlaylistId) {
freshInfo.lastViewedPlaylistId = sessionRef.videoInfo.lastViewedPlaylistId;
}
Object.assign(sessionRef.videoInfo, freshInfo);
}
logInfo('process', `💾 Metadatos cacheados para seguimiento de [${type}] - ${videoId}`);
} catch (e) {
logWarn('process', `⚠️ Error obteniendo metadatos completos, se delegará la resolución al primer tick:`, e);
}
// 3. 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 = sessionRef.videoInfo?.isLive || false;
const isAutoSaveEnabled =
type === 'shorts' ? cachedSettings?.saveShorts :
type === 'preview' ? cachedSettings?.saveInlinePreviews :
type === 'miniplayer' ? cachedSettings?.saveMiniplayerVideos :
(isLive ? cachedSettings?.saveLiveStreams : cachedSettings?.saveRegularVideos);
if (isAutoSaveEnabled !== false) {
let tickCount = 0;
intervalId = setInterval(async () => {
// Self-destruct: verificar que esta instancia siga siendo la sesión activa y no esté finalizada.
// Si la sesión fue reemplazada por otra en el mismo elemento o finalizada externamente,
// este intervalo debe detenerse para evitar procesos "zombie" y fugas de memoria.
const currentSession = activeProcessingSessions.get(videoEl);
if (!currentSession || currentSession !== sessionRef || sessionRef.isFinalized) {
clearInterval(intervalId);
logLog('process', `🧹 Zombie interval eliminado [${type}] - ${videoId}`);
return;
}
tickCount++;
// --- UI WATCHDOG ---
// Si el usuario usa scripts como "Engine Tamer", el DOM puede ser reemplazado post-carga.
// Verificamos periódicamente si nuestra UI sigue presente y la re-inyectamos si es necesario.
if (tickCount % 4 === 0 && type === 'watch') {
const display = document.getElementById('ypp-time-display-indicator');
if (!display || !display.isConnected) {
logWarn('startProcessingSession', '🔍 UI Watchdog: Detectada desaparición de controles. Re-inyectando...');
initTimeDisplay(player);
}
}
// --- PERSISTENCE RESCUE ---
// Usa sessionRef.savedData (resuelto asíncronamente desde storage en fast-path).
// Si el video sigue en 0s pero deberíamos haber reanudado, forzamos un re-seek de último recurso.
const sessionSavedData = sessionRef.savedData;
const isCurrentlyAd = AdDetector.isNodeWithinAdContainer(videoEl);
if (tickCount === 6 && videoEl.currentTime < 1 && (sessionSavedData?.watchProgress ?? 0) > 10 && !isCurrentlyAd) {
logWarn('startProcessingSession', '🆘 Persistence Rescue: El video sigue en 0s tras 6s. Forzando re-seek...');
PlaybackController.resume(player, videoId, videoEl, { ...sessionSavedData, forceResumeTime: sessionSavedData.watchProgress }, type, sessionRef);
}
if (sessionRef.isResumePending) {
// Evitar guardar 0s (falso progreso) mientras el resume original aún está en bucle de espera
return;
}
// 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;
// Detectar "Ghosts": Previews o Miniplayer que siguen en el DOM pero estan ocultos (pooled)
const isHiddenGhost = (type === 'preview' || type === 'miniplayer') && !isVisiblyDisplayed(videoEl);
if (isDisconnected || isAdNow || hasIdChanged || isHiddenGhost) {
const session = activeProcessingSessions.get(videoEl);
const currentSrc = videoEl.currentSrc || videoEl.src || null;
// En previews, YouTube puede reportar IDs transitorios del player compartido
// sin cambiar todavía el <video src>. Evitamos cortar sesión por un único mismatch.
if (type === 'preview' && hasIdChanged && !isDisconnected && !isAdNow) {
const hadSessionSrc = !!session?.lastKnownSrc;
const hasRealSrcChange = hadSessionSrc && currentSrc && session.lastKnownSrc !== currentSrc;
if (!hasRealSrcChange) {
session.idMismatchCount = (session.idMismatchCount || 0) + 1;
if (session.idMismatchCount <= 2) {
return;
}
}
}
// Si el ID cambió pero el elemento está en transición (cambio de src controlado por VideoObserverManager),
// no terminar la sesión - dejar que el observer maneje la transición
if (hasIdChanged && previewTransitions.has(videoEl)) {
previewTransitions.delete(videoEl); // Limpiar el flag de transición
if (FailSafeManager.isSafeMode()) {
SessionOrchestrator.finalizeSession(videoEl, 'safeModePreviewTransition');
VideoObserverManager.enqueueWithResolver(videoEl, 'preview', 'safeModeRestart');
return;
}
SessionOrchestrator.handoffSession(videoEl, currentVideoId, 'previewTransition', 'intraNodeHandoff');
return;
}
if (hasIdChanged && type === 'miniplayer' && VideoObserverManager.hasMiniplayerTransition(videoEl)) {
VideoObserverManager.clearMiniplayerTransition(videoEl);
if (FailSafeManager.isSafeMode()) {
SessionOrchestrator.finalizeSession(videoEl, 'safeModeMiniplayerTransition');
VideoObserverManager.requeueMiniplayer(videoEl);
return;
}
SessionOrchestrator.handoffSession(videoEl, currentVideoId, 'miniplayerTransition', 'intraNodeHandoff');
logInfo('process', `🔄 Handoff de sesión [miniplayer] por transición de ID: ${videoId} → ${currentVideoId}`);
VideoObserverManager.requeueMiniplayer(videoEl);
return;
}
const logReason = isDisconnected ? 'Elemento removido' :
isAdNow ? 'Anuncio detectado' :
hasIdChanged ? `ID cambiado: ${currentVideoId}` :
`${type === 'miniplayer' ? 'Miniplayer cerrada' : 'Preview oculta'} (ghost)`;
logInfo('process', `🛑 Deteniendo sesión [${type}] - ${videoId}. Razón: ${logReason}`);
SessionOrchestrator.finalizeSession(videoEl, logReason);
if (isAdNow) {
try {
VideoObserverManager.waitForAdClear(videoEl, type);
} catch (_) { }
}
// Seguridad extra para previews: re-encolar explícitamente si hubo cambio de ID real
// y el observer de src no alcanzó a capturarlo.
if (type === 'preview' && hasIdChanged && document.contains(videoEl)) {
try {
VideoObserverManager.bootstrap(true);
} catch (_) {
try { processPreviewVideo(videoEl); } catch (_) { }
}
}
return;
}
// Llamada unificada al controlador modular de guardado usando metadatos cacheados
const session = activeProcessingSessions.get(videoEl);
if (session) {
session.idMismatchCount = 0;
session.lastKnownSrc = videoEl.currentSrc || videoEl.src || session.lastKnownSrc || null;
}
const result = await PlaybackController.saveStatus(player, videoEl, type, videoId, session?.videoInfo);
if (result?.videoInfo && session) {
session.videoInfo = result.videoInfo;
}
}, (Math.max(cachedSettings?.minSecondsBetweenSaves || 1, 1)) * 1000); // Guardar según configuración (default 1s)
// Registrar el intervalId inmediatamente para permitir que finalizeSession() pueda limpiarlo
// si la sesión se termina antes de completar el Object.assign final.
sessionRef.intervalId = intervalId;
} else {
logLog('process', `Intervalo omitido para [${type}] - ${videoId} (Guardado automático desactivado)`);
}
Object.assign(sessionRef, {
intervalId,
lastVideoId: videoId,
type,
hasLoggedCompletion: false,
idMismatchCount: 0,
lastKnownSrc: videoEl.currentSrc || videoEl.src || null
});
SessionOrchestrator.transitionState(sessionRef, 'active', 'sessionBootstrapped');
SessionFallbackManager.ensureForSession(sessionRef, 'sessionStart');
// Fast-path para previews: no esperar al primer intervalo (1s por defecto),
// porque en hover corto el usuario puede salir antes del primer guardado.
if (type === 'preview') {
setTimeout(async () => {
const session = activeProcessingSessions.get(videoEl);
if (!session || session.lastVideoId !== videoId || session.type !== 'preview') return;
if (!videoEl.isConnected || AdDetector.isNodeWithinAdContainer(videoEl)) return;
if (videoEl.paused) return;
try {
if (session.isResumePending) return;
const result = await PlaybackController.saveStatus(player, videoEl, type, videoId, session.videoInfo);
if (result?.videoInfo) {
session.videoInfo = result.videoInfo;
}
} catch (_) { }
}, 220);
}
};
async function processWatchVideo(videoEl) {
if (!RouteContextResolver.isContextLocked(videoEl, 'watch')) return;
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 playerVideoId = player ? getPlayerVideoId(player) : null;
const urlId = extractYouTubeVideoIdFromUrl(window.location.href);
// Validacion Crítica: Evitar race conditions en navegación SPA.
// Si el Player reporta un ID diferente al de la URL, es que la API interna aún no se ha actualizado.
if (!playerVideoId || playerVideoId !== urlId) {
logWarn('processWatchVideo', `⚠️ Mismatch de ID detectado (Player: ${playerVideoId} vs URL: ${urlId}). Abortando procesamiento hasta actualización de API.`);
return;
}
const videoId = playerVideoId;
// 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) {
if (!RouteContextResolver.isContextLocked(videoEl, 'shorts')) return;
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 playerVideoId = player ? getPlayerVideoId(player) : null;
const urlId = extractYouTubeVideoIdFromUrl(window.location.href);
// Validacion Crítica: Evitar race conditions en navegación SPA (Shorts).
if (!playerVideoId || playerVideoId !== urlId) {
logWarn('processShortsVideo', `⚠️ Mismatch de ID detectado en Shorts (Player: ${playerVideoId} vs URL: ${urlId}).`);
return;
}
const videoId = playerVideoId;
initShortsTimeDisplay()
logInfo('processShortsVideo', `📱 Procesando video de Shorts: ${videoId}`);
startProcessingSession(videoEl, 'shorts', videoId, player);
}
async function processMiniplayerVideo(videoEl) {
if (!RouteContextResolver.isContextLocked(videoEl, 'miniplayer')) return;
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;
}
// Evitar IDs stale durante transición de src/SPA en miniplayer.
if (player) playerVideoIdCache.delete(player);
// Para miniplayer, priorizar SIEMPRE el ID del player local.
// youtubeHelperApi.video.id representa el contexto global (watch/preview) y puede diferir,
// causando sesiones inválidas que se detienen por "ID cambiado".
const playerVideoId = player ? getPlayerVideoId(player) : null;
const helperVideoId = (typeof youtubeHelperApi !== 'undefined' && youtubeHelperApi.video?.id)
? youtubeHelperApi.video.id
: null;
let videoId = playerVideoId || helperVideoId || null;
if (playerVideoId && helperVideoId && playerVideoId !== helperVideoId) {
logWarn(
'processMiniplayerVideo',
`⚠️ Desfase de IDs detectado (player=${playerVideoId}, helper=${helperVideoId}). Priorizando player para sesión miniplayer.`
);
}
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 previewContextLocked = RouteContextResolver.isContextLocked(videoEl, 'preview');
if (!previewContextLocked) return;
// Si miniplayer está activo, priorizamos su sesión para evitar competencia de IDs/seek.
if (DOMHelpers.getMiniplayerPlayer() && !DOMHelpers.getMiniplayerPlayerVideo()?.paused) {
logLog('processPreviewVideo', '⏭️ Omitiendo preview: miniplayer activo.');
SessionOrchestrator.finalizeSession(videoEl, 'previewBlockedByMiniplayer');
return;
}
const isAd = AdDetector.isNodeWithinAdContainer(videoEl);
if (isAd) {
logWarn('processPreviewVideo', '🚫 Anuncio detectado en Preview, omitiendo procesamiento.');
SessionOrchestrator.finalizeSession(videoEl, 'previewAdDetected');
return;
}
const player = DOMHelpers.getInlinePreviewPlayer();
if (!player) return;
// Debounce de sesion (150ms) para evitar picos de CPU al deslizar sobre el grid
if (videoEl.dataset.yppDebouncing === 'true') return;
videoEl.dataset.yppDebouncing = 'true';
await new Promise(r => setTimeout(r, 150));
delete videoEl.dataset.yppDebouncing;
// Validar que el video siga siendo procesable
if (!videoEl.isConnected) {
logLog('processPreviewVideo', '⏭️ Preview desconectado tras debounce, omitiendo.');
return;
}
if (AdDetector.isNodeWithinAdContainer(videoEl)) {
logWarn('processPreviewVideo', '🚫 Anuncio detectado en Preview (post-debounce), omitiendo procesamiento.');
return;
}
const resolvedPlayerVideoId = player ? getPlayerVideoId(player) : null;
const activeMiniplayer = DOMHelpers.getMiniplayerPlayer();
const activeMiniplayerId = activeMiniplayer ? getPlayerVideoId(activeMiniplayer) : null;
const previewHref = videoEl.closest('ytd-rich-item-renderer, ytd-video-renderer, ytd-compact-video-renderer, ytd-grid-video-renderer, ytd-rich-grid-media')
?.querySelector?.('a#thumbnail[href], a.yt-simple-endpoint[href]')
?.getAttribute?.('href') || null;
const previewHrefVideoId = previewHref ? extractYouTubeVideoIdFromUrl(previewHref) : null;
// Preferimos el ID del enlace del tile cuando existe, para evitar IDs stale
// del player compartido durante coexistencia con miniplayer pausado.
const videoId = previewHrefVideoId || resolvedPlayerVideoId;
const hasPreviewIdConflictWithMiniplayer =
!!activeMiniplayerId &&
!!resolvedPlayerVideoId &&
!previewHrefVideoId &&
resolvedPlayerVideoId === activeMiniplayerId;
if (hasPreviewIdConflictWithMiniplayer) {
logLog(
'processPreviewVideo',
`⏭️ Omitiendo preview por conflicto de ID con miniplayer activo (id=${resolvedPlayerVideoId}).`
);
return;
}
if (!videoId) {
logWarn('processPreviewVideo', '⚠️ Omitiendo preview: no se pudo resolver videoId (href/player).');
return;
}
// Refuerzo: Doble validación antes de iniciar sesión para Previews
// Algunos anuncios no tienen la clase del player inmediatamente, pero se detectan por ID
if (AdDetector.isVideoIdAnAd(videoId)) {
logWarn('processPreviewVideo', `🚫 ID [${videoId}] identificado como anuncio por asociación, abortando preview.`);
return;
}
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} session - Referencia a la sesión actual para abortar si cambia.
*/
async resume(player, videoId, videoEl, savedData, type, session = null) {
if (!savedData || !videoId || !videoEl) return;
// Si se proporciona una sesión, verificar que no esté finalizada de entrada
if (session && session.isFinalized) return;
const isForced = savedData.forceResumeTime > 0;
let timeToSeek = isForced ? savedData.forceResumeTime : (savedData.watchProgress || 0);
// Si el video está completado y NO es un salto forzado por el usuario (desde el historial),
// determinamos el punto de reanudación según la preferencia del usuario.
const isCompletedResume = savedData.isCompleted && !isForced;
if (isCompletedResume) {
if (cachedSettings.resumeCompletedFromStart === true) {
logInfo('PlaybackController', `🔄 Video completado detectado. Forzando reanudación desde el inicio (0s) según configuración.`);
timeToSeek = 0;
} else if (timeToSeek <= 1) {
logInfo('PlaybackController', `⏩ Video completado detectado. Usando sentinel 1.1s para forzar salto al final.`);
timeToSeek = 1.1; // Valor centinela para saltar el return temprano
}
}
// Permitir reanudación si es un video completado incluso si el tiempo es 0 (siempre que pase por los checks de arriba)
if (timeToSeek <= 1 && !isForced && !isCompletedResume) return;
logLog('PlaybackController', `🎬 Intentando reanudar ${videoId} en ${formatTime(timeToSeek)} (${type})`);
const getExpectedDuration = () => {
if (session?.videoInfo?.lengthSeconds > 0) return session.videoInfo.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();
// Asegurar que el elemento de video tenga metadata (evita false positives por chache de sesión)
if (type === 'live' || type === 'shorts') {
return dur > 0 || videoEl.readyState >= 1;
}
return isFinite(dur) && dur > 0 && videoEl.readyState >= 1;
};
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 la sesión fue invalidada o reemplazada durante la espera
const currentSession = activeProcessingSessions.get(videoEl);
if (session && (session.isFinalized || currentSession !== session)) {
logWarn('PlaybackController', `🛑 Abortando resume para ${videoId}: sesión invalidada o reemplazada.`);
return;
}
// Abortar si el video ha cambiado físicamente en el player
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
let safeTime = timeToSeek;
// Si el video está completado y el progreso guardado es bajo (reset accidental o carga inicial)
// y NO queremos volver a empezar (resumeCompletedFromStart es false),
// entonces saltamos al final para que YouTube siga con la lista.
const shouldSeekToEnd = savedData.isCompleted && timeToSeek <= 1.5 && !cachedSettings.resumeCompletedFromStart && !isForced;
if (shouldSeekToEnd && finalDuration > 0) {
safeTime = Math.max(0, finalDuration - 0.5);
logLog('PlaybackController', `⏩ Video completado detectado con progreso bajo. Saltando al final (${formatTime(safeTime)}) para mantener flujo.`);
} else if (finalDuration > 0 && safeTime >= finalDuration) {
safeTime = Math.max(0, finalDuration - 1);
}
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();
videoEl.dataset.resumeRetries = '0';
// Notificar al usuario (Toast/PlaybackBar)
notifySeekOrProgress(safeTime, 'seek', { videoType: type, isForced: savedData.forceResumeTime > 0, videoEl });
// --- PERSISTENCE CHECK ---
// En cuentas logueadas, YouTube puede intentar forzar su propio progreso (native resume).
// Verificamos 800ms después si el tiempo se mantuvo o si saltó hacia atrás.
setTimeout(() => {
const currentSession = activeProcessingSessions.get(videoEl);
if (session && (session.isFinalized || currentSession !== session)) return;
const currentTime = videoEl.currentTime;
// Si el tiempo saltó hacia atrás más de 5 segundos respecto a lo que pedimos reanudar
if (safeTime > 10 && currentTime < (safeTime - 5)) {
logWarn('PlaybackController', `🔄 Detectado salto hacia atrás tras resume (${formatTime(safeTime)} -> ${formatTime(currentTime)}). Reintentando persistencia...`);
if (typeof player?.seekTo === 'function') {
player.seekTo(safeTime, true);
} else {
videoEl.currentTime = safeTime;
}
}
}, 800);
} catch (e) {
logError('PlaybackController', '❌ Error al aplicar seek:', e);
}
}
},
/**
* Guarda el estado actual de la reproducción.
* Coordina la obtención de metadatos, determinación de contexto y persistencia.
* @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;
if (!RouteContextResolver.isContextLocked(videoEl, type)) {
SessionTelemetry.emit('routingDecision', {
decision: 'reject',
reason: 'contextMismatchBeforeSave',
context: type,
videoId
});
return { success: false, reason: 'context_mismatch' };
}
const session = activeProcessingSessions.get(videoEl);
if (!session || session.isFinalized) return { success: false, reason: 'invalid_session' };
// Evitar ejecuciones concurrentes para la misma sesión (bloqueo mutuo)
if (session.isSaving && !options.isManual) return { success: false, reason: 'already_saving' };
session.isSaving = true;
try {
const currentTime = videoEl.currentTime || (typeof player?.getCurrentTime === 'function' ? player.getCurrentTime() : 0);
const duration = videoEl.duration || (typeof player?.getDuration === 'function' ? player.getDuration() : 0);
// Permitir times muy bajos (0) para detectar "Replay" en videos con tiempo fijo
if (!isFinite(currentTime) || currentTime < 0 || isNaN(duration) || duration <= 0) {
return { success: false, reason: 'invalid_time_metrics' };
}
// Throttling de guardado por cambio de tiempo: No guardar si el tiempo no se movió significativamente
// (Evita spam de I/O si el video está cargando o pausado)
const lastSavedTime = parseFloat(videoEl.dataset.lastSavedTime || '-1');
const lastResumedTime = parseFloat(videoEl.dataset.lastResumedTime || '-1');
const lastResumeTs = parseInt(videoEl.dataset.lastResumeTimestamp || '0', 10);
const timeSinceResume = Date.now() - lastResumeTs;
const diff = currentTime - lastSavedTime;
// --- PROTECTION: Anti-Native Resume Overwrite ---
// Si acabamos de reanudar hace menos de 10 segundos y el tiempo actual es mucho menor
// que el tiempo que pedimos reanudar, es probable que YouTube haya forzado su propio progreso.
// No guardamos este tiempo para evitar corromper el historial local con el de la cuenta.
if (timeSinceResume < 10000 && lastResumedTime > 10 && currentTime < (lastResumedTime - 5) && !options.isManual) {
logWarn('PlaybackController', `🛡️ Guardado bloqueado: Posible conflicto con reanudación nativa (${formatTime(currentTime)} < ${formatTime(lastResumedTime)})`);
return { success: false, reason: 'native_resume_conflict' };
}
if (Math.abs(diff) < 0.05 && !options.isManual) {
return { success: false, reason: 'time_not_changed' };
}
// Protección contra saltos falsos tras carga (resets de YouTube hacia atrás al cargar autoplay)
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 (!options.isManual && diff < -5 && timeSinceRelevantStart < 5000) {
logInfo('saveStatus', `Saltando guardado: Posible reset del player tras carga/resume (diff: ${diff.toFixed(2)}s, age: ${timeSinceRelevantStart}ms)`);
// Rebound Seek: Si el player nos lanzó al inicio, intentamos re-aplicar el seek (máximo 3 veces)
let retryCount = parseInt(videoEl.dataset.resumeRetries || '0', 10);
if (retryCount < 3 && videoEl.dataset.lastResumedTime) {
const targetTime = parseFloat(videoEl.dataset.lastResumedTime);
if (!isNaN(targetTime)) {
logLog('saveStatus', `🔄 Re-aplicando seek a ${targetTime}s debido a reset forzado de YouTube... (Intento ${retryCount + 1}/3)`);
try {
if (typeof player?.seekTo === 'function') player.seekTo(targetTime, true);
else videoEl.currentTime = targetTime;
videoEl.dataset.resumeRetries = (retryCount + 1).toString();
// Extender ligeramente la ventana de protección al reintentar
videoEl.dataset.lastResumeTimestamp = Date.now().toString();
} catch (e) { }
}
}
return { success: false, reason: 'player_reset_detected' };
}
if (videoEl.paused) {
logInfo('saveStatus', `Video ${type} - ${videoId} pausado`)
// Si el video está pausado y no fue un seek manual (diff pequeña)
if (Math.abs(diff) < CONFIG.minSeekDiff && !options.isManual) {
return { success: false, reason: 'paused_no_seek' };
}
}
// Registrar el último tiempo guardado exitosamente ANTES de la operación async para bloquear ticks rápidos
videoEl.dataset.lastSavedTime = currentTime.toString();
// Throttled Metadata Refresh: Evitar fetchs innecesarios cada tick (min cooldown 30s)
const lastMetaFetch = parseInt(videoEl.dataset.lastMetaFetch || '0', 10);
const needsRefresh = !videoInfo || !videoInfo.viewCount || videoInfo.viewCount === 0;
const cooldownElapsed = (Date.now() - lastMetaFetch > 30_000);
// Si es manual (isManual) y no tenemos info, forzamos el refresco ignorando el cooldown de 30s.
if (needsRefresh && (cooldownElapsed || options.isManual)) {
logInfo('saveStatus', `🔄 Refrescando metadatos para ${videoId} (cooldown bypass/missing data)`);
videoEl.dataset.lastMetaFetch = Date.now().toString();
const freshInfo = await getCascadedVideoInfo(player, videoId, videoEl, type);
if (videoInfo && freshInfo) {
Object.assign(videoInfo, freshInfo);
} else {
videoInfo = freshInfo;
}
}
// Seguridad: Si llegamos aquí y videoInfo sigue siendo null (ej. falló fetch y no hay cache),
// abortamos para evitar TypeError en el siguiente paso (finalType).
if (!videoInfo) {
logWarn('saveStatus', `⚠️ videoInfo es null tras intento de refresco para ${videoId}. Abortando guardado.`);
return { success: false, reason: 'missing_video_metadata', videoInfo };
}
// 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]' : ''}`);
SessionTelemetry.emit('saveDecision', {
sessionId: session?.sessionId,
context: finalType,
transitionToken: session?.transitionToken,
triggerSource: options.isManual ? 'manual' : 'interval',
decision: 'save',
reason: 'eligible'
});
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;
switch (finalType) {
case 'live':
result = await saveLivestream(player, currentTime, videoInfo, videoEl, saveOptions);
break;
case 'shorts':
result = await saveShortsVideo(player, 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(player, currentTime, videoInfo, videoEl, specificPreviewType, saveOptions);
}
break;
case 'watch':
result = await saveRegularVideo(player, currentTime, videoInfo, videoEl, saveOptions);
break;
case 'miniplayer':
result = await saveMiniplayer(player, currentTime, videoInfo, videoEl, saveOptions);
break;
default:
result = await saveRegularVideo(player, currentTime, videoInfo, videoEl, saveOptions);
break;
}
// 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 });
}
if (result) result.videoInfo = videoInfo;
return result;
} catch (e) {
logError('PlaybackController', `❌ Error inesperado guardando ${videoId}:`, e);
return { success: false, reason: 'exception', error: e.message };
} finally {
if (session) session.isSaving = false;
}
},
};
// 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) {
// Cache global de metadatos (TTL 5 min)
const now = Date.now();
const cached = _videoMetadataCache.get(videoId);
if (cached && (now - cached.ts < 300_000)) {
// logWarn('getCascadedVideoInfo', `Hit cache global para ${videoId}`);
return { ...cached.info };
}
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
};
const finalizeInfo = (res) => {
if (res.title && res.author && (res.viewCount !== null || res.isLive)) {
// LRU eviction: si el cache excede el tamaño máximo, eliminar la entrada más antigua
if (_videoMetadataCache.size >= _MAX_VIDEO_METADATA_CACHE_SIZE) {
const oldestKey = _videoMetadataCache.keys().next().value;
_videoMetadataCache.delete(oldestKey);
}
_videoMetadataCache.set(videoId, { info: { ...res }, ts: Date.now() });
}
return res;
};
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 { videoId: videoIdFromUrl, playlistId: playlistIdFromUrl } = getYouTubeVideoContextFromUrl(window.location.href);
if (playlistIdFromUrl && videoIdFromUrl === info.videoId) {
// URL y estado están sincronizados es seguro usar playlist
info.lastViewedPlaylistId = playlistIdFromUrl;
logLog(
'getCascadedVideoInfo',
`Playlist id obtenido de URL fallback: [${playlistIdFromUrl}]`
);
}
}
if (type === 'preview') {
const videoPreviewLink = DOMHelpers.get('preview:link', () => document.querySelector(`${S.IDS.VIDEO_PREVIEW_CONTAINER} a#media-container-link`), 100);
if (videoPreviewLink?.href) {
const videoPreviewPlaylistId = extractYouTubePlaylistIdFromUrl(videoPreviewLink.href);
if (videoPreviewPlaylistId) {
info.lastViewedPlaylistId = videoPreviewPlaylistId;
logLog('getCascadedVideoInfo', `Playlist id obtenido de video preview fallback: [${videoPreviewPlaylistId}]`);
} else {
logInfo('getCascadedVideoInfo', 'No se encontró 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 playlistId = extractYouTubePlaylistIdFromUrl(anchor?.href);
logLog(
'getCascadedVideoInfo',
`Anchor href: [${anchor?.href}] playlist: ${playlistId}`
);
if (playlistId) {
currentPlaylistId = playlistId;
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 finalizeInfo(info);
}
// ------------------------------------------
// MARK: 📂 Sort UI
// ------------------------------------------
/**
* Crea un dropdown personalizado con soporte de iconos SVG.
* Reemplaza el `<select>` nativo que no permite contenido SVG en sus opciones.
* @param {Object} config - Configuración del dropdown.
* @param {string} config.id - ID del elemento raíz.
* @param {string} config.wrapperClass - Clase CSS adicional para el wrapper.
* @param {string} config.initialValue - Valor seleccionado al inicio.
* @param {Array<{value: string, label: string, icon?: string, isGroup?: boolean}>} config.options - Lista de options o group-labels.
* @param {(value: string) => void} config.onChange - Callback al cambiar la selección.
* @returns {HTMLElement} El elemento wrapper del dropdown.
*/
function createCustomDropdown({ id, wrapperClass = '', initialValue, options, onChange }) {
let currentValue = initialValue;
let isOpen = false;
const wrapper = createElement('div', { className: `ypp-custom-dropdown ${wrapperClass}`.trim() });
if (id) wrapper.id = id;
wrapper.dataset.value = currentValue;
// Find the initial option to show its icon + label in the trigger
const findOption = (val) => options.find(o => !o.isGroup && o.value === val);
const initialOption = findOption(currentValue);
const trigger = createElement('button', {
className: 'ypp-dropdown-trigger',
atribute: { type: 'button', 'aria-haspopup': 'listbox', 'aria-expanded': 'false' }
});
const triggerIcon = createElement('span', { className: 'ypp-dropdown-trigger-icon' });
setInnerHTML(triggerIcon, initialOption?.icon ?? '');
const triggerLabel = createElement('span', {
className: 'ypp-dropdown-trigger-label',
text: initialOption?.label ?? ''
});
const triggerChevron = createElement('span', { className: 'ypp-dropdown-trigger-chevron' });
setInnerHTML(triggerChevron, SVG_ICONS.chevronDown);
trigger.append(triggerIcon, triggerLabel, triggerChevron);
// Build list
const list = createElement('div', {
className: 'ypp-dropdown-list hidden',
atribute: { role: 'listbox' }
});
for (const opt of options) {
if (opt.isGroup) {
const groupLabel = createElement('div', { className: 'ypp-dropdown-group-label' });
if (opt.icon) {
const gi = createElement('span', {});
setInnerHTML(gi, opt.icon);
groupLabel.appendChild(gi);
}
groupLabel.appendChild(createElement('span', { text: opt.label }));
list.appendChild(groupLabel);
continue;
}
const item = createElement('div', {
className: 'ypp-dropdown-item',
atribute: { role: 'option', 'aria-selected': String(opt.value === currentValue), 'data-value': opt.value }
});
if (opt.icon) {
const iconSpan = createElement('span', { className: 'ypp-dropdown-item-icon' });
setInnerHTML(iconSpan, opt.icon);
item.appendChild(iconSpan);
}
item.appendChild(createElement('span', { text: opt.label }));
item.addEventListener('click', () => {
if (currentValue === opt.value) { closeList(); return; }
currentValue = opt.value;
// Update trigger display
setInnerHTML(triggerIcon, opt.icon ?? '');
triggerLabel.textContent = opt.label;
// Update aria-selected
list.querySelectorAll('.ypp-dropdown-item').forEach(el => {
el.setAttribute('aria-selected', String(el.dataset.value === currentValue));
});
closeList();
wrapper.dataset.value = currentValue;
onChange(currentValue);
});
list.appendChild(item);
}
wrapper.append(trigger, list);
// -- State helpers --
const openList = () => {
isOpen = true;
list.classList.remove('hidden');
trigger.setAttribute('aria-expanded', 'true');
triggerChevron.classList.add('open');
// Defer so the current click doesn't immediately close it
requestAnimationFrame(() => document.addEventListener('click', onOutsideClick, { once: true }));
};
const closeList = () => {
isOpen = false;
list.classList.add('hidden');
trigger.setAttribute('aria-expanded', 'false');
triggerChevron.classList.remove('open');
document.removeEventListener('click', onOutsideClick);
};
const onOutsideClick = (e) => {
if (!wrapper.contains(e.target)) closeList();
};
trigger.addEventListener('click', () => {
isOpen ? closeList() : openList();
});
return wrapper;
}
/**
* Crea un selector de ordenamiento con iconos dinámicos para orden ascendente/descendente.
* @param {'recent'|'oldest'|'titleAZ'|'titleZA'|'authorAZ'|'authorZA'|'durationShort'|'durationLong'|'yourMostWatched'|'yourLeastWatched'|'mostViewsYoutube'|'leastViewsYoutube'|'progressDESC'|'progressASC'} currentValue - Valor inicial.
* @param {(value: string) => void} onChange - Callback al cambiar.
* @returns {HTMLElement}
*/
function createSortSelector(currentValue, onChange) {
const wrapper = createElement('div', { className: 'ypp-range-filter-section' });
const updateActive = (val) => wrapper.classList.toggle('active', val !== CONFIG.defaultFilters.orderBy);
updateActive(currentValue);
const chipLabel = createElement('span', {
className: 'ypp-filter-chip-label',
html: `${SVG_ICONS.sortDesc} ${t('sortBy')}`
});
const dropdown = createCustomDropdown({
id: 'sort-dropdown',
initialValue: currentValue,
onChange: (val) => {
updateActive(val);
onChange(val);
},
options: [
{ isGroup: true, label: t('sortBy'), icon: SVG_ICONS.sortDesc },
{ value: 'recent', label: t('mostRecent'), icon: SVG_ICONS.calendar },
{ value: 'oldest', label: t('oldest'), icon: SVG_ICONS.calendar },
{ isGroup: true, label: `${t('titleAZ')}`, icon: SVG_ICONS.sortDesc },
{ value: 'titleAZ', label: t('titleAZ'), icon: SVG_ICONS.sortDesc },
{ value: 'titleZA', label: t('titleZA'), icon: SVG_ICONS.sortAsc },
{ isGroup: true, label: `${t('authorAZ')}`, icon: SVG_ICONS.user },
{ value: 'authorAZ', label: t('authorAZ'), icon: SVG_ICONS.user },
{ value: 'authorZA', label: t('authorZA'), icon: SVG_ICONS.user },
{ isGroup: true, label: t('duration'), icon: SVG_ICONS.hourglass },
{ value: 'durationShort', label: t('durationShort'), icon: SVG_ICONS.hourglass },
{ value: 'durationLong', label: t('durationLong'), icon: SVG_ICONS.hourglass },
{ isGroup: true, label: t('yourMostWatched'), icon: SVG_ICONS.fire },
{ value: 'yourMostWatched', label: t('yourMostWatched'), icon: SVG_ICONS.fire },
{ value: 'yourLeastWatched', label: t('yourLeastWatched'), icon: SVG_ICONS.ice },
{ isGroup: true, label: t('mostViewsYoutube'), icon: SVG_ICONS.people },
{ value: 'mostViewsYoutube', label: t('mostViewsYoutube'), icon: SVG_ICONS.thumbsup },
{ value: 'leastViewsYoutube', label: t('leastViewsYoutube'), icon: SVG_ICONS.thumbsdown },
{ isGroup: true, label: t('progressDESC'), icon: SVG_ICONS.progressOneHundred },
{ value: 'progressDESC', label: t('progressDESC'), icon: SVG_ICONS.progressOneHundred },
{ value: 'progressASC', label: t('progressASC'), icon: SVG_ICONS.progressZero }
]
});
wrapper.appendChild(chipLabel);
wrapper.appendChild(dropdown);
return wrapper;
}
// ------------------------------------------
// MARK: 📂 Filters UI
// ------------------------------------------
/**
* Crea un selector de filtros con iconos dinámicos para orden ascendente/descendente.
* @param {'all'|'video'|'shorts'|'preview'|'live'|'playlist'|'completed'|'completedOnce'|'fixedTime'|'protected'} currentValue - Valor inicial.
* @param {(value: string) => void} onChange - Callback al cambiar.
* @returns {HTMLElement}
*/
function createFilterSelector(currentValue, onChange) {
const wrapper = createElement('div', { className: 'ypp-range-filter-section' });
const updateActive = (val) => wrapper.classList.toggle('active', val !== CONFIG.defaultFilters.filterBy);
updateActive(currentValue);
const chipLabel = createElement('span', {
className: 'ypp-filter-chip-label',
html: `${SVG_ICONS.funnel} ${t('filterByType')}`
});
const dropdown = createCustomDropdown({
id: 'filter-dropdown',
initialValue: currentValue,
onChange: (val) => {
updateActive(val);
onChange(val);
},
options: [
{ value: 'all', label: t('all'), icon: SVG_ICONS.search },
{ value: 'video', label: t('videos'), icon: SVG_ICONS.video },
{ value: 'shorts', label: t('shorts'), icon: SVG_ICONS.smartphone },
{ value: 'preview', label: t('previews'), icon: SVG_ICONS.eye },
{ value: 'live', label: t('liveStreams'), icon: SVG_ICONS.live },
{ value: 'playlist', label: t('playlist'), icon: SVG_ICONS.playlist },
{ value: 'completed', label: t('completedVideos'), icon: SVG_ICONS.check },
{ value: 'completedOnce', label: t('completedOnce'), icon: SVG_ICONS.check + SVG_ICONS.numberOneCircle },
{ value: 'fixedTime', label: t('videosWithFixedTime'), icon: SVG_ICONS.timer + SVG_ICONS.pin },
{ value: 'protected', label: t('protectedVideos'), icon: SVG_ICONS.shieldYesFill }
]
});
wrapper.appendChild(chipLabel);
wrapper.appendChild(dropdown);
return wrapper;
}
/**
* Crea un filtro híbrido que combina un selector de presets con campos de rango personalizados.
* Diseño compacto: chip-label encima, dropdown + inputs Min-Max en una sola fila.
* @param {'views'|'percent'} type - Tipo de filtro.
* @param {number} minVal - Valor mínimo actual.
* @param {number} maxVal - Valor máximo actual.
* @param {(min: number, max: number) => void} onChange - Callback al cambiar.
* @returns {HTMLElement}
*/
function createRangeFilter(type, minVal, maxVal, onChange) {
const wrapper = createElement('div', { className: 'ypp-range-filter-section' });
const isDefault = (min, max) => {
if (type === 'views') return min === CONFIG.defaultFilters.minViews && max === CONFIG.defaultFilters.maxViews;
if (type === 'percent') return min === CONFIG.defaultFilters.minPercent && max === CONFIG.defaultFilters.maxPercent;
return true;
};
const getProgressIcon = (p) => {
if (p >= 99) return SVG_ICONS.check;
if (p >= 95) return SVG_ICONS.progressOneHundred;
if (p >= 66) return SVG_ICONS.progressSixtySix;
if (p >= 33) return SVG_ICONS.progressThirtyThree;
return SVG_ICONS.progressZero;
};
const getIconForRange = (min, max) => {
if (type === 'views') return SVG_ICONS.people;
return getProgressIcon(min);
};
const labelKey = type === 'views' ? 'views' : (type === 'percent' ? 'percentWatched' : '');
const iconFor = type === 'views' ? SVG_ICONS.people : SVG_ICONS.progressOneHundred;
// Chip-label compacto con icono dinámico
const chipIcon = createElement('span', { className: 'ypp-filter-chip-icon', html: getIconForRange(minVal, maxVal) });
const chipLabel = createElement('span', {
className: 'ypp-filter-chip-label',
html: `${iconFor} ${t(labelKey)}`
});
const updateActive = (min, max) => {
wrapper.classList.toggle('active', !isDefault(min, max));
if (type === 'percent' && chipIcon) {
setInnerHTML(chipIcon, getProgressIcon(min));
}
};
updateActive(minVal, maxVal);
const controls = createElement('div', { className: 'ypp-range-controls' });
/** @type {{label: string, min: number, max: number, isCustom?: boolean}[]} */
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 }
];
presets.push({ label: t('custom'), min: -1, max: -1, isCustom: true });
// Determine initial selected preset value
const initialMatchingPreset = presets.find(p => !p.isCustom && minVal === p.min && maxVal === p.max);
const initialPresetValue = initialMatchingPreset
? JSON.stringify({ min: initialMatchingPreset.min, max: initialMatchingPreset.max })
: 'custom';
/** Referencia compartida para sincronizar el label del dropdown al cambiar inputs */
let dropdownRef = null;
const dropdownOptions = presets.map(p => ({
value: p.isCustom ? 'custom' : JSON.stringify({ min: p.min, max: p.max }),
label: p.label,
icon: p.isCustom ? SVG_ICONS.settings : (type === 'percent' ? getProgressIcon(p.min) : SVG_ICONS.people)
}));
const dropdown = createCustomDropdown({
id: `range-preset-${type}`,
initialValue: initialPresetValue,
options: dropdownOptions,
onChange: (val) => {
if (val === 'custom') return;
try {
const { min, max } = JSON.parse(val);
inputMin.value = min;
inputMax.value = max;
updateActive(min, max);
onChange(min, max);
} catch (_) { }
}
});
dropdownRef = dropdown;
// Inputs numéricos personalizados
const customGroup = createElement('div', { className: 'ypp-range-inputs-group' });
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',
atribute: {
type: 'text',
inputmode: 'numeric',
pattern: '[0-9]*',
placeholder: t('minLimit'),
title: t('minLimit'),
min: 0,
value: minVal ?? 0,
...(type === 'percent' ? { max: 100 } : {})
}
});
minWrapper.appendChild(inputMin);
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',
atribute: {
type: 'text',
inputmode: 'numeric',
pattern: '[0-9]*',
placeholder: t('maxLimit'),
title: t('maxLimit'),
min: 0,
value: maxVal ?? 0,
...(type === 'percent' ? { max: 100 } : {})
}
});
maxWrapper.appendChild(inputMax);
// Sincronización: inputs → dropdown label + filtro
const updateFromInputs = () => {
let min = parseInt(inputMin.value, 10) || 0;
let max = parseInt(inputMax.value, 10) || 0;
// Corrección inteligente de rango: el campo que tiene el foco "manda"
if (max > 0 && max < min) {
if (document.activeElement === inputMax) {
min = max;
inputMin.value = min;
} else {
max = min;
inputMax.value = max;
}
}
// Sincronizar dropdown con presets si hay coincidencia exacta
const currentPreset = presets.find(p => !p.isCustom && min === p.min && max === p.max);
const targetValue = currentPreset ? JSON.stringify({ min: currentPreset.min, max: currentPreset.max }) : 'custom';
const targetOpt = dropdownRef.querySelector(`.ypp-dropdown-item[data-value='${targetValue}']`);
if (targetOpt && targetOpt.getAttribute('aria-selected') !== 'true') {
targetOpt.click();
}
updateActive(min, max);
onChange(min, max);
};
const debouncedUpdate = debounce(updateFromInputs, 400);
// Validación de límites y sincronización
applyNumericClamping(inputMin, { min: 0, max: type === 'percent' ? 100 : undefined });
applyNumericClamping(inputMax, { min: 0, max: type === 'percent' ? 100 : undefined });
inputMin.addEventListener('input', debouncedUpdate);
inputMax.addEventListener('input', debouncedUpdate);
customGroup.appendChild(minWrapper);
customGroup.appendChild(createElement('span', { className: 'ypp-range-separator', text: '-' }));
customGroup.appendChild(maxWrapper);
controls.appendChild(dropdown);
controls.appendChild(customGroup);
wrapper.appendChild(chipLabel);
wrapper.appendChild(controls);
return wrapper;
}
/**
* Crea un campo de búsqueda con icono dinámico para buscar videos.
* @param {string} currentValue - Valor inicial.
* @param {(value: string) => void} onChange - Callback al cambiar.
* @returns {HTMLElement}
*/
function createSearchInput(currentValue, onChange) {
const wrapper = createElement('div', { className: '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 {number|null} Caché del tamaño de almacenamiento del script en bytes */
let scriptStorageUsageCache = null;
/** @type {boolean} Flag para indicar si hay un cálculo de almacenamiento en progreso */
let isCalculatingStorageUsage = false;
/** @type {number|null} Timestamp del último clic en recalcular (para debounce) */
let lastRecalculateClick = null;
/** @type {Map<string, string>} Cache global de títulos por ID para uso en createVideoEntry */
let modalVideoTitleById = new Map();
/** @type {Array<{target: EventTarget, event: string, handler: Function}>} Referencias a listeners del modal para cleanup */
let modalVisibilityListeners = [];
/** @type {HTMLElement|null} Referencia al área de playlist para cleanup */
let playlistAreaElement = null;
/** @type {Function|null} Referencia al handler de playlist para cleanup */
let playlistRefreshHandler = null;
/** @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-skeleton-container');
if (!loadingIndicator) {
loadingIndicator = createElement('div', {
className: 'ypp-skeleton-container',
html: Array(4).fill(`
<div class="ypp-skeleton-entry">
<div class="ypp-skeleton-thumb"></div>
<div class="ypp-skeleton-lines">
<div class="ypp-skeleton-line title"></div>
<div class="ypp-skeleton-line meta"></div>
<div class="ypp-skeleton-line meta-short"></div>
</div>
<div class="ypp-skeleton-actions">
<div class="ypp-skeleton-circle"></div>
<div class="ypp-skeleton-circle"></div>
<div class="ypp-skeleton-circle"></div>
</div>
</div>
`).join('')
});
}
if (!virtualScroller || !scrollerElCheck) {
// Primera carga o reconstrucción completa: limpiar observers previos y añadir loader
if (virtualScroller) {
virtualScroller.destroy?.();
virtualScroller = null;
}
setInnerHTML(listContainer, '');
const dummyStats = createElement('div', {
className: 'ypp-virtual-stats',
id: 'ypp-virtual-stats',
style: 'display: none;', // Oculto durante el esqueleto
html: `${SVG_ICONS.spinner} ${t('loading')}...`
});
listContainer.appendChild(dummyStats);
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: 35px; left: 0; right: 0; bottom: 0;
background: var(--ypp-bg);
z-index: 5;
overflow: hidden;
`;
if (!loadingIndicator.parentElement) listContainer.appendChild(loadingIndicator);
loadingIndicator.style.display = 'flex';
const statsEl = document.querySelector('#ypp-virtual-stats');
if (statsEl) {
statsEl.style.display = 'flex'; // Asegurar visibilidad en actualización
setInnerHTML(statsEl, `${SVG_ICONS.spinner} ${t('loading')}...`);
}
}
// Guardar posición de scroll antes de actualizar
const currentScrollTop = virtualScroller?.container?.scrollTop ?? 0;
// TEST: PARA CONGELAR LOS SKELETONS INFINITAMENTE
// await new Promise(() => {});
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?.total > 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?.total || 0);
if (currentOrderBy === 'yourLeastWatched') return (item.info.completionHistory?.total || 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-empty-state-composed');
if (filteredItems.length === 0) {
if (loadingIndicator) loadingIndicator.style.display = 'none';
if (scrollerEl) scrollerEl.style.display = 'none';
if (virtualScroller) virtualScroller.updateItems([]);
if (!emptyMsg) {
emptyMsg = createElement('div', {
className: 'ypp-empty-state-composed',
html: `
${SVG_ICONS.search}
<h3>${t('noSavedVideos')}</h3>
<p>${t('emptyStateSubtitle')}</p>
`
});
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-storage-usage" class="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-storage-usage" class="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',
html: `<span>${filteredItems.length} ${t('videos')}</span><span id="ypp-storage-usage" class="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;
}
// Limpiar listeners de visibilidad del modal para prevenir memory leaks
modalVisibilityListeners.forEach(({ target, event, handler }) => {
target.removeEventListener(event, handler);
});
modalVisibilityListeners = [];
// Limpiar listener de playlist para prevenir memory leaks
if (playlistAreaElement && playlistRefreshHandler) {
playlistAreaElement.removeEventListener('ypp-selection-changed', playlistRefreshHandler);
playlistAreaElement = null;
playlistRefreshHandler = 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;
}
// Limpiar caché y flags de almacenamiento
scriptStorageUsageCache = null;
isCalculatingStorageUsage = false;
lastRecalculateClick = 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]}`;
};
/**
* Calcula el tamaño de almacenamiento usado específicamente por el script.
* Usa una estrategia híbrida para optimizar rendimiento:
* 1. Prioridad 1: storageCache (síncrono, ya poblado durante inicialización)
* 2. Prioridad 2: tamaño del JSON exportado (rápido, una serialización)
* 3. Prioridad 3: iteración con Storage.get() (lento, solo como fallback)
*
* @async
* @function calculateScriptStorageUsage
* @returns {Promise<number>} Tamaño en bytes de los datos del script.
*/
const calculateScriptStorageUsage = async () => {
try {
// Prioridad 1: Usar storageCache (síncrono)
if (storageCache && storageCache.size > 0) {
let totalBytes = 0;
for (const [key, serialized] of storageCache.entries()) {
if (!isNonVideoStorageKey(key)) {
totalBytes += serialized.length * 2; // UTF-16: 2 bytes por caracter
}
}
return totalBytes;
}
// Prioridad 2: Usar tamaño del JSON exportado
const exportData = await getSyncData('export', null);
if (exportData) {
const jsonString = JSON.stringify(exportData);
return jsonString.length * 2; // UTF-16
}
// Prioridad 3: Iterar con Storage.get() (fallback lento)
const keys = await Storage.keys();
const videoKeys = keys.filter(k => !isNonVideoStorageKey(k));
let totalBytes = 0;
for (const key of videoKeys) {
const data = await Storage.get(key);
if (data) {
totalBytes += JSON.stringify(data).length * 2;
}
}
return totalBytes;
} catch (error) {
logError('calculateScriptStorageUsage', 'Error al calcular uso de almacenamiento:', error);
return 0;
}
};
/**
* Actualiza el indicador de uso de almacenamiento en el DOM.
*
* Muestra tres valores con tooltips explicativos:
* 1. Espacio usado por videos guardados del script
* 2. Espacio total de datos de YouTube en IndexedDB
* 3. Espacio total disponible en IndexedDB
*
* Incluye botón de recálculo manual con protecciones contra múltiples clics.
*
* @async
* @function updateStorageUsageIndicator
* @returns {Promise<void>} No retorna ningún valor.
*/
const updateStorageUsageIndicator = async () => {
const el = DOMHelpers.get('ui:storageUsage', () => document.querySelector('#ypp-storage-usage'), 500);
if (!el) return;
// Calcular uso del script (usar caché si está disponible)
let scriptUsage = scriptStorageUsageCache;
if (scriptUsage === null && !isCalculatingStorageUsage) {
isCalculatingStorageUsage = true;
try {
scriptUsage = await calculateScriptStorageUsage();
scriptStorageUsageCache = scriptUsage;
} catch (error) {
logError('updateStorageUsageIndicator', 'Error al calcular uso del script:', error);
scriptUsage = 0;
} finally {
isCalculatingStorageUsage = false;
}
}
// Obtener uso total de IndexedDB
const estimateFn = navigator?.storage?.estimate;
let totalUsage = 0;
let quota = 0;
if (typeof estimateFn === 'function') {
try {
const estimate = await estimateFn.call(navigator.storage);
if (Number.isFinite(estimate.usage) && Number.isFinite(estimate.quota) && estimate.quota > 0) {
totalUsage = estimate.usage;
quota = estimate.quota;
}
} catch (error) {
logError('updateStorageUsageIndicator', 'Error al obtener estimate de storage:', error);
}
}
// Formatear valores
const scriptUsageFormatted = formatBytes(scriptUsage);
const totalUsageFormatted = formatBytes(totalUsage);
const quotaFormatted = formatBytes(quota);
// Crear HTML con tooltips y botón de recálculo
const refreshIcon = SVG_ICONS.restart || '↻';
const html = `
<span title="${t('storageUsageVideosTooltip')}">${t('storageUsageVideos', { usage: scriptUsageFormatted })}</span>
<span title="${t('storageUsageTotalTooltip')}">${t('storageUsageTotal', { usage: totalUsageFormatted })}</span>
<span title="${t('storageUsageAvailableTooltip')}">${t('storageUsageAvailable', { usage: quotaFormatted })}</span>
<button class="ypp-recalculate-storage-btn" title="${t('recalculateStorageTooltip')}" style="background:none;border:none;cursor:pointer;padding:0 4px;margin-left:4px;color:inherit;">
${refreshIcon}
</button>
`;
setInnerHTML(el, html);
// Agregar listener al botón de recálculo
const recalcBtn = el.querySelector('.ypp-recalculate-storage-btn');
if (recalcBtn) {
recalcBtn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
// Debounce: prevenir múltiples clics rápidos (500ms)
const now = Date.now();
if (lastRecalculateClick && now - lastRecalculateClick < 500) {
return;
}
lastRecalculateClick = now;
// Verificar si ya hay un cálculo en progreso
if (isCalculatingStorageUsage) {
return;
}
// Invalidar caché y recalcular
scriptStorageUsageCache = null;
isCalculatingStorageUsage = true;
// Animar el ícono
const targetToAnimate = recalcBtn.querySelector('svg');
let animation = null;
if (targetToAnimate) {
targetToAnimate.style.transformOrigin = 'center';
animation = targetToAnimate.animate([
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }
], {
duration: 800, // Velocidad de la animación
iterations: Infinity,
easing: 'linear'
});
}
try {
// Esperar ambas promesas para asegurar al menos 500ms de animación visible
const [newUsage] = await Promise.all([
calculateScriptStorageUsage(),
new Promise(resolve => setTimeout(resolve, 500))
]);
scriptStorageUsageCache = newUsage;
// Actualizar UI sin recalcular (pasar flag para evitar recálculo)
const el = DOMHelpers.get('ui:storageUsage', () => document.querySelector('#ypp-storage-usage'), 500);
if (el) {
const scriptUsageFormatted = formatBytes(newUsage);
const spans = el.querySelectorAll('span');
if (spans[0]) spans[0].textContent = t('storageUsageVideos', { usage: scriptUsageFormatted });
}
} catch (error) {
logError('recalculateStorage', 'Error al recalcular almacenamiento:', error);
} finally {
isCalculatingStorageUsage = false;
if (animation) {
animation.cancel();
}
}
};
}
};
// ------------------------------------------
// 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.settingsFill} ${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';
};
floatingButtonListeners.push(
{ target: document, event: 'fullscreenchange', handler: updateVisibility },
{ target: window, event: 'yt-navigate-finish', handler: updateVisibility }
);
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-circle ypp-btn-outline-danger',
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(createElement('div', {
className: 'ypp-input-search-icon',
html: SVG_ICONS.search
}))
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.funnel} ${t('advancedFilters')}`
});
const filterBadge = createElement('span', { className: 'ypp-active-filter-badge', style: 'display: none;' });
advancedToggleBtn.appendChild(filterBadge);
searchContainer.appendChild(advancedToggleBtn);
topRow.appendChild(searchContainer);
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 - se agrega a filtersGrid para que display:contents
// los integre directamente como columns 3 y 4 del grid de 4 columnas.
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();
}
));
filtersGrid.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);
// Eliminar actualizaciones automáticas de almacenamiento (ahora es manual con botón recalcular)
// if (!storageUsageRefreshIntervalId) {
// storageUsageRefreshIntervalId = setInterval(() => {
// updateStorageUsageIndicator().catch(() => { });
// }, 60_000);
// }
const footer = createElement('div', { className: 'ypp-footer' });
// 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-primary ypp-shadow-md',
html: `${SVG_ICONS.compose} ${t('manageVideos')}`,
onClickEvent: async () => { await toggleManagementMode(); }
});
const btnCreatePlaylist = createElement('button', {
id: 'ypp-create-playlist-btn',
className: 'ypp-btn ypp-btn-outline-secondary 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-outline-primary ypp-shadow-md',
html: `${SVG_ICONS.settingsFill} ${t('settings')}`,
onClickEvent: async () => { await showSettingsUI(); }
});
const btnClose = createElement('button', {
className: 'ypp-btn ypp-btn-secondary',
html: `${SVG_ICONS.close} ${t('close')}`,
atribute: { 'aria-label': t('close') },
onClickEvent: closeModalVideos
});
secondRow.appendChild(btnToggleManagement);
secondRow.appendChild(btnCreatePlaylist);
secondRow.appendChild(btnSettings);
secondRow.appendChild(btnClose);
footer.appendChild(secondRow);
modalVideosFooterSecondRow = secondRow
videosContainer.appendChild(footer);
const handleOverlayClick = (e) => {
if (e.target === videosOverlay) closeModalVideos();
};
modalVisibilityListeners.push({ target: videosOverlay, event: 'click', handler: handleOverlayClick });
videosOverlay.addEventListener('click', handleOverlayClick);
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 de fondo para playlist basado en el hash de una cadena
* Usa variables CSS del sistema de botones que garantizan contraste AAA.
* @param {string} str - Cadena para generar el color
* @returns {string} Variable CSS con opacity para fondo suave
*/
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);
}
// Usar paleta de colores del sistema que cumplen contraste AAA
// No usar --ypp-success para no chocar con .ypp-protected-item y tampoco usar --ypp-danger
const variants = [
'var(--ypp-primary)',
'var(--ypp-secondary)',
'var(--ypp-warning)',
'var(--ypp-info)',
'var(--ypp-alert)',
'var(--ypp-violet)',
];
const index = Math.abs(hash) % variants.length;
// Versión con opacity para fondo suave (15%)
return `color-mix(in srgb, ${variants[index]}, transparent 85%)`;
}
/**
* Genera un color de borde para playlist basado en el hash de una cadena
* Usa variables CSS del sistema de botones que garantizan contraste AAA.
* @param {string} str - Cadena para generar el color
* @returns {string} Variable CSS para el borde
*/
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);
}
// Usar la misma paleta que el fondo para consistencia
// No usar --ypp-success para no chocar con .ypp-protected-item y tampoco usar --ypp-danger
const variants = [
'var(--ypp-primary)',
'var(--ypp-secondary)',
'var(--ypp-warning)',
'var(--ypp-info)',
'var(--ypp-alert)',
'var(--ypp-violet)',
];
const index = Math.abs(hash) % variants.length;
return variants[index]; // Color completo para borde visible
}
async function handleForceTimeAction(videoId) {
const 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.check} ${t('fixedTimeRemoved')}`);
}
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, titleCache) {
const title = escapeHTML(titleCache);
// Cargar info original por si deshace
const itemInfo = await Storage.get(videoId);
if (itemInfo?.isProtected) {
showFloatingToast(`${SVG_ICONS.warning} ${t('protectedVideoWarning')}`);
return;
}
const deleteFromStorage = async () => {
await Storage.del(videoId);
return true;
};
const undoDelete = async () => {
if (!itemInfo) return;
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;
switch (action) {
case 'force-time':
await handleForceTimeAction(videoId);
break;
case 'unlink-playlist':
await handleUnlinkPlaylistAction(videoId);
break;
case 'delete-entry':
const title = btn.dataset.title;
await handleDeleteEntryAction(videoId, title);
break;
case 'toggle-protection':
await handleToggleProtectionAction(videoId);
break;
}
});
};
async function handleToggleProtectionAction(videoId) {
const info = await Storage.get(videoId);
if (!info) {
logWarn('handleToggleProtectionAction', `No se encontró información para el video ${videoId}`);
return;
}
info.isProtected = !info.isProtected;
await Storage.set(videoId, info);
const msg = info.isProtected
? `${SVG_ICONS.shieldYesFill} ${t('protected')}`
: `${SVG_ICONS.shieldOff} ${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;
// Margen de seguridad para considerar un video como "terminado"
// Si faltan menos de 0.75 segundos, ya se considera “terminado”
const EPSILON = 0.75;
// Validar duración
const hasValidLength = Number.isFinite(lengthSeconds) && lengthSeconds > 0;
// ¿Está prácticamente al final?
const isNearEnd = hasValidLength && watchProgress >= lengthSeconds - EPSILON;
// ¿Se considera terminado?
const isDone = isCompleted || isNearEnd;
// Tiempo restante
const remaining = isDone
? 0
: Math.max(lengthSeconds - watchProgress, 0);
// Porcentaje visto
let percent = null;
if (hasValidLength) {
if (isDone) {
percent = 100;
} else {
percent = Math.min(
100,
Math.round((watchProgress / lengthSeconds) * 100)
);
}
}
const isShorts = type === 'shorts' || type === 'preview_shorts';
const isLiveEntry = type === 'live' || isLive === true;
const isPlaylistItem = !!playlistKey;
const finalPlaylistTitle =
escapeHTML(playlistTitle)
|| escapeHTML(playlistKey)
|| t('unknown');
const viewsText = `${escapeHTML(viewCount.toLocaleString())} ${t('views')}`;
const videoUrl = isShorts
? `https://www.youtube.com/shorts/${videoId}`
: `https://www.youtube.com/watch?v=${videoId}${playlistKey ? '&list=' + playlistKey : ''}`;
const playlistUrl = 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.stopWatch}${SVG_ICONS.pin} ${t('alwaysStartFrom')}: ${formatTime(normalizeSeconds(forceResumeTime))}`
: '';
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)}`}`);
let iconPercent;
if (percent >= 95) iconPercent = SVG_ICONS.progressOneHundred;
else if (percent >= 66) iconPercent = SVG_ICONS.progressSixtySix;
else if (percent >= 33) iconPercent = SVG_ICONS.progressThirtyThree;
else iconPercent = SVG_ICONS.progressZero;
// 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';
}
const selectionClass = isPlaylistCreationMode || isManagementMode ? 'selection-mode' : '';
const thumbClass = isShorts ? 'ypp-thumb-shorts' : 'ypp-thumb-regular';
const fragment = document.createDocumentFragment();
if (isPlaylistCreationMode || isManagementMode) {
fragment.appendChild(createElement('input', {
className: 'ypp-video-checkbox',
atribute: {
type: 'checkbox',
'data-action': 'toggle-selection',
'data-video-id': videoId
},
props: { checked: selectedVideos.has(videoId) }
}));
}
const skeletonEl = createElement('div', {
className: 'ypp-skeleton-thumb',
styles: { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', zIndex: '1', margin: '0', borderRadius: '0' }
});
const imgEl = createElement('img', {
className: `ypp-thumb ${thumbClass}`,
atribute: { title, alt: title, loading: 'lazy', draggable: 'false' },
});
fragment.appendChild(createElement('div', {
className: `ypp-thumb-wrapper ${thumbClass}`,
children: [skeletonEl, imgEl]
}));
const infoChildren = [];
infoChildren.push(createElement('a', {
className: 'ypp-titleLink',
html: `${escapeHTML(title)} ${SVG_ICONS.linkExternal}`,
atribute: { title, href: videoUrl, target: '_blank', rel: 'noopener noreferrer' }
}));
if (isPlaylistItem && finalPlaylistTitle) {
infoChildren.push(createElement('div', {
className: 'ypp-playlist-indicator ypp-shadow-md',
atribute: { title: `${t('playlist')}: ${finalPlaylistTitle} (${playlistKey})` },
styles: { color: playlistBorderColor },
children: [
createElement('a', {
className: 'ypp-playlist-link',
html: `${SVG_ICONS.playlist} ${finalPlaylistTitle} ${SVG_ICONS.linkExternal}`,
atribute: { title: `${t('openPlaylist')}: ${finalPlaylistTitle}`, href: playlistUrl, target: '_blank', rel: 'noopener noreferrer' }
})
]
}));
}
if (authorId) {
infoChildren.push(createElement('a', {
className: 'ypp-author ypp-link',
html: `${escapeHTML(author)} ${SVG_ICONS.linkExternal}`,
atribute: { title: `${t('openChannel')}: ${author}`, href: `https://www.youtube.com/channel/${authorId}`, target: '_blank', rel: 'noopener noreferrer' }
}));
} else {
infoChildren.push(createElement('div', { className: 'ypp-author', text: author }));
}
const viewsChildren = [document.createTextNode(viewsText)];
if (info.completionHistory?.total) {
const history = info.completionHistory;
const limit = 10;
const recent = history.events.slice(-limit).reverse();
const hasMore = history.total > recent.length;
let tooltip = `${t('watchedHistory')}:\n` + recent.map(ts => new Date(ts).toLocaleString().replace(',', '')).join('\n');
if (hasMore) tooltip += `\n... (+${history.total - recent.length})`;
viewsChildren.push(createElement('span', {
className: 'ypp-watched-count',
html: ` [${SVG_ICONS.check} ${t('watchedCount', { count: history.total }, 'Watched ' + history.total + ' times')}]`,
atribute: { title: tooltip }
}));
}
infoChildren.push(createElement('div', { className: 'ypp-views', children: viewsChildren }));
infoChildren.push(createElement('div', { className: `ypp-timestamp ${timestampClass}`, html: timestampText }));
if (!isCompleted) {
if (isLiveEntry) {
infoChildren.push(createElement('div', {
className: 'ypp-progressInfo',
html: `${SVG_ICONS.live} ${t('live')}`
}));
} else if (percent !== null) {
infoChildren.push(createElement('div', {
className: 'ypp-progressInfo',
html: `${iconPercent} ${percent} ${t('percentWatched')} (${formatTime(normalizeSeconds(remaining))} ${t('remaining')})`,
styles: { color: getProgressColorForText(percent) }
}));
}
}
fragment.appendChild(createElement('div', { className: 'ypp-infoDiv', children: infoChildren }));
const buttonsChildren = [];
if (!isLiveEntry) {
buttonsChildren.push(createElement('button', {
className: `ypp-btn ypp-btn-circle ${hasFixedTime ? 'ypp-btn-info' : 'ypp-btn-outline-info'} ypp-shadow-md`,
html: hasFixedTime ? SVG_ICONS.pin : SVG_ICONS.stopWatch,
atribute: { 'data-action': 'force-time', title: hasFixedTime ? t('changeOrRemoveStartTime', { time: formatTime(normalizeSeconds(info.forceResumeTime)) }) : t('setStartTime') }
}));
}
if (lastViewedPlaylistId) {
buttonsChildren.push(createElement('button', {
className: 'ypp-btn ypp-btn-circle ypp-btn-outline-secondary ypp-shadow-md',
html: SVG_ICONS.playlistRemove,
atribute: { 'data-action': 'unlink-playlist', title: t('removeFromPlaylist') }
}));
}
buttonsChildren.push(createElement('button', {
className: `ypp-btn ypp-btn-circle ${isProtected ? 'ypp-btn-success' : 'ypp-btn-outline-success'} ypp-shadow-md`,
html: isProtected ? SVG_ICONS.shieldYesFill : SVG_ICONS.shieldOff,
atribute: { 'data-action': 'toggle-protection', title: isProtected ? t('unprotect') : t('protect') }
}));
buttonsChildren.push(createElement('button', {
className: 'ypp-btn ypp-btn-circle ypp-btn-outline-secondary ypp-shadow-md',
id: 'ypp-btn-open-in-freetube',
html: SVG_ICONS.freetubeIconFill,
atribute: { title: t('openInFreeTube') },
onClickEvent: (event) => {
event.preventDefault();
window.location.assign(`freetube://${videoUrl}`);
}
}));
buttonsChildren.push(createElement('button', {
className: 'ypp-btn ypp-btn-circle ypp-btn-outline-secondary ypp-shadow-md',
id: 'ypp-btn-open-in-spotify',
html: SVG_ICONS.spotifyIconFill,
atribute: { title: t('searchInSpotify') },
onClickEvent: (event) => {
event.preventDefault();
event.stopPropagation();
const cleanTitleForSpotify = (t) => {
if (!t) return '';
let c = t.replace(/\(.*?\)/g, '').replace(/\[.*?\]/g, '')
// Removes common YouTube metadata (e.g., "Official Video", "Lyrics", "HD", "4K", etc.)
.replace(/\b(official\s*video|music\s*video|lyrics?|hd|4k|audio|remastered)\b/gi, '')
// Removes trailing context like "Live at ..." or "From ..." (often not part of track title)
.replace(/live\s+at.+$/i, '')
.replace(/from\s+.+$/i, '')
// Removes platform-specific suffixes like "- YouTube"
.replace(/\s*-\s*YouTube.*$/i, '')
// Normalizes different quote styles to standard double quotes
.replace(/["‘’“”]/g, '"')
// Removes "- Topic" suffix (auto-generated YouTube channels)
.replace(/\s*-\s*Topic.*$/i, '')
// Collapses extra whitespace and trims the result
.replace(/\s+/g, ' ')
.trim();
const q = c.match(/"([^"]+)"/);
if (q && q[1]) {
const a = c.split('"')[0].trim().replace(/[-–]+$/, '').trim();
return a ? `${a} - ${q[1]}` : q[1];
}
const p = c.split(/[-–—]+/).map(x => x.trim()).filter(Boolean);
return p.length >= 2 ? `${p[0]} - ${p[1]}` : c;
};
const cleanedTitle = cleanTitleForSpotify(title);
const query = encodeURIComponent(cleanedTitle);
const spotifyApp = `spotify:search:${query}`;
const spotifyWeb = `https://open.spotify.com/search/${query}`;
const opened = window.open(spotifyApp, '_blank', 'noopener,noreferrer');
setTimeout(() => {
if (!opened || opened.closed || opened.location.href === 'about:blank') {
window.open(spotifyWeb, '_blank', 'noopener,noreferrer');
}
}, 500);
}
}));
buttonsChildren.push(createElement('button', {
className: 'ypp-btn ypp-btn-circle ypp-btn-outline-danger ypp-shadow-md',
html: SVG_ICONS.trash,
atribute: { 'data-action': 'delete-entry', title: t('deleteEntry'), 'data-title': title }
}));
fragment.appendChild(createElement('div', { className: 'ypp-containerButtonsTime', children: buttonsChildren }));
const el = createElement('div', {
className: `ypp-videoWrapper ${itemClass} ${isProtected ? 'ypp-protected-item' : ''} ${selectionClass} ypp-video-item`,
atribute: {
'data-video-id': videoId,
/* 'data-video-type': type,
'data-video-status': isCompleted ? 'completed' : (isLiveEntry ? 'live' : 'watching'),
'data-video-duration': lengthSeconds,
'data-video-progress': watchProgress,
'data-video-percent': percent,
'data-video-remaining': remaining,
'data-video-author': author,
'data-video-author-id': authorId,
'data-video-channel': channel,
'data-video-channel-id': channelId,
'data-video-views': views,
'data-video-timestamp': timestamp,
'data-video-has-fixed-time': hasFixedTime,
'data-video-force-resume-time': hasFixedTime ? forceResumeTime : null,
'data-video-last-viewed-playlist-id': lastViewedPlaylistId, */
...(playlistKey ? { 'data-playlist-key': playlistKey } : {})
},
children: [fragment]
});
if (wrapperStyle) el.style.cssText = wrapperStyle;
getValidatedThumbnail(videoId).then(url => {
if (!imgEl) return;
imgEl.onload = () => {
imgEl.style.opacity = '1';
setTimeout(() => { if (skeletonEl) skeletonEl.remove(); }, 300);
};
imgEl.onerror = () => {
imgEl.style.opacity = '1';
if (skeletonEl) skeletonEl.remove();
};
imgEl.src = url;
// Hack para asegurar que onload se dispare si está en caché
if (imgEl.complete) imgEl.onload();
}).catch(() => {
if (imgEl) imgEl.style.opacity = '1';
if (skeletonEl) skeletonEl.remove();
});
return el;
}
// ------------------------------------------
// 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: '' });
try { showSavedVideosList(); } catch (e) { logError('registerMenuCommands', 'Error abriendo listado de historial:', e); }
});
GM_registerMenuCommand(`✅ ${t('viewCompletedVideos')}`, async () => {
await Filters.set({ filterBy: '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 = 6;
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) { logError('cleanupNonVideoData', 'Error al parsear metadato IDB:', 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) { logError('cleanupNonVideoData', 'Error al parsear config LS:', 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 (e) {
logError('cleanupNonVideoData', 'Error al rescatar vídeo desde localStorage:', e);
}
}
}
}
}
}
// --- 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 o estructura completionHistory plana (0.0.9-8)
const hadLegacyArray = Array.isArray(data.completionHistory);
const hadLegacy = data.timestamp !== undefined || data.duration !== undefined || data.lastUpdated !== undefined || data.viewsNumber !== undefined;
if (hadLegacy || hadLegacyArray || lastMigrationVersion < 6) {
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 initializationPromise = null;
let backupIntervalId = null;
// Inicialización global (solo una vez)
const initializeGlobal = async () => {
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;
// --- Configurar eventos de navegación y API ---
/**
* Maneja eventos de navegación y actualizaciones de la API para sincronizar el estado.
* Se usa una versión debounced para evitar ejecuciones redundantes cuando múltiples
* eventos (ej. navegación + API ready) disparan casi simultáneamente.
*
* 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 = () => {
const newPageType = getYouTubePageType();
const currentVideoId = extractYouTubeVideoIdFromUrl(window.location.href);
// Sincronizar tipo de página global
currentPageType = newPageType;
// Refined Navigation Guard:
// Solo ignoramos si el ID coincide Y ya tenemos una sesión activa para ese ID en el contexto principal.
// Esto permite que si la primera inicialización falló por race-condition (Player API stale),
// los eventos subsiguientes puedan reintentar el bootstrap.
const hasActiveSession = Array.from(activeProcessingSessions.values())
.some(s => (s.type === 'watch' || s.type === 'shorts') && s.lastVideoId === currentVideoId);
const hasActiveMiniplayerSession = Array.from(activeProcessingSessions.values())
.some(s => s.type === 'miniplayer');
const hasActivePreviewSession = Array.from(activeProcessingSessions.values())
.some(s => s.type === 'preview');
// firstLoadGuard: lastHandledPageType === null -> primera carga de página
const isFirstLoad = lastHandledPageType === null;
const isSamePageContext = isFirstLoad || newPageType === lastHandledPageType;
const isSameVideo = currentVideoId === lastHandledVideoId;
// Loop Guard & SPA Recovery:
// Si el ID coincide Y (tenemos sesión activa O es el mismo video/página que ya intentamos),
// evitamos el teardown (init(true)) que destruye el progreso.
if (currentVideoId && (hasActiveSession || (isSameVideo && isSamePageContext)) && isSamePageContext) {
logLog('handleNavigation', `Ignorando reinicio redundante para ${currentVideoId} (${newPageType}). Sesión activa: ${hasActiveSession}`);
// Si no hay sesión activa pero es el mismo video, intentamos un bootstrap ligero
// (skipCleanup=true) por si el player acaba de aparecer en el DOM.
if (!hasActiveSession && isSameVideo && isSamePageContext) {
if (typeof VideoObserverManager?.init === 'function') {
logLog('handleNavigation', '🔄 Programando reintento ligero de bootstrap (300ms)');
setTimeout(() => {
// Verificar nuevamente antes de ejecutar por si hubo otra navegación
if (extractYouTubeVideoIdFromUrl(window.location.href) === currentVideoId) {
VideoObserverManager.init(true, false, true);
}
}, 300);
}
}
return;
}
// En navegación no-watch, si ya hay miniplayer activo preservable y no cambió el tipo de página,
// evitar reinicialización forzada para no re-aplicar seek innecesariamente.
if (!currentVideoId && newPageType !== 'watch' && newPageType === lastHandledPageType && hasActiveMiniplayerSession) {
logLog('handleNavigation', `Ignorando reinicio: Miniplayer activo preservado (${newPageType})`);
return;
}
// Evitar teardown/reinit redundante en Home/Browse mientras una sesión preview está activa:
// yt-helper-api-ready puede refire sin cambio real de ruta y matar la sesión en curso.
if (!currentVideoId && newPageType === lastHandledPageType && hasActivePreviewSession) {
logLog('handleNavigation', `Ignorando reinicio: Preview activo preservado (${newPageType})`);
return;
}
// Actualizar trackers para la próxima navegación
lastHandledVideoId = currentVideoId;
lastHandledPageType = newPageType;
logInfo('handleNavigation',
`🌐 Actualización de estado detectada -
currentPageType: ${currentPageType}
URL Actual: ${window.location.href}
`);
// Solo preservamos el miniplayer si NO vamos a la página 'watch'
const preserveMiniplayer = currentPageType !== 'watch';
// Limpiar cachés de DOM para asegurar que el siguiente procesamiento use elementos frescos
DOMHelpers.clearAll();
// Reinicializar observers; forzar bootstrap solo cuando no estamos preservando miniplayer.
// Si preserveMiniplayer=true, los observers existentes del miniplayer y su sesión deben mantener continuidad.
const shouldForceBootstrap = !preserveMiniplayer;
const skipCleanup = preserveMiniplayer; // Evitar cleanup destructivo si preservamos miniplayer
if (typeof VideoObserverManager?.clearCache === 'function') VideoObserverManager.clearCache();
if (typeof VideoObserverManager?.init === 'function') VideoObserverManager.init(shouldForceBootstrap, preserveMiniplayer, skipCleanup);
};
const debouncedNavigation = debounce(handleNavigation, 100);
// 1. Escuchar eventos estándar de YouTube
globalNavigationListeners.push(
{ target: window, event: 'yt-navigate-finish', handler: debouncedNavigation },
{ target: document, event: 'yt-page-data-updated', handler: debouncedNavigation }
);
window.addEventListener('yt-navigate-finish', debouncedNavigation);
document.addEventListener('yt-page-data-updated', debouncedNavigation);
// 2. Inicializar YouTube Helper API y escuchar sus actualizaciones "silenciosas"
try {
const targetWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : (globalThis ?? window);
if (typeof youtubeHelperApi !== 'undefined') {
YTHelper = youtubeHelperApi;
logInfo('YTHelper', '✅ Referencia a YouTube Helper API obtenida youtubeHelperApi');
} else if (typeof targetWindow.youtubeHelperApi !== 'undefined') {
YTHelper = targetWindow.youtubeHelperApi;
logInfo('YTHelper', '✅ Referencia a YouTube Helper API obtenida targetWindow.youtubeHelperApi');
} else if (targetWindow.youtubeHelperRegistry?.instances?.size > 0) {
YTHelper = Array.from(targetWindow.youtubeHelperRegistry.instances.values())[0];
logInfo('YTHelper', '✅ Referencia a YouTube Helper API obtenida targetWindow.youtubeHelperRegistry');
}
if (YTHelper) {
// Registrar listener persistente que ahora comparte el mismo flujo debounced
ythelperListener = () => {
logInfo('YTHelper', '🆕 Notificación de Video Listo/Actualizado recibida');
debouncedNavigation();
};
YTHelper.eventTarget.addEventListener('yt-helper-api-ready', ythelperListener);
logInfo('YTHelper', 'Registra evento yt-helper-api-ready');
} else {
logError('YTHelper', 'Referencia a YouTube Helper API no obtenida');
}
} catch (error) {
logError('YTHelper', 'Error durante inicializacion YouTube Helper API: ' + error);
}
// 3. Limpiar recursos cuando la página se cierra para prevenir memory leaks
window.addEventListener('unload', () => {
cleanupGlobalListeners();
cleanupThemeObserver();
logLog('initializeGlobal', '🧹 Cleanup de recursos al cerrar página');
});
// --- 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');
LANGUAGE_FLAGS = {
...FALLBACK_FLAGS,
...(externalTranslations.LANGUAGE_FLAGS || externalTranslations.flags || {})
};
TRANSLATIONS = externalTranslations.TRANSLATIONS || externalTranslations.translations || {};
} else {
logWarn('initializeGlobal', ' Traducciones externas incompletas, usando fallback como base');
LANGUAGE_FLAGS = FALLBACK_FLAGS;
TRANSLATIONS = {}; // Se usará FALLBACK_TRANSLATIONS vía la cascada en t()
}
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');
// --- Aplicar tema de YouTube ---
try {
applyTheme();
observeThemeChanges();
logInfo('initializeGlobal', '✅ Sistema de tema iniciado');
} catch (error) {
logError('initializeGlobal', '❌ Error al iniciar sistema de tema:', error);
}
// 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);
}
// --- Inicializar observadores de video ---
try {
// initializeGlobal vuelve a llamar init(), lo que provoca que cleanup()
// detenga la sesión recién iniciada por handleNavigation.
// Solución: evitar cleanup si ya hay observers activos y no es navegación real.
VideoObserverManager.init(false, false, true);
// --- Backup en GitHub (si está habilitado) ---
try {
/**
* Verificar primero si hay configuración válida para backup automático
* antes de programar intervalos innecesarios.
*/
const githubSettings = await GM_getValue(CONFIG.STORAGE_KEYS.github, CONFIG.defaultGithubSettings);
const hasGistBackup = githubSettings?.gist?.autoBackup && githubSettings?.gist?.token;
const hasRepoBackup = githubSettings?.repo?.autoBackup && githubSettings?.repo?.token;
if (!hasGistBackup && !hasRepoBackup) {
logLog('initializeGlobal', '⏭️ Backup automático omitido: no hay configuración válida (autoBackup + token)');
} else {
/**
* Iniciar verificación de respaldo automático y programar chequeo periódico cada 5 min.
* Se agrega un retraso aleatorio (jitter) para evitar que múltiples pestañas
* disparen el chequeo exactamente al mismo tiempo al cargar YouTube.
*/
if (backupIntervalId) clearInterval(backupIntervalId);
const jitterMs = Math.floor(Math.random() * 60 * 1000); // 0-60s de jitter
logLog('initializeGlobal', `Programando primer chequeo de backup en ${Math.round(jitterMs / 1000)}s`);
setTimeout(() => {
checkGitHubBackup();
backupIntervalId = setInterval(checkGitHubBackup, 5 * 60 * 1000);
}, jitterMs);
}
logInfo('initializeGlobal', '✅ VideoObserverManager y observadores inicializados');
} catch (error) {
logError('initializeGlobal', '❌ Error al verificar backup de GitHub:', error);
}
} catch (error) {
logError('initializeGlobal', '❌ Error al inicializar VideoObserverManager:', error);
}
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();
})();